summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-11-16 11:08:21 +0100
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-11-16 11:08:21 +0100
commit04c6609948a770aed30491c66c4c6d779bc71c92 (patch)
tree4184b4e7a8c8ebfe8c4273e9f472779ee8f7bae9 /android/app/src
parent67710f3e2ef57ecbe60c1bcb444ab047f11f79a5 (diff)
parentc8c896bc00b6b23ffaaf3a8708f03efb3ab3a0f2 (diff)
downloadmullvadvpn-04c6609948a770aed30491c66c4c6d779bc71c92.tar.xz
mullvadvpn-04c6609948a770aed30491c66c4c6d779bc71c92.zip
Merge branch 'google-play-in-app-purchases-droid-277'
Diffstat (limited to 'android/app/src')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt234
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt240
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt301
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt191
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt186
-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/dialog/payment/VerificationPendingDialog.kt48
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt72
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt46
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt74
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/PaymentState.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt24
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/MessageHandler.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt74
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt19
-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.kt79
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt64
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt57
-rw-r--r--android/app/src/oss/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt6
-rw-r--r--android/app/src/play/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt14
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt104
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt141
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt152
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt132
38 files changed, 2352 insertions, 90 deletions
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 aec6c85595..e997ae29e4 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
@@ -1,21 +1,33 @@
package net.mullvad.mullvadvpn.compose.screen
+import android.app.Activity
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import io.mockk.MockKAnnotations
+import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
+import net.mullvad.mullvadvpn.compose.state.PaymentState
+import net.mullvad.mullvadvpn.compose.test.PLAY_PAYMENT_INFO_ICON_TEST_TAG
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.mullvadvpn.viewmodel.AccountUiState
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+@OptIn(ExperimentalMaterial3Api::class)
class AccountScreenTest {
@get:Rule val composeTestRule = createComposeRule()
@@ -24,12 +36,12 @@ class AccountScreenTest {
MockKAnnotations.init(this)
}
- @OptIn(ExperimentalMaterial3Api::class)
@Test
fun testDefaultState() {
// Arrange
composeTestRule.setContentWithTheme {
AccountScreen(
+ showSitePayment = true,
uiState =
AccountUiState(
deviceName = DUMMY_DEVICE_NAME,
@@ -48,13 +60,13 @@ class AccountScreenTest {
}
}
- @OptIn(ExperimentalMaterial3Api::class)
@Test
fun testManageAccountClick() {
// Arrange
val mockedClickHandler: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
AccountScreen(
+ showSitePayment = true,
uiState =
AccountUiState(
deviceName = DUMMY_DEVICE_NAME,
@@ -74,13 +86,13 @@ class AccountScreenTest {
verify { mockedClickHandler.invoke() }
}
- @OptIn(ExperimentalMaterial3Api::class)
@Test
fun testRedeemVoucherClick() {
// Arrange
val mockedClickHandler: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
AccountScreen(
+ showSitePayment = true,
uiState =
AccountUiState(
deviceName = DUMMY_DEVICE_NAME,
@@ -100,13 +112,13 @@ class AccountScreenTest {
verify { mockedClickHandler.invoke() }
}
- @OptIn(ExperimentalMaterial3Api::class)
@Test
fun testLogoutClick() {
// Arrange
val mockedClickHandler: () -> Unit = mockk(relaxed = true)
composeTestRule.setContentWithTheme {
AccountScreen(
+ showSitePayment = true,
uiState =
AccountUiState(
deviceName = DUMMY_DEVICE_NAME,
@@ -126,6 +138,220 @@ class AccountScreenTest {
verify { mockedClickHandler.invoke() }
}
+ @Test
+ fun testShowPurchaseCompleteDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default()
+ .copy(
+ paymentDialogData =
+ PurchaseResult.Completed.Success.toPaymentDialogData()
+ ),
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Time was successfully added").assertExists()
+ }
+
+ @Test
+ fun testShowVerificationErrorDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default()
+ .copy(
+ paymentDialogData =
+ PurchaseResult.Error.VerificationError(null).toPaymentDialogData()
+ ),
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Verifying purchase").assertExists()
+ }
+
+ @Test
+ fun testShowFetchProductsErrorDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default()
+ .copy(
+ paymentDialogData =
+ PurchaseResult.Error.FetchProductsError(ProductId(""), null)
+ .toPaymentDialogData()
+ ),
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Google Play unavailable").assertExists()
+ }
+
+ @Test
+ fun testShowBillingErrorPaymentButton() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default().copy(billingPaymentState = PaymentState.Error.Billing),
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Add 30 days time").assertExists()
+ }
+
+ @Test
+ fun testShowBillingPaymentAvailable() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns null
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Add 30 days time ($10)").assertExists()
+ }
+
+ @Test
+ fun testShowPendingPayment() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Google Play payment pending").assertExists()
+ }
+
+ @Test
+ fun testShowPendingPaymentInfoDialog() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick()
+
+ // Assert
+ composeTestRule
+ .onNodeWithText(
+ "We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful."
+ )
+ .assertExists()
+ }
+
+ @Test
+ fun testShowVerificationInProgress() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Verifying purchase").assertExists()
+ }
+
+ @Test
+ fun testOnPurchaseBillingProductClick() {
+ // Arrange
+ val clickHandler: (ProductId, () -> Activity) -> Unit = mockk(relaxed = true)
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID")
+ every { mockPaymentProduct.status } returns null
+ composeTestRule.setContentWithTheme {
+ AccountScreen(
+ showSitePayment = true,
+ uiState =
+ AccountUiState.default()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ onPurchaseBillingProductClick = clickHandler,
+ uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
+ enterTransitionEndAction = MutableSharedFlow<Unit>().asSharedFlow()
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Add 30 days time ($10)").performClick()
+
+ // Assert
+ verify { clickHandler.invoke(ProductId("PRODUCT_ID"), any()) }
+ }
+
companion object {
private const val DUMMY_DEVICE_NAME = "fake_name"
private const val DUMMY_ACCOUNT_NUMBER = "fake_number"
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 b0198316e3..28e2519c81 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt
@@ -1,16 +1,28 @@
package net.mullvad.mullvadvpn.compose.screen
+import android.app.Activity
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import io.mockk.MockKAnnotations
+import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
+import net.mullvad.mullvadvpn.compose.state.PaymentState
+import net.mullvad.mullvadvpn.compose.test.PLAY_PAYMENT_INFO_ICON_TEST_TAG
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel
import org.junit.Before
import org.junit.Rule
@@ -174,4 +186,232 @@ class OutOfTimeScreenTest {
// Assert
verify(exactly = 1) { mockClickListener.invoke() }
}
+
+ @Test
+ fun testShowPurchaseCompleteDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState(
+ paymentDialogData = PurchaseResult.Completed.Success.toPaymentDialogData()
+ ),
+ uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> }
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Time was successfully added").assertExists()
+ }
+
+ @Test
+ fun testShowVerificationErrorDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState(
+ paymentDialogData =
+ PurchaseResult.Error.VerificationError(null).toPaymentDialogData()
+ ),
+ uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> }
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Verifying purchase").assertExists()
+ }
+
+ @Test
+ fun testShowFetchProductsErrorDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState()
+ .copy(
+ paymentDialogData =
+ PurchaseResult.Error.FetchProductsError(ProductId(""), null)
+ .toPaymentDialogData()
+ ),
+ uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Google Play unavailable").assertExists()
+ }
+
+ @Test
+ fun testShowBillingErrorPaymentButton() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState = OutOfTimeUiState().copy(billingPaymentState = PaymentState.Error.Billing),
+ uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> }
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Add 30 days time").assertExists()
+ }
+
+ @Test
+ fun testShowBillingPaymentAvailable() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns null
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> }
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Add 30 days time ($10)").assertExists()
+ }
+
+ @Test
+ fun testShowPendingPayment() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Google Play payment pending").assertExists()
+ }
+
+ @Test
+ fun testShowPendingPaymentInfoDialog() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick()
+
+ // Assert
+ composeTestRule
+ .onNodeWithText(
+ "We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful."
+ )
+ .assertExists()
+ }
+
+ @Test
+ fun testShowVerificationInProgress() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<OutOfTimeViewModel.UiSideEffect>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Verifying purchase").assertExists()
+ }
+
+ @Test
+ fun testOnPurchaseBillingProductClick() {
+ // Arrange
+ val clickHandler: (ProductId, () -> Activity) -> Unit = mockk(relaxed = true)
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID")
+ every { mockPaymentProduct.status } returns null
+ composeTestRule.setContentWithTheme {
+ OutOfTimeScreen(
+ showSitePayment = true,
+ uiState =
+ OutOfTimeUiState(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = clickHandler
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Add 30 days time ($10)").performClick()
+
+ // Assert
+ verify { clickHandler(ProductId("PRODUCT_ID"), any()) }
+ }
}
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 8331794cab..a54c41c20d 100644
--- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt
@@ -1,15 +1,27 @@
package net.mullvad.mullvadvpn.compose.screen
+import android.app.Activity
import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import io.mockk.MockKAnnotations
+import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.compose.setContentWithTheme
+import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
+import net.mullvad.mullvadvpn.compose.test.PLAY_PAYMENT_INFO_ICON_TEST_TAG
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel
import org.junit.Before
import org.junit.Rule
@@ -35,7 +47,9 @@ class WelcomeScreenTest {
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {}
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
)
}
@@ -58,7 +72,9 @@ class WelcomeScreenTest {
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {}
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
)
}
@@ -87,7 +103,9 @@ class WelcomeScreenTest {
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {}
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
)
}
@@ -108,7 +126,9 @@ class WelcomeScreenTest {
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {}
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
)
}
@@ -129,7 +149,9 @@ class WelcomeScreenTest {
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = mockClickListener
+ openConnectScreen = mockClickListener,
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
)
}
@@ -150,7 +172,9 @@ class WelcomeScreenTest {
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {}
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
)
}
@@ -174,7 +198,9 @@ class WelcomeScreenTest {
onRedeemVoucherClick = mockClickListener,
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {}
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
)
}
@@ -184,4 +210,265 @@ class WelcomeScreenTest {
// Assert
verify(exactly = 1) { mockClickListener.invoke() }
}
+
+ @Test
+ fun testShowPurchaseCompleteDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState =
+ WelcomeUiState(
+ paymentDialogData = PurchaseResult.Completed.Success.toPaymentDialogData()
+ ),
+ uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Time was successfully added").assertExists()
+ }
+
+ @Test
+ fun testShowVerificationErrorDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState =
+ WelcomeUiState(
+ paymentDialogData =
+ PurchaseResult.Error.VerificationError(null).toPaymentDialogData()
+ ),
+ uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Verifying purchase").assertExists()
+ }
+
+ @Test
+ fun testShowFetchProductsErrorDialog() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState =
+ WelcomeUiState()
+ .copy(
+ paymentDialogData =
+ PurchaseResult.Error.FetchProductsError(ProductId(""), null)
+ .toPaymentDialogData()
+ ),
+ uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Google Play unavailable").assertExists()
+ }
+
+ @Test
+ fun testShowBillingErrorPaymentButton() {
+ // Arrange
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState = WelcomeUiState().copy(billingPaymentState = PaymentState.Error.Billing),
+ uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onClosePurchaseResultDialog = {},
+ onPurchaseBillingProductClick = { _, _ -> }
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Add 30 days time").assertExists()
+ }
+
+ @Test
+ fun testShowBillingPaymentAvailable() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns null
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState =
+ WelcomeUiState(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Add 30 days time ($10)").assertExists()
+ }
+
+ @Test
+ fun testShowPendingPayment() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState =
+ WelcomeUiState()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Google Play payment pending").assertExists()
+ }
+
+ @Test
+ fun testShowPendingPaymentInfoDialog() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.PENDING
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState =
+ WelcomeUiState()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick()
+
+ // Assert
+ composeTestRule
+ .onNodeWithText(
+ "We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful."
+ )
+ .assertExists()
+ }
+
+ @Test
+ fun testShowVerificationInProgress() {
+ // Arrange
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState =
+ WelcomeUiState()
+ .copy(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
+ )
+ }
+
+ // Assert
+ composeTestRule.onNodeWithText("Verifying purchase").assertExists()
+ }
+
+ @Test
+ fun testOnPurchaseBillingProductClick() {
+ // Arrange
+ val clickHandler: (ProductId, () -> Activity) -> Unit = mockk(relaxed = true)
+ val mockPaymentProduct: PaymentProduct = mockk()
+ every { mockPaymentProduct.price } returns ProductPrice("$10")
+ every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID")
+ every { mockPaymentProduct.status } returns null
+ composeTestRule.setContentWithTheme {
+ WelcomeScreen(
+ showSitePayment = true,
+ uiState =
+ WelcomeUiState(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(listOf(mockPaymentProduct))
+ ),
+ uiSideEffect = MutableStateFlow(WelcomeViewModel.UiSideEffect.OpenConnectScreen),
+ onSitePaymentClick = {},
+ onRedeemVoucherClick = {},
+ onSettingsClick = {},
+ onAccountClick = {},
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = clickHandler,
+ onClosePurchaseResultDialog = {}
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Add 30 days time ($10)").performClick()
+
+ // Assert
+ verify { clickHandler(ProductId("PRODUCT_ID"), any()) }
+ }
}
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
new file mode 100644
index 0000000000..3f396cf698
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt
@@ -0,0 +1,191 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+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.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.VariantButton
+import net.mullvad.mullvadvpn.compose.state.PaymentState
+import net.mullvad.mullvadvpn.compose.test.PLAY_PAYMENT_INFO_ICON_TEST_TAG
+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
+
+@Preview
+@Composable
+private fun PreviewPlayPaymentPaymentAvailable() {
+ AppTheme {
+ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ PlayPayment(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(
+ products =
+ listOf(
+ PaymentProduct(
+ productId = ProductId("test"),
+ price = ProductPrice("$10"),
+ status = null
+ )
+ )
+ ),
+ onPurchaseBillingProductClick = {},
+ onInfoClick = {},
+ modifier = Modifier.padding(Dimens.screenVerticalMargin)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewPlayPaymentLoading() {
+ AppTheme {
+ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ PlayPayment(
+ billingPaymentState = PaymentState.Loading,
+ onPurchaseBillingProductClick = {},
+ onInfoClick = {},
+ modifier = Modifier.padding(Dimens.screenVerticalMargin)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewPlayPaymentPaymentPending() {
+ AppTheme {
+ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ PlayPayment(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(
+ products =
+ listOf(
+ PaymentProduct(
+ productId = ProductId("test"),
+ price = ProductPrice("$10"),
+ status = PaymentStatus.PENDING
+ )
+ )
+ ),
+ onPurchaseBillingProductClick = {},
+ onInfoClick = {},
+ modifier = Modifier.padding(Dimens.screenVerticalMargin)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun PreviewPlayPaymentVerificationInProgress() {
+ AppTheme {
+ Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
+ PlayPayment(
+ billingPaymentState =
+ PaymentState.PaymentAvailable(
+ products =
+ listOf(
+ PaymentProduct(
+ productId = ProductId("test"),
+ price = ProductPrice("$10"),
+ status = PaymentStatus.VERIFICATION_IN_PROGRESS
+ )
+ )
+ ),
+ onPurchaseBillingProductClick = {},
+ onInfoClick = {},
+ modifier = Modifier.padding(Dimens.screenVerticalMargin)
+ )
+ }
+ }
+}
+
+@Composable
+fun PlayPayment(
+ billingPaymentState: PaymentState,
+ onPurchaseBillingProductClick: (ProductId) -> Unit,
+ onInfoClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ when (billingPaymentState) {
+ PaymentState.Loading -> {
+ Column(modifier = modifier.fillMaxWidth()) {
+ MullvadCircularProgressIndicatorSmall(modifier = modifier)
+ }
+ }
+ PaymentState.NoPayment,
+ PaymentState.NoProductsFounds -> {
+ // Show nothing
+ }
+ is PaymentState.PaymentAvailable -> {
+ 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.payment_status_verification_in_progress
+ )
+ else -> null
+ }
+ statusMessage?.let {
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ text = statusMessage,
+ modifier = Modifier.padding(bottom = Dimens.smallPadding)
+ )
+ IconButton(
+ onClick = onInfoClick,
+ modifier = Modifier.testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.icon_info),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
+ }
+ 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)) }
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt
index 89af2eafe9..9ce21c6bac 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt
@@ -50,10 +50,7 @@ fun ChangelogDialog(changesList: List<String>, version: String, onDismiss: () ->
}
},
confirmButton = {
- PrimaryButton(
- text = stringResource(R.string.changes_dialog_dismiss_button),
- onClick = onDismiss
- )
+ PrimaryButton(text = stringResource(R.string.got_it), onClick = onDismiss)
},
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt
index ad12932405..d032a9fa8e 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/InfoDialog.kt
@@ -88,7 +88,7 @@ fun InfoDialog(message: String, additionalInfo: String? = null, onDismiss: () ->
confirmButton = {
PrimaryButton(
modifier = Modifier.wrapContentHeight().fillMaxWidth(),
- text = stringResource(R.string.changes_dialog_dismiss_button),
+ text = stringResource(R.string.got_it),
onClick = onDismiss,
)
},
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt
index 14afdbcf24..c5b619a9cd 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt
@@ -120,7 +120,7 @@ fun RedeemVoucherDialog(
stringResource(
id =
if (uiState.voucherViewModelState is VoucherDialogState.Success)
- R.string.changes_dialog_dismiss_button
+ R.string.got_it
else R.string.cancel
),
onClick = {
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
new file mode 100644
index 0000000000..7e94b7455e
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt
@@ -0,0 +1,186 @@
+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.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
+
+@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.payment_pending_dialog_title,
+ 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.loading_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 = {}
+ )
+ }
+}
+
+@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 -> MullvadCircularProgressIndicatorMedium()
+ 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.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ iconContentColor = Color.Unspecified,
+ textContentColor =
+ MaterialTheme.colorScheme.onBackground
+ .copy(alpha = AlphaDescription)
+ .compositeOver(MaterialTheme.colorScheme.background),
+ 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
new file mode 100644
index 0000000000..9876964610
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt
@@ -0,0 +1,26 @@
+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/dialog/payment/VerificationPendingDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt
new file mode 100644
index 0000000000..112afeebf5
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt
@@ -0,0 +1,48 @@
+package net.mullvad.mullvadvpn.compose.dialog.payment
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.PrimaryButton
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription
+
+@Preview
+@Composable
+private fun PreviewVerificationPendingDialog() {
+ AppTheme { VerificationPendingDialog(onClose = {}) }
+}
+
+@Composable
+fun VerificationPendingDialog(onClose: () -> Unit) {
+ AlertDialog(
+ icon = {}, // Makes it look a bit more balanced
+ title = {
+ Text(
+ text = stringResource(id = R.string.payment_pending_dialog_title),
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(id = R.string.payment_pending_dialog_message),
+ style = MaterialTheme.typography.bodySmall
+ )
+ },
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ textContentColor =
+ MaterialTheme.colorScheme.onBackground
+ .copy(alpha = AlphaDescription)
+ .compositeOver(MaterialTheme.colorScheme.background),
+ onDismissRequest = onClose,
+ confirmButton = {
+ PrimaryButton(text = stringResource(id = R.string.got_it), onClick = onClose)
+ }
+ )
+}
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 34ba02d756..fecd23406a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
@@ -1,5 +1,6 @@
package net.mullvad.mullvadvpn.compose.screen
+import android.app.Activity
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -22,6 +23,7 @@ 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.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
@@ -37,11 +39,19 @@ 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.NavigateBackDownIconButton
+import net.mullvad.mullvadvpn.compose.component.PlayPayment
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar
import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog
+import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog
+import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook
+import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView
-import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
+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.util.toExpiryDateString
@@ -55,11 +65,27 @@ import org.joda.time.DateTime
private fun PreviewAccountScreen() {
AppTheme {
AccountScreen(
+ showSitePayment = true,
uiState =
AccountUiState(
deviceName = "Test Name",
accountNumber = "1234123412341234",
- accountExpiry = null
+ accountExpiry = null,
+ billingPaymentState =
+ PaymentState.PaymentAvailable(
+ listOf(
+ PaymentProduct(
+ ProductId("productId"),
+ price = ProductPrice("34 SEK"),
+ status = null
+ ),
+ PaymentProduct(
+ ProductId("productId_pending"),
+ price = ProductPrice("34 SEK"),
+ status = PaymentStatus.PENDING
+ )
+ ),
+ )
),
uiSideEffect = MutableSharedFlow<AccountViewModel.UiSideEffect>().asSharedFlow(),
enterTransitionEndAction = MutableSharedFlow()
@@ -70,12 +96,18 @@ private fun PreviewAccountScreen() {
@ExperimentalMaterial3Api
@Composable
fun AccountScreen(
+ showSitePayment: Boolean,
uiState: AccountUiState,
uiSideEffect: SharedFlow<AccountViewModel.UiSideEffect>,
enterTransitionEndAction: SharedFlow<Unit>,
onRedeemVoucherClick: () -> Unit = {},
onManageAccountClick: () -> Unit = {},
onLogoutClick: () -> Unit = {},
+ onPurchaseBillingProductClick:
+ (productId: ProductId, activityProvider: () -> Activity) -> Unit =
+ { _, _ ->
+ },
+ onClosePurchaseResultDialog: (success: Boolean) -> Unit = {},
onBackClick: () -> Unit = {}
) {
// This will enable SECURE_FLAG while this screen is visible to preview screenshot
@@ -84,17 +116,38 @@ fun AccountScreen(
val context = LocalContext.current
val backgroundColor = MaterialTheme.colorScheme.background
val systemUiController = rememberSystemUiController()
-
var showDeviceNameInfoDialog by remember { mutableStateOf(false) }
+ var showVerificationPendingDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(backgroundColor)
enterTransitionEndAction.collect { systemUiController.setStatusBarColor(backgroundColor) }
}
+ val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook()
+ LaunchedEffect(Unit) {
+ uiSideEffect.collect { viewAction ->
+ if (viewAction is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) {
+ openAccountPage(viewAction.token)
+ }
+ }
+ }
+
if (showDeviceNameInfoDialog) {
DeviceNameInfoDialog { showDeviceNameInfoDialog = false }
}
+ if (showVerificationPendingDialog) {
+ VerificationPendingDialog(onClose = { showVerificationPendingDialog = false })
+ }
+
+ uiState.paymentDialogData?.let {
+ PaymentDialog(
+ paymentDialogData = uiState.paymentDialogData,
+ retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } },
+ onCloseDialog = onClosePurchaseResultDialog
+ )
+ }
+
LaunchedEffect(Unit) {
uiSideEffect.collect { uiSideEffect ->
if (uiSideEffect is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) {
@@ -121,7 +174,18 @@ fun AccountScreen(
Spacer(modifier = Modifier.weight(1f))
Column(modifier = Modifier.padding(bottom = Dimens.screenVerticalMargin)) {
- if (IS_PLAY_BUILD.not()) {
+ uiState.billingPaymentState?.let {
+ PlayPayment(
+ billingPaymentState = uiState.billingPaymentState,
+ onPurchaseBillingProductClick = { productId ->
+ onPurchaseBillingProductClick(productId) { context as Activity }
+ },
+ onInfoClick = { showVerificationPendingDialog = true },
+ modifier = Modifier.padding(bottom = Dimens.buttonSpacing)
+ )
+ }
+
+ if (showSitePayment) {
ExternalButton(
text = stringResource(id = R.string.manage_account),
onClick = onManageAccountClick,
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 efb07acfa2..a7fd6bae2f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt
@@ -1,5 +1,6 @@
package net.mullvad.mullvadvpn.compose.screen
+import android.app.Activity
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
@@ -14,8 +15,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -27,10 +33,14 @@ 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.component.ScaffoldWithTopBarAndDeviceName
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog
+import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog
import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
+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
@@ -95,8 +105,12 @@ fun OutOfTimeScreen(
onRedeemVoucherClick: () -> Unit = {},
openConnectScreen: () -> Unit = {},
onSettingsClick: () -> Unit = {},
- onAccountClick: () -> Unit = {}
+ onAccountClick: () -> Unit = {},
+ onPurchaseBillingProductClick: (ProductId, activityProvider: () -> Activity) -> Unit = { _, _ ->
+ },
+ onClosePurchaseResultDialog: (success: Boolean) -> Unit = {}
) {
+ val context = LocalContext.current
val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook()
LaunchedEffect(key1 = Unit) {
uiSideEffect.collect { uiSideEffect ->
@@ -107,6 +121,20 @@ fun OutOfTimeScreen(
}
}
}
+
+ var showVerificationPendingDialog by remember { mutableStateOf(false) }
+ if (showVerificationPendingDialog) {
+ VerificationPendingDialog(onClose = { showVerificationPendingDialog = false })
+ }
+
+ uiState.paymentDialogData?.let {
+ PaymentDialog(
+ paymentDialogData = uiState.paymentDialogData,
+ retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } },
+ onCloseDialog = onClosePurchaseResultDialog
+ )
+ }
+
val scrollState = rememberScrollState()
ScaffoldWithTopBarAndDeviceName(
topBarColor =
@@ -191,6 +219,22 @@ fun OutOfTimeScreen(
)
)
}
+ uiState.billingPaymentState?.let {
+ PlayPayment(
+ billingPaymentState = uiState.billingPaymentState,
+ onPurchaseBillingProductClick = { productId ->
+ onPurchaseBillingProductClick(productId) { context as Activity }
+ },
+ onInfoClick = { showVerificationPendingDialog = true },
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ )
+ .align(Alignment.CenterHorizontally)
+ )
+ }
if (showSitePayment) {
SitePaymentButton(
onClick = onSitePaymentClick,
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 f3c9f9dc7e..d26e8c8265 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt
@@ -1,5 +1,6 @@
package net.mullvad.mullvadvpn.compose.screen
+import android.app.Activity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -36,13 +37,20 @@ import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton
import net.mullvad.mullvadvpn.compose.button.SitePaymentButton
import net.mullvad.mullvadvpn.compose.component.CopyAnimatedIconButton
+import net.mullvad.mullvadvpn.compose.component.PlayPayment
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog
+import net.mullvad.mullvadvpn.compose.dialog.payment.VerificationPendingDialog
+import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle
import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces
import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
+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.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
@@ -56,13 +64,26 @@ private fun PreviewWelcomeScreen() {
AppTheme {
WelcomeScreen(
showSitePayment = true,
- uiState = WelcomeUiState(accountNumber = "4444555566667777", deviceName = "Happy Mole"),
+ uiState =
+ WelcomeUiState(
+ accountNumber = "4444555566667777",
+ deviceName = "Happy Mole",
+ billingPaymentState =
+ PaymentState.PaymentAvailable(
+ products =
+ listOf(
+ PaymentProduct(ProductId("product"), ProductPrice("$44"), null)
+ )
+ )
+ ),
uiSideEffect = MutableSharedFlow<WelcomeViewModel.UiSideEffect>().asSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
- openConnectScreen = {}
+ openConnectScreen = {},
+ onPurchaseBillingProductClick = { _, _ -> },
+ onClosePurchaseResultDialog = {}
)
}
}
@@ -76,7 +97,9 @@ fun WelcomeScreen(
onRedeemVoucherClick: () -> Unit,
onSettingsClick: () -> Unit,
onAccountClick: () -> Unit,
- openConnectScreen: () -> Unit
+ openConnectScreen: () -> Unit,
+ onPurchaseBillingProductClick: (productId: ProductId, activityProvider: () -> Activity) -> Unit,
+ onClosePurchaseResultDialog: (success: Boolean) -> Unit
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
@@ -88,6 +111,20 @@ fun WelcomeScreen(
}
}
}
+
+ var showVerificationPendingDialog by remember { mutableStateOf(false) }
+ if (showVerificationPendingDialog) {
+ VerificationPendingDialog(onClose = { showVerificationPendingDialog = false })
+ }
+
+ uiState.paymentDialogData?.let {
+ PaymentDialog(
+ paymentDialogData = uiState.paymentDialogData,
+ retryPurchase = { onPurchaseBillingProductClick(it) { context as Activity } },
+ onCloseDialog = onClosePurchaseResultDialog
+ )
+ }
+
val scrollState = rememberScrollState()
val snackbarHostState = remember { SnackbarHostState() }
@@ -133,7 +170,14 @@ fun WelcomeScreen(
Spacer(modifier = Modifier.weight(1f))
// Payment button area
- PaymentPanel(showSitePayment, onSitePaymentClick, onRedeemVoucherClick)
+ PaymentPanel(
+ showSitePayment = showSitePayment,
+ billingPaymentState = uiState.billingPaymentState,
+ onSitePaymentClick = onSitePaymentClick,
+ onRedeemVoucherClick = onRedeemVoucherClick,
+ onPurchaseBillingProductClick = onPurchaseBillingProductClick,
+ onPaymentInfoClick = { showVerificationPendingDialog = true }
+ )
}
}
}
@@ -264,9 +308,13 @@ fun DeviceNameRow(deviceName: String?) {
@Composable
private fun PaymentPanel(
showSitePayment: Boolean,
+ billingPaymentState: PaymentState?,
onSitePaymentClick: () -> Unit,
- onRedeemVoucherClick: () -> Unit
+ onRedeemVoucherClick: () -> Unit,
+ onPurchaseBillingProductClick: (productId: ProductId, activityProvider: () -> Activity) -> Unit,
+ onPaymentInfoClick: () -> Unit
) {
+ val context = LocalContext.current
Column(
modifier =
Modifier.fillMaxWidth()
@@ -274,6 +322,22 @@ private fun PaymentPanel(
.background(color = MaterialTheme.colorScheme.background)
) {
Spacer(modifier = Modifier.padding(top = Dimens.screenVerticalMargin))
+ billingPaymentState?.let {
+ PlayPayment(
+ billingPaymentState = billingPaymentState,
+ onPurchaseBillingProductClick = { productId ->
+ onPurchaseBillingProductClick(productId) { context as Activity }
+ },
+ onInfoClick = onPaymentInfoClick,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ )
+ .align(Alignment.CenterHorizontally)
+ )
+ }
if (showSitePayment) {
SitePaymentButton(
onClick = onSitePaymentClick,
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 f7794e5a55..0491f80ea0 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt
@@ -1,8 +1,11 @@
package net.mullvad.mullvadvpn.compose.state
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.model.TunnelState
data class OutOfTimeUiState(
val tunnelState: TunnelState = TunnelState.Disconnected,
- val deviceName: String
+ val deviceName: String = "",
+ val billingPaymentState: PaymentState? = null,
+ val paymentDialogData: PaymentDialogData? = null
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/PaymentState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/PaymentState.kt
new file mode 100644
index 0000000000..60f8d5864f
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/PaymentState.kt
@@ -0,0 +1,19 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
+
+sealed interface PaymentState {
+ data object Loading : PaymentState
+
+ data object NoPayment : PaymentState
+
+ data object NoProductsFounds : PaymentState
+
+ data class PaymentAvailable(val products: List<PaymentProduct>) : PaymentState
+
+ sealed interface Error : PaymentState {
+ data object Generic : Error
+
+ data object Billing : Error
+ }
+}
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 c6959f23e0..bd1c19e9c9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt
@@ -1,9 +1,12 @@
package net.mullvad.mullvadvpn.compose.state
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.model.TunnelState
data class WelcomeUiState(
val tunnelState: TunnelState = TunnelState.Disconnected,
val accountNumber: String? = null,
- val deviceName: String? = null
+ val deviceName: String? = null,
+ val billingPaymentState: PaymentState? = null,
+ val paymentDialogData: PaymentDialogData? = null
)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
index dea9e12a3d..14a42403e1 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt
@@ -26,4 +26,7 @@ const val LOCATION_INFO_TEST_TAG = "location_info_test_tag"
const val NOTIFICATION_BANNER = "notification_banner"
const val NOTIFICATION_BANNER_ACTION = "notification_banner_action"
+// PlayPayment
+const val PLAY_PAYMENT_INFO_ICON_TEST_TAG = "play_payment_info_icon_test_tag"
+
const val LOGIN_TITLE_TEST_TAG = "login_title_test_tag"
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 bfd3f061d5..5be527ac0c 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
@@ -11,18 +11,22 @@ import net.mullvad.mullvadvpn.applist.ApplicationsIconManager
import net.mullvad.mullvadvpn.applist.ApplicationsProvider
import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport
import net.mullvad.mullvadvpn.lib.ipc.EventDispatcher
+import net.mullvad.mullvadvpn.lib.ipc.MessageHandler
+import net.mullvad.mullvadvpn.lib.payment.PaymentProvider
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.ChangelogRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
-import net.mullvad.mullvadvpn.ui.serviceconnection.MessageHandler
import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
import net.mullvad.mullvadvpn.usecase.AccountExpiryNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.EmptyPaymentUseCase
import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
+import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase
import net.mullvad.mullvadvpn.usecase.PortRangeUseCase
import net.mullvad.mullvadvpn.usecase.RelayListUseCase
import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase
@@ -100,8 +104,20 @@ val uiModule = module {
single { RelayListListener(get()) }
+ // Will be resolved using from either of the two PaymentModule.kt classes.
+ single { PaymentProvider(get()) }
+
+ single<PaymentUseCase> {
+ val paymentRepository = get<PaymentProvider>().paymentRepository
+ if (paymentRepository != null) {
+ PlayPaymentUseCase(paymentRepository = paymentRepository)
+ } else {
+ EmptyPaymentUseCase()
+ }
+ }
+
// View models
- viewModel { AccountViewModel(get(), get(), get()) }
+ viewModel { AccountViewModel(get(), get(), get(), get()) }
viewModel {
ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG)
}
@@ -114,10 +130,10 @@ val uiModule = module {
viewModel { SettingsViewModel(get(), get()) }
viewModel { VoucherDialogViewModel(get(), get()) }
viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) }
- viewModel { WelcomeViewModel(get(), get(), get()) }
+ viewModel { WelcomeViewModel(get(), get(), get(), get()) }
viewModel { ReportProblemViewModel(get()) }
viewModel { ViewLogsViewModel(get()) }
- viewModel { OutOfTimeViewModel(get(), get(), get()) }
+ viewModel { OutOfTimeViewModel(get(), get(), get(), get()) }
}
const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
index 1d8c49224a..eb618ea0f8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AccountRepository.kt
@@ -17,13 +17,13 @@ import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.MessageHandler
import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.lib.ipc.events
import net.mullvad.mullvadvpn.model.AccountCreationResult
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.AccountHistory
import net.mullvad.mullvadvpn.model.LoginResult
-import net.mullvad.mullvadvpn.ui.serviceconnection.MessageHandler
-import net.mullvad.mullvadvpn.ui.serviceconnection.events
class AccountRepository(
private val messageHandler: MessageHandler,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
index 98b0c0576c..f299b8c956 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
@@ -28,6 +28,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import net.mullvad.mullvadvpn.BuildConfig
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog
+import net.mullvad.mullvadvpn.di.paymentModule
import net.mullvad.mullvadvpn.di.uiModule
import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted
import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
@@ -54,6 +55,7 @@ import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import org.koin.android.ext.android.getKoin
import org.koin.core.context.loadKoinModules
+import org.koin.dsl.bind
open class MainActivity : FragmentActivity() {
private val requestNotificationPermissionLauncher =
@@ -78,7 +80,7 @@ open class MainActivity : FragmentActivity() {
private var currentDeviceState: DeviceState? = null
override fun onCreate(savedInstanceState: Bundle?) {
- loadKoinModules(uiModule)
+ loadKoinModules(listOf(uiModule, paymentModule))
getKoin().apply {
accountRepository = get()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
index efdc0783a3..5225368dac 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
@@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.ComposeView
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.screen.AccountScreen
+import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -27,12 +28,15 @@ class AccountFragment : BaseFragment() {
AppTheme {
val state = vm.uiState.collectAsState().value
AccountScreen(
+ showSitePayment = IS_PLAY_BUILD.not(),
uiState = state,
uiSideEffect = vm.uiSideEffect,
enterTransitionEndAction = vm.enterTransitionEndAction,
onRedeemVoucherClick = { openRedeemVoucherFragment() },
onManageAccountClick = vm::onManageAccountClick,
onLogoutClick = vm::onLogoutClick,
+ onPurchaseBillingProductClick = vm::startBillingPayment,
+ onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog,
onBackClick = { activity?.onBackPressedDispatcher?.onBackPressed() }
)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt
index 53df05c5f3..5a1ae49e1a 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt
@@ -36,7 +36,9 @@ class OutOfTimeFragment : BaseFragment() {
onSettingsClick = ::openSettingsView,
onAccountClick = ::openAccountView,
openConnectScreen = ::advanceToConnectScreen,
- onDisconnectClick = vm::onDisconnectClick
+ onDisconnectClick = vm::onDisconnectClick,
+ onPurchaseBillingProductClick = vm::startBillingPayment,
+ onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt
index d04c5de53a..5c5e0c83f8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/WelcomeFragment.kt
@@ -35,7 +35,9 @@ class WelcomeFragment : BaseFragment() {
onRedeemVoucherClick = ::openRedeemVoucherFragment,
onSettingsClick = ::openSettingsView,
onAccountClick = ::openAccountView,
- openConnectScreen = ::advanceToConnectScreen
+ openConnectScreen = ::advanceToConnectScreen,
+ onPurchaseBillingProductClick = vm::startBillingPayment,
+ onClosePurchaseResultDialog = vm::onClosePurchaseResultDialog
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/MessageHandler.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/MessageHandler.kt
deleted file mode 100644
index 3bbcae9361..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/MessageHandler.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package net.mullvad.mullvadvpn.ui.serviceconnection
-
-import kotlin.reflect.KClass
-import kotlinx.coroutines.flow.Flow
-import net.mullvad.mullvadvpn.lib.ipc.Event
-import net.mullvad.mullvadvpn.lib.ipc.Request
-
-interface MessageHandler {
- fun <R : Event> events(klass: KClass<R>): Flow<R>
-
- fun trySendRequest(request: Request): Boolean
-}
-
-inline fun <reified R : Event> MessageHandler.events(): Flow<R> {
- return this.events(R::class)
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt
index 0a1767624c..30b8540bf9 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt
@@ -9,7 +9,9 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.MessageHandler
import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.lib.ipc.events
import net.mullvad.mullvadvpn.model.Constraint
import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
import net.mullvad.mullvadvpn.model.Ownership
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
index 556d69ecfe..7f44b0c7d4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt
@@ -17,6 +17,7 @@ import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
import net.mullvad.mullvadvpn.lib.endpoint.BuildConfig
import net.mullvad.mullvadvpn.lib.endpoint.putApiEndpointConfigurationExtra
import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.MessageHandler
import net.mullvad.mullvadvpn.lib.ipc.Request
import net.mullvad.mullvadvpn.service.MullvadVpnService
import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault
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
new file mode 100644
index 0000000000..151e2caec7
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt
@@ -0,0 +1,74 @@
+package net.mullvad.mullvadvpn.usecase
+
+import android.app.Activity
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import net.mullvad.mullvadvpn.lib.payment.PaymentRepository
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
+import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult
+
+interface PaymentUseCase {
+ val paymentAvailability: Flow<PaymentAvailability?>
+ val purchaseResult: Flow<PurchaseResult?>
+
+ suspend fun purchaseProduct(productId: ProductId, activityProvider: () -> Activity)
+
+ suspend fun queryPaymentAvailability()
+
+ suspend fun resetPurchaseResult()
+
+ suspend fun verifyPurchases()
+}
+
+class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : PaymentUseCase {
+ private val _paymentAvailability = MutableStateFlow<PaymentAvailability?>(null)
+ private val _purchaseResult = MutableStateFlow<PurchaseResult?>(null)
+
+ override val paymentAvailability = _paymentAvailability.asStateFlow()
+ override val purchaseResult = _purchaseResult.asStateFlow()
+
+ override suspend fun purchaseProduct(productId: ProductId, activityProvider: () -> Activity) {
+ paymentRepository.purchaseProduct(productId, activityProvider).collect(_purchaseResult)
+ }
+
+ override suspend fun queryPaymentAvailability() {
+ paymentRepository.queryPaymentAvailability().collect(_paymentAvailability)
+ }
+
+ override suspend fun resetPurchaseResult() {
+ _purchaseResult.emit(null)
+ }
+
+ override suspend fun verifyPurchases() {
+ paymentRepository.verifyPurchases().collect {
+ if (it == VerificationResult.Success) {
+ // Update the payment availability after a successful verification.
+ queryPaymentAvailability()
+ }
+ }
+ }
+}
+
+class EmptyPaymentUseCase : PaymentUseCase {
+ override val paymentAvailability = MutableStateFlow(PaymentAvailability.ProductsUnavailable)
+ override val purchaseResult = MutableStateFlow<PurchaseResult?>(null)
+
+ override suspend fun purchaseProduct(productId: ProductId, activityProvider: () -> Activity) {
+ // No op
+ }
+
+ override suspend fun queryPaymentAvailability() {
+ // No op
+ }
+
+ override suspend fun resetPurchaseResult() {
+ // No op
+ }
+
+ override suspend fun verifyPurchases() {
+ // No op
+ }
+}
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
new file mode 100644
index 0000000000..6a69a807f1
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt
@@ -0,0 +1,19 @@
+package net.mullvad.mullvadvpn.util
+
+import net.mullvad.mullvadvpn.compose.state.PaymentState
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability
+
+fun PaymentAvailability.toPaymentState(): PaymentState =
+ when (this) {
+ PaymentAvailability.Error.ServiceUnavailable,
+ PaymentAvailability.Error.BillingUnavailable -> PaymentState.Error.Billing
+ is PaymentAvailability.Error.Other -> PaymentState.Error.Generic
+ is PaymentAvailability.ProductsAvailable -> PaymentState.PaymentAvailable(products)
+ PaymentAvailability.ProductsUnavailable -> PaymentState.NoPayment
+ PaymentAvailability.NoProductsFounds -> PaymentState.NoProductsFounds
+ PaymentAvailability.Loading -> PaymentState.Loading
+ // Unrecoverable error states
+ PaymentAvailability.Error.DeveloperError,
+ PaymentAvailability.Error.FeatureNotSupported,
+ PaymentAvailability.Error.ItemUnavailable -> PaymentState.NoPayment
+ }
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
new file mode 100644
index 0000000000..bf6dbec35e
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt
@@ -0,0 +1,78 @@
+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.loading_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.payment_pending_dialog_title,
+ 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 fb3e3d6393..5f72167499 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,39 +1,54 @@
package net.mullvad.mullvadvpn.viewmodel
+import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
+import net.mullvad.mullvadvpn.compose.state.PaymentState
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.DeviceState
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
+import net.mullvad.mullvadvpn.util.toPaymentState
import org.joda.time.DateTime
class AccountViewModel(
- private var accountRepository: AccountRepository,
- private var serviceConnectionManager: ServiceConnectionManager,
+ private val accountRepository: AccountRepository,
+ private val serviceConnectionManager: ServiceConnectionManager,
+ private val paymentUseCase: PaymentUseCase,
deviceRepository: DeviceRepository
) : ViewModel() {
private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1)
private val _enterTransitionEndAction = MutableSharedFlow<Unit>()
+
val uiSideEffect = _uiSideEffect.asSharedFlow()
- val uiState =
- combine(deviceRepository.deviceState, accountRepository.accountExpiryState) {
- deviceState,
- accountExpiry ->
+ val uiState: StateFlow<AccountUiState> =
+ combine(
+ deviceRepository.deviceState,
+ accountRepository.accountExpiryState,
+ paymentUseCase.purchaseResult,
+ paymentUseCase.paymentAvailability
+ ) { deviceState, accountExpiry, purchaseResult, paymentAvailability ->
AccountUiState(
- deviceName = deviceState.deviceName(),
- accountNumber = deviceState.token(),
- accountExpiry = accountExpiry.date()
+ deviceName = deviceState.deviceName() ?: "",
+ accountNumber = deviceState.token() ?: "",
+ accountExpiry = accountExpiry.date(),
+ paymentDialogData = purchaseResult?.toPaymentDialogData(),
+ billingPaymentState = paymentAvailability?.toPaymentState()
)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AccountUiState.default())
@@ -42,7 +57,9 @@ class AccountViewModel(
val enterTransitionEndAction = _enterTransitionEndAction.asSharedFlow()
init {
- accountRepository.fetchAccountExpiry()
+ updateAccountExpiry()
+ verifyPurchases()
+ fetchPaymentAvailability()
}
fun onManageAccountClick() {
@@ -63,6 +80,40 @@ class AccountViewModel(
viewModelScope.launch { _enterTransitionEndAction.emit(Unit) }
}
+ fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) {
+ viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) }
+ }
+
+ private fun verifyPurchases() {
+ viewModelScope.launch {
+ paymentUseCase.verifyPurchases()
+ updateAccountExpiry()
+ }
+ }
+
+ 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.
+ // In those cases we want to update the both the payment availability and the account
+ // expiry.
+ if (success) {
+ updateAccountExpiry()
+ } else {
+ fetchPaymentAvailability()
+ }
+ viewModelScope.launch {
+ paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again.
+ }
+ }
+
+ private fun updateAccountExpiry() {
+ accountRepository.fetchAccountExpiry()
+ }
+
sealed class UiSideEffect {
data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect()
}
@@ -71,14 +122,18 @@ class AccountViewModel(
data class AccountUiState(
val deviceName: String?,
val accountNumber: String?,
- val accountExpiry: DateTime?
+ val accountExpiry: DateTime?,
+ val billingPaymentState: PaymentState? = null,
+ val paymentDialogData: PaymentDialogData? = null
) {
companion object {
fun default() =
AccountUiState(
deviceName = DeviceState.Unknown.deviceName(),
accountNumber = DeviceState.Unknown.token(),
- accountExpiry = AccountExpiry.Missing.date()
+ accountExpiry = AccountExpiry.Missing.date(),
+ billingPaymentState = PaymentState.Loading,
+ paymentDialogData = null,
)
}
}
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 b1df2d2225..e570f7a0fe 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt
@@ -1,14 +1,15 @@
package net.mullvad.mullvadvpn.viewmodel
+import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
@@ -24,14 +26,17 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
+import net.mullvad.mullvadvpn.util.toPaymentState
import org.joda.time.DateTime
-@OptIn(FlowPreview::class)
class OutOfTimeViewModel(
private val accountRepository: AccountRepository,
private val serviceConnectionManager: ServiceConnectionManager,
private val deviceRepository: DeviceRepository,
+ private val paymentUseCase: PaymentUseCase,
private val pollAccountExpiry: Boolean = true,
) : ViewModel() {
@@ -48,21 +53,21 @@ class OutOfTimeViewModel(
}
}
.flatMapLatest { serviceConnection ->
- kotlinx.coroutines.flow.combine(
+ combine(
serviceConnection.connectionProxy.tunnelStateFlow(),
- deviceRepository.deviceState
- ) { tunnelState, deviceState ->
+ deviceRepository.deviceState,
+ paymentUseCase.paymentAvailability,
+ paymentUseCase.purchaseResult
+ ) { tunnelState, deviceState, paymentAvailability, purchaseResult ->
OutOfTimeUiState(
tunnelState = tunnelState,
deviceName = deviceState.deviceName() ?: "",
+ billingPaymentState = paymentAvailability?.toPaymentState(),
+ paymentDialogData = purchaseResult?.toPaymentDialogData()
)
}
}
- .stateIn(
- viewModelScope,
- SharingStarted.WhileSubscribed(),
- OutOfTimeUiState(deviceName = "")
- )
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), OutOfTimeUiState())
init {
viewModelScope.launch {
@@ -78,10 +83,12 @@ class OutOfTimeViewModel(
}
viewModelScope.launch {
while (pollAccountExpiry) {
- accountRepository.fetchAccountExpiry()
+ updateAccountExpiry()
delay(ACCOUNT_EXPIRY_POLL_INTERVAL)
}
}
+ verifyPurchases()
+ fetchPaymentAvailability()
}
private fun ConnectionProxy.tunnelStateFlow(): Flow<TunnelState> =
@@ -101,6 +108,41 @@ class OutOfTimeViewModel(
viewModelScope.launch { serviceConnectionManager.connectionProxy()?.disconnect() }
}
+ fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) {
+ viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) }
+ }
+
+ private fun verifyPurchases() {
+ viewModelScope.launch {
+ paymentUseCase.verifyPurchases()
+ updateAccountExpiry()
+ }
+ }
+
+ 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.
+ // In those cases we want to update the both the payment availability and the account
+ // expiry.
+ if (success) {
+ updateAccountExpiry()
+ _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen)
+ } else {
+ fetchPaymentAvailability()
+ }
+ viewModelScope.launch {
+ paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again.
+ }
+ }
+
+ private fun updateAccountExpiry() {
+ accountRepository.fetchAccountExpiry()
+ }
+
sealed interface UiSideEffect {
data class OpenAccountView(val token: String) : UiSideEffect
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 6c9b2ea75d..b02a1599a4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt
@@ -1,5 +1,6 @@
package net.mullvad.mullvadvpn.viewmodel
+import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.FlowPreview
@@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
@@ -25,9 +27,12 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS
import net.mullvad.mullvadvpn.util.addDebounceForUnknownState
import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
+import net.mullvad.mullvadvpn.util.toPaymentState
import org.joda.time.DateTime
@OptIn(FlowPreview::class)
@@ -35,9 +40,9 @@ class WelcomeViewModel(
private val accountRepository: AccountRepository,
private val deviceRepository: DeviceRepository,
private val serviceConnectionManager: ServiceConnectionManager,
+ private val paymentUseCase: PaymentUseCase,
private val pollAccountExpiry: Boolean = true
) : ViewModel() {
-
private val _uiSideEffect = MutableSharedFlow<UiSideEffect>(extraBufferCapacity = 1)
val uiSideEffect = _uiSideEffect.asSharedFlow()
@@ -55,12 +60,16 @@ class WelcomeViewModel(
serviceConnection.connectionProxy.tunnelUiStateFlow(),
deviceRepository.deviceState.debounce {
it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS)
- }
- ) { tunnelState, deviceState ->
+ },
+ paymentUseCase.paymentAvailability,
+ paymentUseCase.purchaseResult
+ ) { tunnelState, deviceState, paymentAvailability, purchaseResult ->
WelcomeUiState(
tunnelState = tunnelState,
accountNumber = deviceState.token(),
- deviceName = deviceState.deviceName()
+ deviceName = deviceState.deviceName(),
+ billingPaymentState = paymentAvailability?.toPaymentState(),
+ paymentDialogData = purchaseResult?.toPaymentDialogData()
)
}
}
@@ -80,10 +89,12 @@ class WelcomeViewModel(
}
viewModelScope.launch {
while (pollAccountExpiry) {
- accountRepository.fetchAccountExpiry()
+ updateAccountExpiry()
delay(ACCOUNT_EXPIRY_POLL_INTERVAL)
}
}
+ verifyPurchases()
+ fetchPaymentAvailability()
}
private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> =
@@ -99,6 +110,42 @@ class WelcomeViewModel(
}
}
+ fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) {
+ viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) }
+ }
+
+ private fun verifyPurchases() {
+ viewModelScope.launch {
+ paymentUseCase.verifyPurchases()
+ updateAccountExpiry()
+ }
+ }
+
+ @OptIn(FlowPreview::class)
+ 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.
+ // In those cases we want to update the both the payment availability and the account
+ // expiry.
+ if (success) {
+ updateAccountExpiry()
+ _uiSideEffect.tryEmit(UiSideEffect.OpenConnectScreen)
+ } else {
+ fetchPaymentAvailability()
+ }
+ viewModelScope.launch {
+ paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again.
+ }
+ }
+
+ private fun updateAccountExpiry() {
+ accountRepository.fetchAccountExpiry()
+ }
+
sealed interface UiSideEffect {
data class OpenAccountView(val token: String) : UiSideEffect
diff --git a/android/app/src/oss/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt b/android/app/src/oss/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt
new file mode 100644
index 0000000000..cb5cb649a6
--- /dev/null
+++ b/android/app/src/oss/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.di
+
+import net.mullvad.mullvadvpn.lib.payment.PaymentProvider
+import org.koin.dsl.module
+
+val paymentModule = module { single { PaymentProvider(null) } }
diff --git a/android/app/src/play/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt b/android/app/src/play/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt
new file mode 100644
index 0000000000..82738b5246
--- /dev/null
+++ b/android/app/src/play/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt
@@ -0,0 +1,14 @@
+package net.mullvad.mullvadvpn.di
+
+import net.mullvad.mullvadvpn.lib.billing.BillingPaymentRepository
+import net.mullvad.mullvadvpn.lib.billing.BillingRepository
+import net.mullvad.mullvadvpn.lib.billing.PlayPurchaseRepository
+import net.mullvad.mullvadvpn.lib.payment.PaymentProvider
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+
+val paymentModule = module {
+ single { BillingRepository(androidContext()) }
+ single { PaymentProvider(BillingPaymentRepository(get(), get())) }
+ single { PlayPurchaseRepository(get()) }
+}
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
new file mode 100644
index 0000000000..a1d8bee37a
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt
@@ -0,0 +1,104 @@
+package net.mullvad.mullvadvpn.usecase
+
+import app.cash.turbine.test
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.payment.PaymentRepository
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
+import org.junit.Test
+
+class PlayPaymentUseCaseTest {
+
+ private val mockPaymentRepository: PaymentRepository = mockk(relaxed = true)
+
+ private val playPaymentUseCase = PlayPaymentUseCase(mockPaymentRepository)
+
+ @Test
+ fun testUpdatePaymentAvailability() = runTest {
+ // Arrange
+ val productsUnavailable = PaymentAvailability.ProductsUnavailable
+ val paymentRepositoryQueryPaymentAvailabilityFlow = flow { emit(productsUnavailable) }
+ every { mockPaymentRepository.queryPaymentAvailability() } returns
+ paymentRepositoryQueryPaymentAvailabilityFlow
+
+ // Act, Assert
+ playPaymentUseCase.paymentAvailability.test {
+ assertNull(awaitItem())
+ playPaymentUseCase.queryPaymentAvailability()
+ assertEquals(productsUnavailable, awaitItem())
+ }
+ }
+
+ @Test
+ fun testUpdatePurchaseResult() = runTest {
+ // Arrange
+ val fetchingProducts = PurchaseResult.FetchingProducts
+ val productId = ProductId("productId")
+ val paymentRepositoryPurchaseResultFlow = flow { emit(fetchingProducts) }
+ every { mockPaymentRepository.purchaseProduct(any(), any()) } returns
+ paymentRepositoryPurchaseResultFlow
+
+ // Act, Assert
+ playPaymentUseCase.purchaseResult.test {
+ assertNull(awaitItem())
+ playPaymentUseCase.purchaseProduct(productId, mockk())
+ assertEquals(fetchingProducts, awaitItem())
+ }
+ }
+
+ @Test
+ fun testPurchaseProduct() = runTest {
+ // Arrange
+ val productId = ProductId("productId")
+
+ // Act
+ playPaymentUseCase.purchaseProduct(productId, mockk())
+
+ // Assert
+ coVerify { mockPaymentRepository.purchaseProduct(productId, any()) }
+ }
+
+ @Test
+ fun testQueryPaymentAvailability() = runTest {
+ // Act
+ playPaymentUseCase.queryPaymentAvailability()
+
+ // Assert
+ coVerify { mockPaymentRepository.queryPaymentAvailability() }
+ }
+
+ @Test
+ fun testResetPurchaseResult() = runTest {
+ // Arrange
+ val completedSuccess = PurchaseResult.Completed.Success
+ val productId = ProductId("productId")
+ val paymentRepositoryPurchaseResultFlow = flow { emit(completedSuccess) }
+ every { mockPaymentRepository.purchaseProduct(any(), any()) } returns
+ paymentRepositoryPurchaseResultFlow
+
+ // Act, Assert
+ playPaymentUseCase.purchaseResult.test {
+ assertNull(awaitItem())
+ playPaymentUseCase.purchaseProduct(productId, mockk())
+ assertEquals(completedSuccess, awaitItem())
+ playPaymentUseCase.resetPurchaseResult()
+ assertNull(awaitItem())
+ }
+ }
+
+ @Test
+ fun testVerifyPurchases() = runTest {
+ // Act
+ playPaymentUseCase.verifyPurchases()
+
+ // Assert
+ coVerify { mockPaymentRepository.verifyPurchases() }
+ }
+}
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 fc1fd5e99b..c02e755951 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,15 +1,27 @@
package net.mullvad.mullvadvpn.viewmodel
+import android.app.Activity
import app.cash.turbine.test
+import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
+import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.lib.common.test.assertLists
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
import net.mullvad.mullvadvpn.model.AccountAndDevice
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.Device
@@ -19,6 +31,8 @@ import net.mullvad.mullvadvpn.repository.DeviceRepository
import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -31,8 +45,11 @@ class AccountViewModelTest {
private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
private val mockDeviceRepository: DeviceRepository = mockk()
private val mockAuthTokenCache: AuthTokenCache = mockk()
+ private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true)
private val deviceState: MutableStateFlow<DeviceState> = MutableStateFlow(DeviceState.Initial)
+ private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null)
+ private val purchaseResult = MutableStateFlow<PurchaseResult?>(null)
private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing)
private val dummyAccountAndDevice: AccountAndDevice =
@@ -51,15 +68,19 @@ class AccountViewModelTest {
@Before
fun setUp() {
mockkStatic(CACHE_EXTENSION_CLASS)
+ mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS)
every { mockServiceConnectionManager.authTokenCache() } returns mockAuthTokenCache
every { mockDeviceRepository.deviceState } returns deviceState
every { mockAccountRepository.accountExpiryState } returns accountExpiryState
+ coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult
+ coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability
viewModel =
AccountViewModel(
accountRepository = mockAccountRepository,
serviceConnectionManager = mockServiceConnectionManager,
- deviceRepository = mockDeviceRepository
+ deviceRepository = mockDeviceRepository,
+ paymentUseCase = mockPaymentUseCase
)
}
@@ -72,10 +93,9 @@ class AccountViewModelTest {
fun testAccountLoggedInState() = runTest {
// Act, Assert
viewModel.uiState.test {
- var result = awaitItem()
- assertEquals(null, result.deviceName)
+ awaitItem() // Default state
deviceState.value = DeviceState.LoggedIn(accountAndDevice = dummyAccountAndDevice)
- result = awaitItem()
+ val result = awaitItem()
assertEquals(DUMMY_DEVICE_NAME, result.accountNumber)
}
}
@@ -89,8 +109,121 @@ class AccountViewModelTest {
verify { mockAccountRepository.logout() }
}
+ @Test
+ fun testBillingProductsUnavailableState() = runTest {
+ // Arrange in setup
+
+ // Act, Assert
+ viewModel.uiState.test {
+ awaitItem() // Default state
+ paymentAvailability.tryEmit(PaymentAvailability.ProductsUnavailable)
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.NoPayment>(result)
+ }
+ }
+
+ @Test
+ fun testBillingProductsGenericErrorState() = 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 testBillingProductsBillingErrorState() = 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 testBillingProductsPaymentAvailableState() = 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)
+ }
+ }
+
+ @Test
+ fun testBillingUserCancelled() = runTest {
+ // Arrange
+ val result = PurchaseResult.Completed.Cancelled
+ purchaseResult.value = result
+ every { result.toPaymentDialogData() } returns null
+
+ // Act, Assert
+ viewModel.uiState.test { assertNull(awaitItem().paymentDialogData) }
+ }
+
+ @Test
+ fun testBillingPurchaseSuccess() = runTest {
+ // Arrange
+ val result = PurchaseResult.Completed.Success
+ val expectedData: PaymentDialogData = mockk()
+ purchaseResult.value = result
+ every { result.toPaymentDialogData() } returns expectedData
+
+ // Act, Assert
+ viewModel.uiState.test { assertEquals(expectedData, awaitItem().paymentDialogData) }
+ }
+
+ @Test
+ fun testStartBillingPayment() {
+ // Arrange
+ val mockProductId = ProductId("MOCK")
+ val mockActivityProvider = mockk<() -> Activity>()
+
+ // Act
+ viewModel.startBillingPayment(mockProductId, mockActivityProvider)
+
+ // Assert
+ coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) }
+ }
+
+ @Test
+ fun testOnClosePurchaseResultDialogSuccessful() {
+ // Arrange
+
+ // Act
+ viewModel.onClosePurchaseResultDialog(success = true)
+
+ // Assert
+ verify { mockAccountRepository.fetchAccountExpiry() }
+ coVerify { mockPaymentUseCase.resetPurchaseResult() }
+ }
+
+ @Test
+ fun testOnClosePurchaseResultDialogNotSuccessful() {
+ // Arrange
+
+ // Act
+ viewModel.onClosePurchaseResultDialog(success = false)
+
+ // Assert
+ coVerify { mockPaymentUseCase.queryPaymentAvailability() }
+ coVerify { mockPaymentUseCase.resetPurchaseResult() }
+ }
+
companion object {
private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt"
+ 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/OutOfTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt
index 8c1ec10f5a..dad51eab59 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt
@@ -1,8 +1,10 @@
package net.mullvad.mullvadvpn.viewmodel
+import android.app.Activity
import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -10,11 +12,19 @@ import io.mockk.unmockkAll
import io.mockk.verify
import kotlin.test.assertEquals
import kotlin.test.assertIs
+import kotlin.test.assertNull
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState
+import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.lib.common.test.assertLists
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.DeviceState
import net.mullvad.mullvadvpn.model.TunnelState
@@ -27,6 +37,8 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.talpid.util.EventNotifier
import org.joda.time.DateTime
import org.joda.time.ReadableInstant
@@ -42,6 +54,8 @@ class OutOfTimeViewModelTest {
MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing)
private val deviceState = MutableStateFlow<DeviceState>(DeviceState.Initial)
+ private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null)
+ private val purchaseResult = MutableStateFlow<PurchaseResult?>(null)
// Service connections
private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
@@ -50,15 +64,17 @@ class OutOfTimeViewModelTest {
// Event notifiers
private val eventNotifierTunnelRealState = EventNotifier<TunnelState>(TunnelState.Disconnected)
- private val mockAccountRepository: AccountRepository = mockk()
+ private val mockAccountRepository: AccountRepository = mockk(relaxed = true)
private val mockDeviceRepository: DeviceRepository = mockk()
private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true)
private lateinit var viewModel: OutOfTimeViewModel
@Before
fun setUp() {
mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS)
+ mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS)
every { mockServiceConnectionManager.connectionState } returns serviceConnectionState
@@ -70,11 +86,16 @@ class OutOfTimeViewModelTest {
every { mockDeviceRepository.deviceState } returns deviceState
+ coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult
+
+ coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability
+
viewModel =
OutOfTimeViewModel(
accountRepository = mockAccountRepository,
serviceConnectionManager = mockServiceConnectionManager,
deviceRepository = mockDeviceRepository,
+ paymentUseCase = mockPaymentUseCase,
pollAccountExpiry = false
)
}
@@ -112,9 +133,9 @@ class OutOfTimeViewModelTest {
// Act, Assert
viewModel.uiState.test {
assertEquals(OutOfTimeUiState(deviceName = ""), awaitItem())
+ eventNotifierTunnelRealState.notify(tunnelRealStateTestItem)
serviceConnectionState.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
- eventNotifierTunnelRealState.notify(tunnelRealStateTestItem)
val result = awaitItem()
assertEquals(tunnelRealStateTestItem, result.tunnelState)
}
@@ -149,8 +170,135 @@ class OutOfTimeViewModelTest {
verify { mockProxy.disconnect() }
}
+ @Test
+ fun testBillingProductsUnavailableState() = runTest {
+ // Arrange
+ val productsUnavailable = PaymentAvailability.ProductsUnavailable
+ paymentAvailability.value = productsUnavailable
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.NoPayment>(result)
+ }
+ }
+
+ @Test
+ fun testBillingProductsGenericErrorState() = runTest {
+ // Arrange
+ val paymentAvailabilityError = PaymentAvailability.Error.Other(mockk())
+ paymentAvailability.value = paymentAvailabilityError
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.Error.Generic>(result)
+ }
+ }
+
+ @Test
+ fun testBillingProductsBillingErrorState() = runTest {
+ // Arrange
+ val paymentAvailabilityError = PaymentAvailability.Error.BillingUnavailable
+ paymentAvailability.value = paymentAvailabilityError
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.Error.Billing>(result)
+ }
+ }
+
+ @Test
+ fun testBillingProductsPaymentAvailableState() = runTest {
+ // Arrange
+ val mockProduct: PaymentProduct = mockk()
+ val expectedProductList = listOf(mockProduct)
+ val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct))
+ paymentAvailability.value = productsAvailable
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.PaymentAvailable>(result)
+ assertLists(expectedProductList, result.products)
+ }
+ }
+
+ @Test
+ fun testBillingUserCancelled() = runTest {
+ // Arrange
+ val result = PurchaseResult.Completed.Cancelled
+ purchaseResult.value = result
+ every { result.toPaymentDialogData() } returns null
+
+ // Act, Assert
+ viewModel.uiState.test { assertNull(awaitItem().paymentDialogData) }
+ }
+
+ @Test
+ fun testBillingPurchaseSuccess() = runTest {
+ // Arrange
+ val result = PurchaseResult.Completed.Success
+ val expectedData: PaymentDialogData = mockk()
+ purchaseResult.value = result
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ every { result.toPaymentDialogData() } returns expectedData
+
+ // Act, Assert
+ viewModel.uiState.test { assertEquals(expectedData, awaitItem().paymentDialogData) }
+ }
+
+ @Test
+ fun testStartBillingPayment() {
+ // Arrange
+ val mockProductId = ProductId("MOCK")
+ val mockActivityProvider = mockk<() -> Activity>()
+
+ // Act
+ viewModel.startBillingPayment(mockProductId, mockActivityProvider)
+
+ // Assert
+ coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) }
+ }
+
+ @Test
+ fun testOnClosePurchaseResultDialogSuccessful() {
+ // Arrange
+
+ // Act
+ viewModel.onClosePurchaseResultDialog(success = true)
+
+ // Assert
+ verify { mockAccountRepository.fetchAccountExpiry() }
+ coVerify { mockPaymentUseCase.resetPurchaseResult() }
+ }
+
+ @Test
+ fun testOnClosePurchaseResultDialogNotSuccessful() {
+ // Arrange
+
+ // Act
+ viewModel.onClosePurchaseResultDialog(success = false)
+
+ // Assert
+ coVerify { mockPaymentUseCase.queryPaymentAvailability() }
+ coVerify { mockPaymentUseCase.resetPurchaseResult() }
+ }
+
companion object {
private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS =
"net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt"
+ private const val PURCHASE_RESULT_EXTENSIONS_CLASS =
+ "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt"
}
}
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 b16eeec2f8..e958df9337 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt
@@ -1,19 +1,29 @@
package net.mullvad.mullvadvpn.viewmodel
+import android.app.Activity
import androidx.lifecycle.viewModelScope
import app.cash.turbine.test
import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlin.test.assertEquals
import kotlin.test.assertIs
+import kotlin.test.assertNull
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData
+import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.lib.common.test.assertLists
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability
+import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct
+import net.mullvad.mullvadvpn.lib.payment.model.ProductId
+import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult
import net.mullvad.mullvadvpn.model.AccountAndDevice
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.Device
@@ -27,6 +37,8 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
+import net.mullvad.mullvadvpn.usecase.PaymentUseCase
+import net.mullvad.mullvadvpn.util.toPaymentDialogData
import net.mullvad.talpid.util.EventNotifier
import org.joda.time.DateTime
import org.joda.time.ReadableInstant
@@ -42,6 +54,8 @@ class WelcomeViewModelTest {
MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
private val deviceState = MutableStateFlow<DeviceState>(DeviceState.Initial)
private val accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing)
+ private val purchaseResult = MutableStateFlow<PurchaseResult?>(null)
+ private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null)
// Service connections
private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
@@ -50,15 +64,17 @@ class WelcomeViewModelTest {
// Event notifiers
private val eventNotifierTunnelUiState = EventNotifier<TunnelState>(TunnelState.Disconnected)
- private val mockAccountRepository: AccountRepository = mockk()
+ private val mockAccountRepository: AccountRepository = mockk(relaxed = true)
private val mockDeviceRepository: DeviceRepository = mockk()
private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true)
private lateinit var viewModel: WelcomeViewModel
@Before
fun setUp() {
mockkStatic(SERVICE_CONNECTION_MANAGER_EXTENSIONS)
+ mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS)
every { mockDeviceRepository.deviceState } returns deviceState
@@ -70,11 +86,16 @@ class WelcomeViewModelTest {
every { mockAccountRepository.accountExpiryState } returns accountExpiryState
+ coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult
+
+ coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability
+
viewModel =
WelcomeViewModel(
accountRepository = mockAccountRepository,
deviceRepository = mockDeviceRepository,
serviceConnectionManager = mockServiceConnectionManager,
+ paymentUseCase = mockPaymentUseCase,
pollAccountExpiry = false
)
}
@@ -112,9 +133,9 @@ class WelcomeViewModelTest {
// Act, Assert
viewModel.uiState.test {
assertEquals(WelcomeUiState(), awaitItem())
+ eventNotifierTunnelUiState.notify(tunnelUiStateTestItem)
serviceConnectionState.value =
ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
- eventNotifierTunnelUiState.notify(tunnelUiStateTestItem)
val result = awaitItem()
assertEquals(tunnelUiStateTestItem, result.tunnelState)
}
@@ -158,8 +179,115 @@ class WelcomeViewModelTest {
}
}
+ @Test
+ fun testBillingProductsUnavailableState() = runTest {
+ // Arrange
+ val productsUnavailable = PaymentAvailability.ProductsUnavailable
+
+ // Act, Assert
+ viewModel.uiState.test {
+ // Default item
+ awaitItem()
+ paymentAvailability.tryEmit(productsUnavailable)
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.NoPayment>(result)
+ }
+ }
+
+ @Test
+ fun testBillingProductsGenericErrorState() = runTest {
+ // Arrange
+ val paymentOtherError = PaymentAvailability.Error.Other(mockk())
+ paymentAvailability.tryEmit(paymentOtherError)
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.Error.Generic>(result)
+ }
+ }
+
+ @Test
+ fun testBillingProductsBillingErrorState() = runTest {
+ // Arrange
+ val paymentBillingError = PaymentAvailability.Error.BillingUnavailable
+ paymentAvailability.value = paymentBillingError
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.Error.Billing>(result)
+ }
+ }
+
+ @Test
+ fun testBillingProductsPaymentAvailableState() = runTest {
+ // Arrange
+ val mockProduct: PaymentProduct = mockk()
+ val expectedProductList = listOf(mockProduct)
+ val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct))
+ paymentAvailability.value = productsAvailable
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+
+ // Act, Assert
+ viewModel.uiState.test {
+ val result = awaitItem().billingPaymentState
+ assertIs<PaymentState.PaymentAvailable>(result)
+ assertLists(expectedProductList, result.products)
+ }
+ }
+
+ @Test
+ fun testBillingUserCancelled() = runTest {
+ // Arrange
+ val result = PurchaseResult.Completed.Cancelled
+ purchaseResult.value = result
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ every { result.toPaymentDialogData() } returns null
+
+ // Act, Assert
+ viewModel.uiState.test { assertNull(awaitItem().paymentDialogData) }
+ }
+
+ @Test
+ fun testBillingPurchaseSuccess() = runTest {
+ // Arrange
+ val result = PurchaseResult.Completed.Success
+ val expectedData: PaymentDialogData = mockk()
+ purchaseResult.value = result
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer)
+ every { result.toPaymentDialogData() } returns expectedData
+
+ // Act, Assert
+ viewModel.uiState.test { assertEquals(expectedData, awaitItem().paymentDialogData) }
+ }
+
+ @Test
+ fun testStartBillingPayment() {
+ // Arrange
+ val mockProductId = ProductId("MOCK")
+ val mockActivityProvider = mockk<() -> Activity>()
+
+ // Act
+ viewModel.startBillingPayment(mockProductId, mockActivityProvider)
+
+ // Assert
+ coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) }
+ }
+
companion object {
private const val SERVICE_CONNECTION_MANAGER_EXTENSIONS =
"net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManagerExtensionsKt"
+ private const val PURCHASE_RESULT_EXTENSIONS_CLASS =
+ "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt"
}
}