diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-11-16 11:08:21 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-11-16 11:08:21 +0100 |
| commit | 04c6609948a770aed30491c66c4c6d779bc71c92 (patch) | |
| tree | 4184b4e7a8c8ebfe8c4273e9f472779ee8f7bae9 | |
| parent | 67710f3e2ef57ecbe60c1bcb444ab047f11f79a5 (diff) | |
| parent | c8c896bc00b6b23ffaaf3a8708f03efb3ab3a0f2 (diff) | |
| download | mullvadvpn-04c6609948a770aed30491c66c4c6d779bc71c92.tar.xz mullvadvpn-04c6609948a770aed30491c66c4c6d779bc71c92.zip | |
Merge branch 'google-play-in-app-purchases-droid-277'
98 files changed, 4108 insertions, 100 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 353a90c912..3c81c2e56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Line wrap the file at 100 chars. Th - Migrate voucher dialog to compose. - Add "New Device" in app notification & rework notification system - Add support for setting per-app language in system settings. +- Add support for in app purchases for versions that are released on Google Play. #### Linux - Don't block forwarding of traffic when the split tunnel mark (ct mark) is set. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6250d03ee0..a3035f0a12 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -315,6 +315,10 @@ dependencies { implementation(project(Dependencies.Mullvad.resourceLib)) implementation(project(Dependencies.Mullvad.talpidLib)) implementation(project(Dependencies.Mullvad.themeLib)) + implementation(project(Dependencies.Mullvad.paymentLib)) + + // Play implementation + playImplementation(project(Dependencies.Mullvad.billingLib)) implementation(Dependencies.androidMaterial) implementation(Dependencies.commonsValidator) 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/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" } } diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index d0748afc0a..57af45997b 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -8,6 +8,7 @@ object Dependencies { const val leakCanary = "com.squareup.leakcanary:leakcanary-android:${Versions.leakCanary}" const val mockkWebserver = "com.squareup.okhttp3:mockwebserver:${Versions.mockWebserver}" const val turbine = "app.cash.turbine:turbine:${Versions.turbine}" + const val billingClient = "com.android.billingclient:billing-ktx:${Versions.billingClient}" object AndroidX { const val appcompat = "androidx.appcompat:appcompat:${Versions.AndroidX.appcompat}" @@ -102,6 +103,8 @@ object Dependencies { const val talpidLib = ":lib:talpid" const val themeLib = ":lib:theme" const val commonTestLib = ":lib:common-test" + const val billingLib = ":lib:billing" + const val paymentLib = ":lib:payment" } object Plugin { diff --git a/android/buildSrc/src/main/kotlin/Extensions.kt b/android/buildSrc/src/main/kotlin/Extensions.kt index 8659cb8ecb..0115aa9f30 100644 --- a/android/buildSrc/src/main/kotlin/Extensions.kt +++ b/android/buildSrc/src/main/kotlin/Extensions.kt @@ -13,3 +13,6 @@ fun String.isNonStableVersion(): Boolean { fun DependencyHandler.`leakCanaryImplementation`(dependencyNotation: Any): Dependency? = add("leakCanaryImplementation", dependencyNotation) + +fun DependencyHandler.`playImplementation`(dependencyNotation: Any): Dependency? = + add("playImplementation", dependencyNotation) diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index b9cab543a9..4ceb4f787f 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -11,6 +11,7 @@ object Versions { const val mockk = "1.13.8" const val mockWebserver = "4.11.0" const val turbine = "1.0.0" + const val billingClient = "6.0.1" object Android { const val compileSdkVersion = 33 diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index d26a920add..074e8a71a2 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -1468,6 +1468,16 @@ <sha256 value="56589abf965af58ea1d31c15d676fe0d62b9db92983fbdfe8e72c983ea577dbb" origin="Generated by Gradle"/> </artifact> </component> + <component group="com.android.billingclient" name="billing" version="6.0.1"> + <artifact name="billing-6.0.1.aar"> + <sha256 value="740a22581265736a82b6a049d9e6c04364e79a0ff22d493a715f5f0e70a9206b" origin="Generated by Gradle"/> + </artifact> + </component> + <component group="com.android.billingclient" name="billing-ktx" version="6.0.1"> + <artifact name="billing-ktx-6.0.1.aar"> + <sha256 value="5c243282d12cb21bbc2d7fea388d54183140f8d4b201a4380a0a6e8f3122a15e" origin="Generated by Gradle"/> + </artifact> + </component> <component group="com.android.databinding" name="baseLibrary" version="8.1.0"> <artifact name="baseLibrary-8.1.0.jar"> <sha256 value="794113709dab21b06c262b3795e73cb708fbacae61715f34361e1af6237a1870" origin="Generated by Gradle"/> @@ -1971,6 +1981,21 @@ <sha256 value="9b586dc8eeeb4f601038e23ef8ffd6a1deeca1163276d02797b0d2b8f9764b62" origin="Generated by Gradle"/> </artifact> </component> + <component group="com.google.android.datatransport" name="transport-api" version="3.0.0"> + <artifact name="transport-api-3.0.0.aar"> + <sha256 value="4e6983c0703b357df6f1c6ceacb1b5dfc2c5006a789c799fec2298b2b5337466" origin="Generated by Gradle"/> + </artifact> + </component> + <component group="com.google.android.datatransport" name="transport-backend-cct" version="3.1.8"> + <artifact name="transport-backend-cct-3.1.8.aar"> + <sha256 value="e17edd1ef7fd475c90baa4e39422332f27087d34bcb46cb48ce86af9a54a612e" origin="Generated by Gradle"/> + </artifact> + </component> + <component group="com.google.android.datatransport" name="transport-runtime" version="3.1.8"> + <artifact name="transport-runtime-3.1.8.aar"> + <sha256 value="cb9353ef1791ae17097d878ca711e25a9c32cec9042adc49b00cadfee1a7290b" origin="Generated by Gradle"/> + </artifact> + </component> <component group="com.google.android.material" name="material" version="1.4.0"> <artifact name="material-1.4.0.aar"> <sha256 value="80a0e02abf8a8a8cbe5716e06ac80cd683840b9f5b0d2f19a2a279e47f2895ee" origin="Generated by Gradle"/> @@ -2062,6 +2087,26 @@ <sha256 value="9e6814cb71816988a4fd1b07a993a8f21bb7058d522c162b1de849e19bea54ae" origin="Generated by Gradle"/> </artifact> </component> + <component group="com.google.errorprone" name="error_prone_annotations" version="2.3.4"> + <artifact name="error_prone_annotations-2.3.4.jar"> + <sha256 value="baf7d6ea97ce606c53e11b6854ba5f2ce7ef5c24dddf0afa18d1260bd25b002c" origin="Generated by Gradle"/> + </artifact> + </component> + <component group="com.google.firebase" name="firebase-encoders" version="17.0.0"> + <artifact name="firebase-encoders-17.0.0.jar"> + <sha256 value="282a5a703f9b7eb56508dde97ea918e95d73318b157050f457f7a86dca750150" origin="Generated by Gradle"/> + </artifact> + </component> + <component group="com.google.firebase" name="firebase-encoders-json" version="18.0.0"> + <artifact name="firebase-encoders-json-18.0.0.aar"> + <sha256 value="80aece7e1ef58957ca2fc1957bc9208ec92a3a9528201331d3c63e3182570f97" origin="Generated by Gradle"/> + </artifact> + </component> + <component group="com.google.firebase" name="firebase-encoders-proto" version="16.0.0"> + <artifact name="firebase-encoders-proto-16.0.0.jar"> + <sha256 value="293db96a0d1d43f033167881b638d8fde844e4e5495f5101cf52295765295e0e" origin="Generated by Gradle"/> + </artifact> + </component> <component group="com.google.flatbuffers" name="flatbuffers-java" version="1.12.0"> <artifact name="flatbuffers-java-1.12.0.jar"> <sha256 value="3f8c088b4dd04a9858721f2e162508c94db0dd86f961e306ee63ef2eda871bf7" origin="Generated by Gradle"/> @@ -3363,6 +3408,11 @@ <sha256 value="3277ac102ae17aad10a55abec75ff5696c8d109790396434b496e75087854203" origin="Generated by Gradle"/> </artifact> </component> + <component group="org.jetbrains.kotlin" name="kotlin-reflect" version="1.7.20"> + <artifact name="kotlin-reflect-1.7.20.jar"> + <sha256 value="afe70b6faf6c23f6fedcb0cf88b07cb1778139f4b744ae13b23eb8bbc4ee09f8" origin="Generated by Gradle"/> + </artifact> + </component> <component group="org.jetbrains.kotlin" name="kotlin-reflect" version="1.8.20"> <artifact name="kotlin-reflect-1.8.20.jar"> <sha256 value="531e3c3f9b0c45f9a21f1642174453066a1066bec0190254a6331b331814ab8b" origin="Generated by Gradle"/> diff --git a/android/lib/billing/build.gradle.kts b/android/lib/billing/build.gradle.kts new file mode 100644 index 0000000000..255459f453 --- /dev/null +++ b/android/lib/billing/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + id(Dependencies.Plugin.androidLibraryId) + id(Dependencies.Plugin.kotlinAndroidId) +} + +android { + namespace = "net.mullvad.mullvadvpn.lib.billing" + compileSdk = Versions.Android.compileSdkVersion + + defaultConfig { + minSdk = Versions.Android.minSdkVersion + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = Versions.jvmTarget + } + + lint { + lintConfig = file("${rootProject.projectDir}/config/lint.xml") + abortOnError = true + warningsAsErrors = true + } + + packaging { + resources { + pickFirsts += setOf( + // Fixes packaging error caused by: jetified-junit-* + "META-INF/LICENSE.md", + "META-INF/LICENSE-notice.md" + ) + } + } +} + +dependencies { + implementation(Dependencies.Kotlin.stdlib) + implementation(Dependencies.KotlinX.coroutinesAndroid) + + implementation(Dependencies.Koin.core) + implementation(Dependencies.Koin.android) + + //Billing library + implementation(Dependencies.billingClient) + + //Model + implementation(project(Dependencies.Mullvad.modelLib)) + + //IPC + implementation(project(Dependencies.Mullvad.ipcLib)) + + //Payment library + implementation(project(Dependencies.Mullvad.paymentLib)) + + // Test dependencies + testImplementation(project(Dependencies.Mullvad.commonTestLib)) + testImplementation(Dependencies.Kotlin.test) + testImplementation(Dependencies.KotlinX.coroutinesTest) + testImplementation(Dependencies.MockK.core) + testImplementation(Dependencies.junit) + testImplementation(Dependencies.turbine) + + androidTestImplementation(project(Dependencies.Mullvad.commonTestLib)) + androidTestImplementation(Dependencies.MockK.android) + androidTestImplementation(Dependencies.Kotlin.test) + androidTestImplementation(Dependencies.KotlinX.coroutinesTest) + androidTestImplementation(Dependencies.turbine) + androidTestImplementation(Dependencies.junit) + androidTestImplementation(Dependencies.AndroidX.espressoContrib) + androidTestImplementation(Dependencies.AndroidX.espressoCore) +} diff --git a/android/lib/billing/src/androidTest/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingRepositoryTest.kt b/android/lib/billing/src/androidTest/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingRepositoryTest.kt new file mode 100644 index 0000000000..85982007b8 --- /dev/null +++ b/android/lib/billing/src/androidTest/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingRepositoryTest.kt @@ -0,0 +1,388 @@ +package net.mullvad.mullvadvpn.lib.billing + +import android.app.Activity +import android.content.Context +import app.cash.turbine.test +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetailsResult +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesResult +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryPurchasesParams +import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchasesAsync +import io.mockk.CapturingSlot +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.billing.model.BillingException +import net.mullvad.mullvadvpn.lib.billing.model.PurchaseEvent +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module + +class BillingRepositoryTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private val mockContext: Context = mockk() + private lateinit var billingRepository: BillingRepository + + private val mockBillingClientBuilder: BillingClient.Builder = mockk(relaxed = true) + private val mockBillingClient: BillingClient = mockk() + + private val purchaseUpdatedListenerSlot: CapturingSlot<PurchasesUpdatedListener> = + CapturingSlot() + + @Before + fun setUp() { + startKoin { modules(module { single { mockk<Activity>() } }) } + + mockkStatic(BILLING_CLIENT_CLASS) + mockkStatic(BILLING_CLIENT_KOTLIN_CLASS) + mockkStatic(BILLING_FLOW_PARAMS) + + every { BillingClient.newBuilder(any()) } returns mockBillingClientBuilder + every { mockBillingClientBuilder.enablePendingPurchases() } returns mockBillingClientBuilder + every { mockBillingClientBuilder.setListener(capture(purchaseUpdatedListenerSlot)) } returns + mockBillingClientBuilder + every { mockBillingClientBuilder.build() } returns mockBillingClient + + billingRepository = BillingRepository(mockContext) + } + + @After + fun tearDown() { + unmockkAll() + stopKoin() + } + + @Test + fun testQueryProductsOk() = runTest { + // Arrange + val mockBillingResult: BillingResult = mockk() + val mockProductDetails: ProductDetails = mockk() + val expectedProductDetailsResult: ProductDetailsResult = mockk() + val productId = "TEST" + val price = "44.4" + + every { mockBillingResult.responseCode } returns BillingResponseCode.OK + every { mockBillingClient.isReady } returns true + every { mockBillingClient.connectionState } returns BillingClient.ConnectionState.CONNECTED + coEvery { mockBillingClient.queryProductDetails(any()) } returns + expectedProductDetailsResult + every { expectedProductDetailsResult.billingResult } returns mockBillingResult + every { expectedProductDetailsResult.productDetailsList } returns listOf(mockProductDetails) + every { mockProductDetails.productId } returns productId + every { mockProductDetails.oneTimePurchaseOfferDetails?.formattedPrice } returns price + + // Act + val result = billingRepository.queryProducts(listOf(productId)) + + // Assert + assertEquals(expectedProductDetailsResult, result) + } + + @Test + fun testQueryProductsItemUnavailable() = runTest { + // Arrange + val mockBillingResult: BillingResult = mockk() + val mockProductDetailsResult: ProductDetailsResult = mockk() + + every { mockBillingResult.responseCode } returns BillingResponseCode.ITEM_UNAVAILABLE + every { mockBillingClient.isReady } returns true + every { mockBillingClient.connectionState } returns BillingClient.ConnectionState.CONNECTED + coEvery { mockBillingClient.queryProductDetails(any()) } returns mockProductDetailsResult + every { mockProductDetailsResult.billingResult } returns mockBillingResult + every { mockProductDetailsResult.productDetailsList } returns emptyList() + + // Act + val result = billingRepository.queryProducts(listOf("TEST")) + + // Assert + assertEquals(mockProductDetailsResult, result) + } + + @Test + fun testQueryProductsBillingUnavailable() = runTest { + // Arrange + val mockBillingResult: BillingResult = mockk() + val mockProductDetailsResult: ProductDetailsResult = mockk() + + every { mockBillingResult.responseCode } returns BillingResponseCode.BILLING_UNAVAILABLE + every { mockBillingClient.isReady } returns true + every { mockBillingClient.connectionState } returns BillingClient.ConnectionState.CONNECTED + coEvery { mockBillingClient.queryProductDetails(any()) } returns mockProductDetailsResult + every { mockProductDetailsResult.billingResult } returns mockBillingResult + every { mockProductDetailsResult.productDetailsList } returns emptyList() + + // Act + val result = billingRepository.queryProducts(listOf("TEST")) + + // Assert + assertEquals(mockProductDetailsResult, result) + } + + @Test + fun testStartPurchaseFlowOk() = runTest { + // Arrange + val mockProductBillingResult: BillingResult = mockk() + val mockBillingResult: BillingResult = mockk() + val transactionId = "MOCK22" + val mockProductDetails: ProductDetails = mockk(relaxed = true) + val mockActivityProvider: () -> Activity = mockk() + every { mockBillingResult.responseCode } returns BillingResponseCode.OK + every { mockBillingClient.isReady } returns true + every { mockBillingClient.connectionState } returns BillingClient.ConnectionState.CONNECTED + every { mockBillingClient.launchBillingFlow(any(), any()) } returns mockBillingResult + every { BillingFlowParams.newBuilder() } returns mockk(relaxed = true) + every { mockProductBillingResult.responseCode } returns BillingResponseCode.OK + every { mockActivityProvider() } returns mockk() + + // Act + val result = + billingRepository.startPurchaseFlow( + mockProductDetails, + transactionId, + mockActivityProvider + ) + + // Assert + assertEquals(mockBillingResult, result) + } + + @Test + fun testStartPurchaseFlowBillingUnavailable() = runTest { + // Arrange + val mockBillingResult: BillingResult = mockk() + val transactionId = "MOCK22" + val mockProductDetails: ProductDetails = mockk(relaxed = true) + val mockActivityProvider: () -> Activity = mockk() + every { mockBillingResult.responseCode } returns BillingResponseCode.BILLING_UNAVAILABLE + every { mockBillingClient.isReady } returns true + every { mockBillingClient.connectionState } returns BillingClient.ConnectionState.CONNECTED + every { mockBillingClient.launchBillingFlow(any(), any()) } returns mockBillingResult + every { BillingFlowParams.newBuilder() } returns mockk(relaxed = true) + every { mockActivityProvider() } returns mockk() + + // Act + val result = + billingRepository.startPurchaseFlow( + mockProductDetails, + transactionId, + mockActivityProvider + ) + + // Assert + assertEquals(mockBillingResult, result) + } + + @Test + fun testQueryPurchasesFoundPurchases() = runTest { + // Arrange + val mockResult: PurchasesResult = mockk() + val mockPurchase: Purchase = mockk() + every { mockResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockResult.purchasesList } returns listOf(mockPurchase) + every { mockBillingClient.isReady } returns true + every { mockBillingClient.connectionState } returns BillingClient.ConnectionState.CONNECTED + coEvery { mockBillingClient.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns + mockResult + every { BillingFlowParams.newBuilder() } returns mockk(relaxed = true) + + // Act + val result = billingRepository.queryPurchases() + + // Assert + assertEquals(mockResult, result) + } + + @Test + fun testQueryPurchasesNoPurchaseFound() = runTest { + // Arrange + val mockResult: PurchasesResult = mockk() + every { mockResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockResult.purchasesList } returns emptyList() + every { mockBillingClient.isReady } returns true + every { mockBillingClient.connectionState } returns BillingClient.ConnectionState.CONNECTED + coEvery { mockBillingClient.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns + mockResult + every { BillingFlowParams.newBuilder() } returns mockk(relaxed = true) + + // Act + val result = billingRepository.queryPurchases() + + // Assert + assertEquals(mockResult, result) + } + + @Test + fun testQueryPurchasesError() = runTest { + // Arrange + val responseCode = BillingResponseCode.ITEM_UNAVAILABLE + val message = "ERROR" + val expectedError = BillingException(responseCode, message) + val mockResult: PurchasesResult = mockk() + every { mockResult.billingResult.responseCode } returns responseCode + every { mockResult.billingResult.debugMessage } returns message + every { mockResult.purchasesList } returns emptyList() + every { mockBillingClient.isReady } returns true + every { mockBillingClient.connectionState } returns BillingClient.ConnectionState.CONNECTED + coEvery { mockBillingClient.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns + mockResult + every { BillingFlowParams.newBuilder() } returns mockk(relaxed = true) + + // Act + val result = billingRepository.queryPurchases() + + // Assert + assertEquals( + expectedError.toBillingResult().responseCode, + result.billingResult.responseCode + ) + assertEquals(expectedError.message, result.billingResult.debugMessage) + } + + @Test + fun testPurchaseEventPurchaseComplete() = runTest { + // Arrange + val mockPurchase: Purchase = mockk() + val mockPurchaseList = listOf(mockPurchase) + val mockBillingResult: BillingResult = mockk() + every { mockBillingResult.responseCode } returns BillingResponseCode.OK + + // Act, Assert + billingRepository.purchaseEvents.test { + purchaseUpdatedListenerSlot.captured.onPurchasesUpdated( + mockBillingResult, + mockPurchaseList + ) + val result = awaitItem() + assertIs<PurchaseEvent.Completed>(result) + assertLists(mockPurchaseList, result.purchases) + } + } + + @Test + fun testPurchaseEventUserCanceled() = runTest { + // Arrange + val mockBillingResult: BillingResult = mockk() + val mockResponseCode: Int = BillingResponseCode.USER_CANCELED + every { mockBillingResult.responseCode } returns mockResponseCode + + // Act, Assert + billingRepository.purchaseEvents.test { + purchaseUpdatedListenerSlot.captured.onPurchasesUpdated(mockBillingResult, null) + val result = awaitItem() + assertIs<PurchaseEvent.UserCanceled>(result) + } + } + + @Test + fun testPurchaseEventError() = runTest { + // Arrange + val mockDebugMessage = "ERROR" + val mockBillingResult: BillingResult = mockk() + val mockResponseCode: Int = BillingResponseCode.ERROR + val expectedError = + BillingException(responseCode = mockResponseCode, message = mockDebugMessage) + every { mockBillingResult.responseCode } returns mockResponseCode + every { mockBillingResult.debugMessage } returns mockDebugMessage + + // Act, Assert + billingRepository.purchaseEvents.test { + purchaseUpdatedListenerSlot.captured.onPurchasesUpdated(mockBillingResult, null) + val result = awaitItem() + assertIs<PurchaseEvent.Error>(result) + assertEquals(expectedError.message, result.exception.message) + } + } + + @Test + fun testEnsureConnectedStartConnection() = runTest { + // Arrange + val mockStartConnectionResult: BillingResult = mockk() + every { mockBillingClient.isReady } returns false + every { mockBillingClient.connectionState } returns + BillingClient.ConnectionState.DISCONNECTED + every { mockBillingClient.startConnection(any()) } answers + { + firstArg<BillingClientStateListener>() + .onBillingSetupFinished(mockStartConnectionResult) + } + every { mockStartConnectionResult.responseCode } returns BillingResponseCode.OK + coEvery { mockBillingClient.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns + mockk(relaxed = true) + + // Act + billingRepository.queryPurchases() + + // Assert + verify { mockBillingClient.startConnection(any()) } + coVerify { mockBillingClient.queryPurchasesAsync(any<QueryPurchasesParams>()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testEnsureConnectedOnlyOneSuccessfulConnection() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + var hasConnected = false + val mockStartConnectionResult: BillingResult = mockk() + every { mockBillingClient.isReady } answers { hasConnected } + every { mockBillingClient.connectionState } answers + { + if (hasConnected) { + BillingClient.ConnectionState.CONNECTED + } else { + BillingClient.ConnectionState.DISCONNECTED + } + } + every { mockBillingClient.startConnection(any()) } answers + { + hasConnected = true + firstArg<BillingClientStateListener>() + .onBillingSetupFinished(mockStartConnectionResult) + } + every { mockStartConnectionResult.responseCode } returns BillingResponseCode.OK + coEvery { mockBillingClient.queryPurchasesAsync(any<QueryPurchasesParams>()) } returns + mockk(relaxed = true) + coEvery { mockBillingClient.queryProductDetails(any()) } returns mockk(relaxed = true) + + // Act + launch { billingRepository.queryPurchases() } + launch { billingRepository.queryProducts(listOf("MOCK")) } + + // Assert + verify(exactly = 1) { mockBillingClient.startConnection(any()) } + coVerify { mockBillingClient.queryPurchasesAsync(any<QueryPurchasesParams>()) } + coVerify { mockBillingClient.queryProductDetails(any()) } + } + + companion object { + private const val BILLING_CLIENT_CLASS = "com.android.billingclient.api.BillingClient" + private const val BILLING_CLIENT_KOTLIN_CLASS = + "com.android.billingclient.api.BillingClientKotlinKt" + private const val BILLING_FLOW_PARAMS = "com.android.billingclient.api.BillingFlowParams" + } +} diff --git a/android/lib/billing/src/main/AndroidManifest.xml b/android/lib/billing/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b2d3ea1235 --- /dev/null +++ b/android/lib/billing/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" /> diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt new file mode 100644 index 0000000000..76df623ada --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt @@ -0,0 +1,167 @@ +package net.mullvad.mullvadvpn.lib.billing + +import android.app.Activity +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.Purchase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow +import net.mullvad.mullvadvpn.lib.billing.extension.getProductDetails +import net.mullvad.mullvadvpn.lib.billing.extension.nonPendingPurchases +import net.mullvad.mullvadvpn.lib.billing.extension.responseCode +import net.mullvad.mullvadvpn.lib.billing.extension.toBillingException +import net.mullvad.mullvadvpn.lib.billing.extension.toPaymentAvailability +import net.mullvad.mullvadvpn.lib.billing.extension.toPaymentStatus +import net.mullvad.mullvadvpn.lib.billing.extension.toPurchaseResult +import net.mullvad.mullvadvpn.lib.billing.model.BillingException +import net.mullvad.mullvadvpn.lib.billing.model.PurchaseEvent +import net.mullvad.mullvadvpn.lib.payment.PaymentRepository +import net.mullvad.mullvadvpn.lib.payment.ProductIds +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 +import net.mullvad.mullvadvpn.model.PlayPurchase +import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult +import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult + +class BillingPaymentRepository( + private val billingRepository: BillingRepository, + private val playPurchaseRepository: PlayPurchaseRepository +) : PaymentRepository { + + override fun queryPaymentAvailability(): Flow<PaymentAvailability> = flow { + emit(PaymentAvailability.Loading) + val purchases = billingRepository.queryPurchases() + val productIdToPaymentStatus = + purchases.purchasesList + .filter { it.products.isNotEmpty() } + .associate { it.products.first() to it.purchaseState.toPaymentStatus() } + emit( + billingRepository + .queryProducts(listOf(ProductIds.OneMonth)) + .toPaymentAvailability(productIdToPaymentStatus) + ) + } + + override fun purchaseProduct( + productId: ProductId, + activityProvider: () -> Activity + ): Flow<PurchaseResult> = flow { + emit(PurchaseResult.FetchingProducts) + + val productDetailsResult = billingRepository.queryProducts(listOf(productId.value)) + + val productDetails = + when (productDetailsResult.responseCode()) { + BillingResponseCode.OK -> { + productDetailsResult.getProductDetails(productId.value) + ?: run { + emit(PurchaseResult.Error.NoProductFound(productId)) + return@flow + } + } + else -> { + emit( + PurchaseResult.Error.FetchProductsError( + productId, + productDetailsResult.toBillingException() + ) + ) + return@flow + } + } + + // Get transaction id + emit(PurchaseResult.FetchingObfuscationId) + val obfuscatedId: String = + when (val result = initialisePurchase()) { + is PlayPurchaseInitResult.Ok -> result.obfuscatedId + else -> { + emit(PurchaseResult.Error.TransactionIdError(productId, null)) + return@flow + } + } + + val result = + billingRepository.startPurchaseFlow( + productDetails = productDetails, + obfuscatedId = obfuscatedId, + activityProvider = activityProvider + ) + + if (result.responseCode == BillingResponseCode.OK) { + emit(PurchaseResult.BillingFlowStarted) + } else { + emit( + PurchaseResult.Error.BillingError( + BillingException(result.responseCode, result.debugMessage) + ) + ) + return@flow + } + + // Wait for a callback from the billing library + when (val event = billingRepository.purchaseEvents.firstOrNull()) { + is PurchaseEvent.Error -> emit(event.toPurchaseResult()) + is PurchaseEvent.Completed -> { + val purchase = + event.purchases.firstOrNull() + ?: run { + emit(PurchaseResult.Error.BillingError(null)) + return@flow + } + if (purchase.purchaseState == Purchase.PurchaseState.PENDING) { + emit(PurchaseResult.Completed.Pending) + } else { + emit(PurchaseResult.VerificationStarted) + if (verifyPurchase(event.purchases.first()) == PlayPurchaseVerifyResult.Ok) { + emit(PurchaseResult.Completed.Success) + } else { + emit(PurchaseResult.Error.VerificationError(null)) + } + } + } + PurchaseEvent.UserCanceled -> emit(event.toPurchaseResult()) + else -> emit(PurchaseResult.Error.BillingError(null)) + } + } + + override fun verifyPurchases(): Flow<VerificationResult> = flow { + emit(VerificationResult.FetchingUnfinishedPurchases) + val purchasesResult = billingRepository.queryPurchases() + when (purchasesResult.responseCode()) { + BillingResponseCode.OK -> { + val purchases = purchasesResult.nonPendingPurchases() + if (purchases.isNotEmpty()) { + emit(VerificationResult.VerificationStarted) + val verificationResult = verifyPurchase(purchases.first()) + emit( + when (verificationResult) { + is PlayPurchaseVerifyResult.Error -> + VerificationResult.Error.VerificationError(null) + PlayPurchaseVerifyResult.Ok -> VerificationResult.Success + } + ) + } else { + emit(VerificationResult.NothingToVerify) + } + } + else -> + emit(VerificationResult.Error.BillingError(purchasesResult.toBillingException())) + } + } + + private suspend fun initialisePurchase(): PlayPurchaseInitResult { + return playPurchaseRepository.initializePlayPurchase() + } + + private suspend fun verifyPurchase(purchase: Purchase): PlayPurchaseVerifyResult { + return playPurchaseRepository.verifyPlayPurchase( + PlayPurchase( + productId = purchase.products.first(), + purchaseToken = purchase.purchaseToken, + ) + ) + } +} diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingRepository.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingRepository.kt new file mode 100644 index 0000000000..6274f8cb6f --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingRepository.kt @@ -0,0 +1,194 @@ +package net.mullvad.mullvadvpn.lib.billing + +import android.app.Activity +import android.content.Context +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetailsResult +import com.android.billingclient.api.PurchasesResult +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryProductDetailsParams.Product +import com.android.billingclient.api.QueryPurchasesParams +import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchasesAsync +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.mullvad.mullvadvpn.lib.billing.model.BillingException +import net.mullvad.mullvadvpn.lib.billing.model.PurchaseEvent + +class BillingRepository(context: Context) { + + private val billingClient: BillingClient + + private val purchaseUpdateListener: PurchasesUpdatedListener = + PurchasesUpdatedListener { result, purchases -> + when (result.responseCode) { + BillingResponseCode.OK -> { + _purchaseEvents.tryEmit( + PurchaseEvent.Completed(purchases?.toList() ?: emptyList()) + ) + } + BillingResponseCode.USER_CANCELED -> { + _purchaseEvents.tryEmit(PurchaseEvent.UserCanceled) + } + else -> { + _purchaseEvents.tryEmit( + PurchaseEvent.Error( + exception = + BillingException( + responseCode = result.responseCode, + message = result.debugMessage + ) + ) + ) + } + } + } + + private val _purchaseEvents = MutableSharedFlow<PurchaseEvent>(extraBufferCapacity = 1) + val purchaseEvents = _purchaseEvents.asSharedFlow() + + init { + billingClient = + BillingClient.newBuilder(context) + .enablePendingPurchases() + .setListener(purchaseUpdateListener) + .build() + } + + private val ensureConnectedMutex = Mutex() + + private suspend fun ensureConnected() = + ensureConnectedMutex.withLock { + suspendCoroutine { + if ( + billingClient.isReady && + billingClient.connectionState == BillingClient.ConnectionState.CONNECTED + ) { + it.resume(Unit) + } else { + startConnection(it) + } + } + } + + private fun startConnection(continuation: Continuation<Unit>) { + billingClient.startConnection( + object : BillingClientStateListener { + override fun onBillingServiceDisconnected() { + // Maybe do something here? + continuation.resumeWithException( + BillingException( + BillingResponseCode.SERVICE_DISCONNECTED, + "Billing service disconnected" + ) + ) + } + + override fun onBillingSetupFinished(result: BillingResult) { + if (result.responseCode == BillingResponseCode.OK) { + continuation.resume(Unit) + } else { + continuation.resumeWithException( + BillingException(result.responseCode, result.debugMessage) + ) + } + } + } + ) + } + + suspend fun queryProducts(productIds: List<String>): ProductDetailsResult { + return queryProductDetails(productIds) + } + + suspend fun startPurchaseFlow( + productDetails: ProductDetails, + obfuscatedId: String, + activityProvider: () -> Activity + ): BillingResult { + return try { + ensureConnected() + + val productDetailsParamsList = + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .build() + ) + + val billingFlowParams = + BillingFlowParams.newBuilder() + .setProductDetailsParamsList(productDetailsParamsList) + .setObfuscatedAccountId(obfuscatedId) + .build() + + val activity = activityProvider() + // Launch the billing flow + billingClient.launchBillingFlow(activity, billingFlowParams) + } catch (t: Throwable) { + if (t is BillingException) { + t.toBillingResult() + } else { + throw t + } + } + } + + suspend fun queryPurchases(): PurchasesResult { + return try { + ensureConnected() + + val queryPurchaseHistoryParams: QueryPurchasesParams = + QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.INAPP) + .build() + + billingClient.queryPurchasesAsync(queryPurchaseHistoryParams) + } catch (t: Throwable) { + if (t is BillingException) { + t.toPurchasesResult() + } else { + throw t + } + } + } + + private suspend fun queryProductDetails(productIds: List<String>): ProductDetailsResult { + return try { + ensureConnected() + + val productList = + productIds.map { productId -> + Product.newBuilder() + .setProductId(productId) + .setProductType(BillingClient.ProductType.INAPP) + .build() + } + val params = QueryProductDetailsParams.newBuilder() + params.setProductList(productList) + + billingClient.queryProductDetails(params.build()) + } catch (t: Throwable) { + if (t is BillingException) { + return ProductDetailsResult(t.toBillingResult(), null) + } else { + return ProductDetailsResult( + BillingResult.newBuilder().setResponseCode(BillingResponseCode.ERROR).build(), + null + ) + } + } + } +} diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/PlayPurchaseRepository.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/PlayPurchaseRepository.kt new file mode 100644 index 0000000000..ac71372f76 --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/PlayPurchaseRepository.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.lib.billing + +import kotlinx.coroutines.flow.first +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.PlayPurchase +import net.mullvad.mullvadvpn.model.PlayPurchaseInitError +import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult +import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyError +import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult + +class PlayPurchaseRepository(private val messageHandler: MessageHandler) { + suspend fun initializePlayPurchase(): PlayPurchaseInitResult { + val result = messageHandler.trySendRequest(Request.InitPlayPurchase) + + return if (result) { + messageHandler.events<Event.PlayPurchaseInitResultEvent>().first().result + } else { + PlayPurchaseInitResult.Error(PlayPurchaseInitError.OtherError) + } + } + + suspend fun verifyPlayPurchase(purchase: PlayPurchase): PlayPurchaseVerifyResult { + val result = messageHandler.trySendRequest(Request.VerifyPlayPurchase(purchase)) + return if (result) { + messageHandler.events<Event.PlayPurchaseVerifyResultEvent>().first().result + } else { + PlayPurchaseVerifyResult.Error(PlayPurchaseVerifyError.OtherError) + } + } +} diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultExtensions.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultExtensions.kt new file mode 100644 index 0000000000..3e4aee180a --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultExtensions.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.lib.billing.extension + +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetailsResult +import net.mullvad.mullvadvpn.lib.billing.model.BillingException + +fun ProductDetailsResult.getProductDetails(productId: String): ProductDetails? = + this.productDetailsList?.firstOrNull { it.productId == productId } + +fun ProductDetailsResult.responseCode(): Int = this.billingResult.responseCode + +fun ProductDetailsResult.toBillingException(): BillingException = + BillingException(responseCode = this.responseCode(), message = this.billingResult.debugMessage) diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultToPaymentAvailability.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultToPaymentAvailability.kt new file mode 100644 index 0000000000..37cc701724 --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultToPaymentAvailability.kt @@ -0,0 +1,37 @@ +package net.mullvad.mullvadvpn.lib.billing.extension + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.ProductDetailsResult +import net.mullvad.mullvadvpn.lib.billing.model.BillingException +import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus + +fun ProductDetailsResult.toPaymentAvailability( + productIdToPaymentStatus: Map<String, PaymentStatus?> +) = + when (this.billingResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + val productDetailsList = this.productDetailsList + if (productDetailsList?.isNotEmpty() == true) { + PaymentAvailability.ProductsAvailable( + productDetailsList.toPaymentProducts(productIdToPaymentStatus) + ) + } else { + PaymentAvailability.NoProductsFounds + } + } + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> + PaymentAvailability.Error.BillingUnavailable + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> + PaymentAvailability.Error.ServiceUnavailable + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> + PaymentAvailability.Error.DeveloperError + BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> + PaymentAvailability.Error.FeatureNotSupported + BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> + PaymentAvailability.Error.ItemUnavailable + else -> + PaymentAvailability.Error.Other( + BillingException(this.billingResult.responseCode, this.billingResult.debugMessage) + ) + } diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsToPaymentProduct.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsToPaymentProduct.kt new file mode 100644 index 0000000000..fa9a20613f --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsToPaymentProduct.kt @@ -0,0 +1,17 @@ +package net.mullvad.mullvadvpn.lib.billing.extension + +import com.android.billingclient.api.ProductDetails +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 + +fun ProductDetails.toPaymentProduct(productIdToStatus: Map<String, PaymentStatus?>) = + PaymentProduct( + productId = ProductId(this.productId), + price = ProductPrice(this.oneTimePurchaseOfferDetails?.formattedPrice ?: ""), + productIdToStatus[this.productId] + ) + +fun List<ProductDetails>.toPaymentProducts(productIdToStatus: Map<String, PaymentStatus?>) = + this.map { it.toPaymentProduct(productIdToStatus) } diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchaseEventToPurchaseResult.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchaseEventToPurchaseResult.kt new file mode 100644 index 0000000000..e0e4bf0a77 --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchaseEventToPurchaseResult.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.lib.billing.extension + +import net.mullvad.mullvadvpn.lib.billing.model.PurchaseEvent +import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult + +fun PurchaseEvent.toPurchaseResult() = + when (this) { + is PurchaseEvent.Error -> PurchaseResult.Error.BillingError(this.exception) + is PurchaseEvent.Completed -> PurchaseResult.VerificationStarted + PurchaseEvent.UserCanceled -> PurchaseResult.Completed.Cancelled + } diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchaseStateToPaymentStatus.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchaseStateToPaymentStatus.kt new file mode 100644 index 0000000000..701e5fde3d --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchaseStateToPaymentStatus.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.lib.billing.extension + +import com.android.billingclient.api.Purchase +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus + +internal fun Int.toPaymentStatus(): PaymentStatus? = + when (this) { + Purchase.PurchaseState.PURCHASED -> PaymentStatus.VERIFICATION_IN_PROGRESS + Purchase.PurchaseState.PENDING -> PaymentStatus.PENDING + else -> null + } diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchasesResultExtensions.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchasesResultExtensions.kt new file mode 100644 index 0000000000..d76d1a8b7e --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchasesResultExtensions.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.lib.billing.extension + +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesResult +import net.mullvad.mullvadvpn.lib.billing.model.BillingException + +fun PurchasesResult.nonPendingPurchases(): List<Purchase> = + this.purchasesList.filter { it.purchaseState != Purchase.PurchaseState.PENDING } + +fun PurchasesResult.responseCode(): Int = this.billingResult.responseCode + +fun PurchasesResult.toBillingException(): BillingException = + BillingException(responseCode = this.responseCode(), message = this.billingResult.debugMessage) diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/BillingException.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/BillingException.kt new file mode 100644 index 0000000000..08f6a89cca --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/BillingException.kt @@ -0,0 +1,15 @@ +package net.mullvad.mullvadvpn.lib.billing.model + +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.PurchasesResult + +class BillingException(private val responseCode: Int, message: String) : Throwable(message) { + + fun toBillingResult(): BillingResult = + BillingResult.newBuilder() + .setResponseCode(responseCode) + .setDebugMessage(message ?: "") + .build() + + fun toPurchasesResult(): PurchasesResult = PurchasesResult(toBillingResult(), emptyList()) +} diff --git a/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/PurchaseEvent.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/PurchaseEvent.kt new file mode 100644 index 0000000000..b88f31cae6 --- /dev/null +++ b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/PurchaseEvent.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.lib.billing.model + +import com.android.billingclient.api.Purchase + +sealed interface PurchaseEvent { + data object UserCanceled : PurchaseEvent + + data class Error(val exception: BillingException) : PurchaseEvent + + data class Completed(val purchases: List<Purchase>) : PurchaseEvent +} diff --git a/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt new file mode 100644 index 0000000000..fe25457e49 --- /dev/null +++ b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt @@ -0,0 +1,377 @@ +package net.mullvad.mullvadvpn.lib.billing + +import app.cash.turbine.test +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.ProductDetailsResult +import com.android.billingclient.api.Purchase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.billing.extension.toPaymentProduct +import net.mullvad.mullvadvpn.lib.billing.model.PurchaseEvent +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +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.PlayPurchaseInitError +import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult +import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyError +import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class BillingPaymentRepositoryTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private val mockBillingRepository: BillingRepository = mockk() + private val mockPlayPurchaseRepository: PlayPurchaseRepository = mockk() + + private val purchaseEventFlow = MutableSharedFlow<PurchaseEvent>(extraBufferCapacity = 1) + + private lateinit var paymentRepository: BillingPaymentRepository + + @Before + fun setUp() { + mockkStatic(PRODUCT_DETAILS_TO_PAYMENT_PRODUCT_EXT) + + every { mockBillingRepository.purchaseEvents } returns purchaseEventFlow + + paymentRepository = + BillingPaymentRepository( + billingRepository = mockBillingRepository, + playPurchaseRepository = mockPlayPurchaseRepository + ) + } + + @Test + fun testQueryAvailablePaymentProductsAvailable() = runTest { + // Arrange + val expectedProduct: PaymentProduct = mockk() + val mockProduct: ProductDetails = mockk() + val mockResult: ProductDetailsResult = mockk() + coEvery { mockBillingRepository.queryPurchases() } returns mockk(relaxed = true) + coEvery { mockBillingRepository.queryProducts(any()) } returns mockResult + every { mockProduct.toPaymentProduct(any()) } returns expectedProduct + every { mockResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockResult.productDetailsList } returns listOf(mockProduct) + + // Act, Assert + paymentRepository.queryPaymentAvailability().test { + // Loading + awaitItem() + val result = awaitItem() + assertIs<PaymentAvailability.ProductsAvailable>(result) + assertEquals(expectedProduct, result.products.first()) + awaitComplete() + } + } + + @Test + fun testQueryAvailablePaymentProductsUnavailable() = runTest { + // Arrange + val mockResult: ProductDetailsResult = mockk() + every { mockResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockResult.productDetailsList } returns emptyList() + coEvery { mockBillingRepository.queryPurchases() } returns mockk(relaxed = true) + coEvery { mockBillingRepository.queryProducts(any()) } returns mockResult + + // Act, Assert + paymentRepository.queryPaymentAvailability().test { + // Loading + awaitItem() + val result = awaitItem() + assertIs<PaymentAvailability.NoProductsFounds>(result) + awaitComplete() + } + } + + @Test + fun testQueryAvailablePaymentBillingUnavailableError() = runTest { + // Arrange + val mockResult: ProductDetailsResult = mockk() + every { mockResult.billingResult.responseCode } returns + BillingResponseCode.BILLING_UNAVAILABLE + coEvery { mockBillingRepository.queryPurchases() } returns mockk(relaxed = true) + coEvery { mockBillingRepository.queryProducts(any()) } returns mockResult + + // Act, Assert + paymentRepository.queryPaymentAvailability().test { + // Loading + awaitItem() + val result = awaitItem() + assertIs<PaymentAvailability.Error.BillingUnavailable>(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductStartPurchaseFetchProductsError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk<ProductDetailsResult>() + every { mockProductDetailsResult.billingResult.responseCode } returns + BillingResponseCode.BILLING_UNAVAILABLE + every { mockProductDetailsResult.billingResult.debugMessage } returns "ERROR" + coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns + mockProductDetailsResult + + // Act, Assert + paymentRepository.purchaseProduct(mockProductId, mockk()).test { + assertIs<PurchaseResult.FetchingProducts>(awaitItem()) + val result = awaitItem() + assertIs<PurchaseResult.Error.FetchProductsError>(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductStartPurchaseNoProductsFoundError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk<ProductDetailsResult>() + every { mockProductDetailsResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockProductDetailsResult.productDetailsList } returns emptyList() + coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns + mockProductDetailsResult + + // Act, Assert + paymentRepository.purchaseProduct(mockProductId, mockk()).test { + assertIs<PurchaseResult.FetchingProducts>(awaitItem()) + val result = awaitItem() + assertIs<PurchaseResult.Error.NoProductFound>(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductStartPurchaseTransactionIdError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk<ProductDetailsResult>() + val mockProductDetails: ProductDetails = mockk() + every { mockProductDetails.productId } returns mockProductId.value + every { mockProductDetailsResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockProductDetailsResult.productDetailsList } returns listOf(mockProductDetails) + coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns + mockProductDetailsResult + coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns + PlayPurchaseInitResult.Error(PlayPurchaseInitError.OtherError) + + // Act, Assert + paymentRepository.purchaseProduct(mockProductId, mockk()).test { + assertIs<PurchaseResult.FetchingProducts>(awaitItem()) + assertIs<PurchaseResult.FetchingObfuscationId>(awaitItem()) + val result = awaitItem() + assertIs<PurchaseResult.Error.TransactionIdError>(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductStartPurchaseFlowBillingError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk<ProductDetailsResult>() + val mockProductDetails: ProductDetails = mockk() + every { mockProductDetails.productId } returns mockProductId.value + every { mockProductDetailsResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockProductDetailsResult.productDetailsList } returns listOf(mockProductDetails) + coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns + mockProductDetailsResult + val mockBillingResult: BillingResult = mockk() + every { mockBillingResult.responseCode } returns BillingResponseCode.BILLING_UNAVAILABLE + every { mockBillingResult.debugMessage } returns "Mock error" + coEvery { + mockBillingRepository.startPurchaseFlow( + productDetails = any(), + obfuscatedId = any(), + activityProvider = any() + ) + } returns mockBillingResult + coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns + PlayPurchaseInitResult.Ok("MOCK") + + // Act, Assert + paymentRepository.purchaseProduct(mockProductId, mockk()).test { + // Purchase started + assertIs<PurchaseResult.FetchingProducts>(awaitItem()) + assertIs<PurchaseResult.FetchingObfuscationId>(awaitItem()) + val result = awaitItem() + assertIs<PurchaseResult.Error.BillingError>(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductPurchaseCanceled() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk<ProductDetailsResult>() + val mockProductDetails: ProductDetails = mockk() + every { mockProductDetails.productId } returns mockProductId.value + every { mockProductDetailsResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockProductDetailsResult.productDetailsList } returns listOf(mockProductDetails) + coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns + mockProductDetailsResult + val mockObfuscatedId = "MOCK-ID" + val mockBillingResult: BillingResult = mockk() + every { mockBillingResult.responseCode } returns BillingResponseCode.OK + coEvery { + mockBillingRepository.startPurchaseFlow( + productDetails = any(), + obfuscatedId = mockObfuscatedId, + activityProvider = any() + ) + } returns mockBillingResult + coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns + PlayPurchaseInitResult.Ok(mockObfuscatedId) + + // Act, Assert + paymentRepository.purchaseProduct(mockProductId, mockk()).test { + assertIs<PurchaseResult.FetchingProducts>(awaitItem()) + assertIs<PurchaseResult.FetchingObfuscationId>(awaitItem()) + assertIs<PurchaseResult.BillingFlowStarted>(awaitItem()) + purchaseEventFlow.tryEmit(PurchaseEvent.UserCanceled) + val result = awaitItem() + assertIs<PurchaseResult.Completed.Cancelled>(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductVerificationError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk<ProductDetailsResult>() + val mockProductDetails: ProductDetails = mockk() + every { mockProductDetails.productId } returns mockProductId.value + every { mockProductDetailsResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockProductDetailsResult.productDetailsList } returns listOf(mockProductDetails) + coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns + mockProductDetailsResult + val mockPurchaseToken = "TOKEN" + val mockBillingPurchase: Purchase = mockk() + val mockBillingResult: BillingResult = mockk() + every { mockBillingPurchase.purchaseState } returns Purchase.PurchaseState.PURCHASED + every { mockBillingResult.responseCode } returns BillingResponseCode.OK + every { mockBillingPurchase.products } returns listOf(mockProductId.value) + every { mockBillingPurchase.purchaseToken } returns mockPurchaseToken + coEvery { + mockBillingRepository.startPurchaseFlow( + productDetails = any(), + obfuscatedId = any(), + activityProvider = any() + ) + } returns mockBillingResult + coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns + PlayPurchaseInitResult.Ok("MOCK-ID") + coEvery { mockPlayPurchaseRepository.verifyPlayPurchase(any()) } returns + PlayPurchaseVerifyResult.Error(PlayPurchaseVerifyError.OtherError) + + // Act, Assert + paymentRepository.purchaseProduct(mockProductId, mockk()).test { + assertIs<PurchaseResult.FetchingProducts>(awaitItem()) + assertIs<PurchaseResult.FetchingObfuscationId>(awaitItem()) + assertIs<PurchaseResult.BillingFlowStarted>(awaitItem()) + purchaseEventFlow.tryEmit(PurchaseEvent.Completed(listOf(mockBillingPurchase))) + assertIs<PurchaseResult.VerificationStarted>(awaitItem()) + val result = awaitItem() + assertIs<PurchaseResult.Error.VerificationError>(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductPurchaseCompleted() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk<ProductDetailsResult>() + val mockProductDetails: ProductDetails = mockk() + every { mockProductDetails.productId } returns mockProductId.value + every { mockProductDetailsResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockProductDetailsResult.productDetailsList } returns listOf(mockProductDetails) + coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns + mockProductDetailsResult + val mockPurchaseToken = "TOKEN" + val mockBillingPurchase: Purchase = mockk() + val mockBillingResult: BillingResult = mockk() + every { mockBillingPurchase.purchaseState } returns Purchase.PurchaseState.PURCHASED + every { mockBillingResult.responseCode } returns BillingResponseCode.OK + every { mockBillingPurchase.products } returns listOf(mockProductId.value) + every { mockBillingPurchase.purchaseToken } returns mockPurchaseToken + coEvery { + mockBillingRepository.startPurchaseFlow( + productDetails = any(), + obfuscatedId = any(), + activityProvider = any() + ) + } returns mockBillingResult + coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns + PlayPurchaseInitResult.Ok("MOCK") + coEvery { mockPlayPurchaseRepository.verifyPlayPurchase(any()) } returns + PlayPurchaseVerifyResult.Ok + + // Act, Assert + paymentRepository.purchaseProduct(mockProductId, mockk()).test { + assertIs<PurchaseResult.FetchingProducts>(awaitItem()) + assertIs<PurchaseResult.FetchingObfuscationId>(awaitItem()) + assertIs<PurchaseResult.BillingFlowStarted>(awaitItem()) + purchaseEventFlow.tryEmit(PurchaseEvent.Completed(listOf(mockBillingPurchase))) + assertIs<PurchaseResult.VerificationStarted>(awaitItem()) + val result = awaitItem() + assertIs<PurchaseResult.Completed.Success>(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductPurchasePending() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk<ProductDetailsResult>() + val mockProductDetails: ProductDetails = mockk() + every { mockProductDetails.productId } returns mockProductId.value + every { mockProductDetailsResult.billingResult.responseCode } returns BillingResponseCode.OK + every { mockProductDetailsResult.productDetailsList } returns listOf(mockProductDetails) + coEvery { mockBillingRepository.queryProducts(listOf(mockProductId.value)) } returns + mockProductDetailsResult + val mockBillingPurchase: Purchase = mockk() + val mockBillingResult: BillingResult = mockk() + every { mockBillingPurchase.purchaseState } returns Purchase.PurchaseState.PENDING + every { mockBillingResult.responseCode } returns BillingResponseCode.OK + coEvery { + mockBillingRepository.startPurchaseFlow( + productDetails = any(), + obfuscatedId = any(), + activityProvider = any() + ) + } returns mockBillingResult + coEvery { mockPlayPurchaseRepository.initializePlayPurchase() } returns + PlayPurchaseInitResult.Ok("MOCK") + + // Act, Assert + paymentRepository.purchaseProduct(mockProductId, mockk()).test { + assertIs<PurchaseResult.FetchingProducts>(awaitItem()) + assertIs<PurchaseResult.FetchingObfuscationId>(awaitItem()) + assertIs<PurchaseResult.BillingFlowStarted>(awaitItem()) + purchaseEventFlow.tryEmit(PurchaseEvent.Completed(listOf(mockBillingPurchase))) + val result = awaitItem() + assertIs<PurchaseResult.Completed.Pending>(result) + awaitComplete() + } + } + + companion object { + private const val PRODUCT_DETAILS_TO_PAYMENT_PRODUCT_EXT = + "net.mullvad.mullvadvpn.lib.billing.extension.ProductDetailsToPaymentProductKt" + } +} diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt index c5ede20327..e1079807f1 100644 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt +++ b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Event.kt @@ -5,16 +5,16 @@ import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.model.AccountCreationResult import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.AccountHistory -import net.mullvad.mullvadvpn.model.AppVersionInfo import net.mullvad.mullvadvpn.model.DeviceListEvent import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.LoginResult +import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult +import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult import net.mullvad.mullvadvpn.model.RelayList import net.mullvad.mullvadvpn.model.RemoveDeviceResult import net.mullvad.mullvadvpn.model.Settings import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.model.VoucherSubmissionResult // Events that can be sent from the service sealed class Event : Message.EventMessage() { @@ -61,6 +61,11 @@ sealed class Event : Message.EventMessage() { val result: net.mullvad.mullvadvpn.model.VoucherSubmissionResult ) : Event() + @Parcelize data class PlayPurchaseInitResultEvent(val result: PlayPurchaseInitResult) : Event() + + @Parcelize + data class PlayPurchaseVerifyResultEvent(val result: PlayPurchaseVerifyResult) : Event() + @Parcelize object VpnPermissionRequest : Event() companion object { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/MessageHandler.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageHandler.kt index 3bbcae9361..04de35e3bd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/MessageHandler.kt +++ b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageHandler.kt @@ -1,9 +1,7 @@ -package net.mullvad.mullvadvpn.ui.serviceconnection +package net.mullvad.mullvadvpn.lib.ipc 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> diff --git a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt index 38237e84b3..b73010785a 100644 --- a/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt +++ b/android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt @@ -9,6 +9,7 @@ import net.mullvad.mullvadvpn.model.DnsOptions import net.mullvad.mullvadvpn.model.GeographicLocationConstraint import net.mullvad.mullvadvpn.model.ObfuscationSettings import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.model.PlayPurchase import net.mullvad.mullvadvpn.model.Providers import net.mullvad.mullvadvpn.model.QuantumResistantState import net.mullvad.mullvadvpn.model.WireguardConstraints @@ -83,6 +84,10 @@ sealed class Request : Message.RequestMessage() { @Parcelize data class SubmitVoucher(val voucher: String) : Request() + @Parcelize data object InitPlayPurchase : Request() + + @Parcelize data class VerifyPlayPurchase(val playPurchase: PlayPurchase) : Request() + @Parcelize data class UnregisterListener(val listenerId: Int) : Request() @Parcelize data class VpnPermissionResponse(val isGranted: Boolean) : Request() diff --git a/android/lib/payment/build.gradle.kts b/android/lib/payment/build.gradle.kts new file mode 100644 index 0000000000..23f945b4f9 --- /dev/null +++ b/android/lib/payment/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id(Dependencies.Plugin.androidLibraryId) + id(Dependencies.Plugin.kotlinAndroidId) +} + +android { + namespace = "net.mullvad.mullvadvpn.lib.payment" + compileSdk = Versions.Android.compileSdkVersion + + defaultConfig { + minSdk = Versions.Android.minSdkVersion + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = Versions.jvmTarget + } + + lint { + lintConfig = file("${rootProject.projectDir}/config/lint.xml") + abortOnError = true + warningsAsErrors = true + } + + packaging { + resources { + pickFirsts += setOf( + // Fixes packaging error caused by: jetified-junit-* + "META-INF/LICENSE.md", + "META-INF/LICENSE-notice.md" + ) + } + } +} + +dependencies { + implementation(Dependencies.Kotlin.stdlib) + implementation(Dependencies.KotlinX.coroutinesAndroid) +} diff --git a/android/lib/payment/src/main/AndroidManifest.xml b/android/lib/payment/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b2d3ea1235 --- /dev/null +++ b/android/lib/payment/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" /> diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentProvider.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentProvider.kt new file mode 100644 index 0000000000..431b406dc0 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentProvider.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.payment + +@JvmInline value class PaymentProvider(val paymentRepository: PaymentRepository?) diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentRepository.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentRepository.kt new file mode 100644 index 0000000000..73fd0c061d --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentRepository.kt @@ -0,0 +1,20 @@ +package net.mullvad.mullvadvpn.lib.payment + +import android.app.Activity +import kotlinx.coroutines.flow.Flow +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 PaymentRepository { + + fun purchaseProduct( + productId: ProductId, + activityProvider: () -> Activity + ): Flow<PurchaseResult> + + fun verifyPurchases(): Flow<VerificationResult> + + fun queryPaymentAvailability(): Flow<PaymentAvailability> +} diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt new file mode 100644 index 0000000000..8754968891 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.lib.payment + +object ProductIds { + const val OneMonth = "one_month" +} diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentAvailability.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentAvailability.kt new file mode 100644 index 0000000000..012237d825 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentAvailability.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +sealed interface PaymentAvailability { + data object Loading : PaymentAvailability + + data class ProductsAvailable(val products: List<PaymentProduct>) : PaymentAvailability + + data object ProductsUnavailable : PaymentAvailability + + data object NoProductsFounds : PaymentAvailability + + sealed interface Error : PaymentAvailability { + data object BillingUnavailable : Error + + data object ServiceUnavailable : Error + + data object FeatureNotSupported : Error + + data object DeveloperError : Error + + data object ItemUnavailable : Error + + data class Other(val exception: Throwable) : Error + } +} diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentProduct.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentProduct.kt new file mode 100644 index 0000000000..8945453d37 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentProduct.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +data class PaymentProduct( + val productId: ProductId, + val price: ProductPrice, + val status: PaymentStatus? +) diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentStatus.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentStatus.kt new file mode 100644 index 0000000000..37574249a6 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentStatus.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +enum class PaymentStatus { + PENDING, + VERIFICATION_IN_PROGRESS +} diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductId.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductId.kt new file mode 100644 index 0000000000..f14fefab28 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductId.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +@JvmInline value class ProductId(val value: String) diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductPrice.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductPrice.kt new file mode 100644 index 0000000000..5dc90db5fb --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductPrice.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +@JvmInline value class ProductPrice(val value: String) diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt new file mode 100644 index 0000000000..f5b89bffe6 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +sealed interface PurchaseResult { + data object FetchingProducts : PurchaseResult + + data object FetchingObfuscationId : PurchaseResult + + data object BillingFlowStarted : PurchaseResult + + data object VerificationStarted : PurchaseResult + + sealed interface Completed : PurchaseResult { + data object Success : Completed + + data object Cancelled : Completed + + // This ends our part of the purchase flow. The rest is handled by Google and the api. + data object Pending : Completed + } + + sealed interface Error : PurchaseResult { + data class NoProductFound(val productId: ProductId) : Error + + data class FetchProductsError(val productId: ProductId, val exception: Throwable?) : Error + + data class TransactionIdError(val productId: ProductId, val exception: Throwable?) : Error + + data class BillingError(val exception: Throwable?) : Error + + data class VerificationError(val exception: Throwable?) : Error + } + + fun isTerminatingState(): Boolean = this is Completed || this is Error +} diff --git a/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationResult.kt b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationResult.kt new file mode 100644 index 0000000000..725ea0af68 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationResult.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +sealed interface VerificationResult { + data object FetchingUnfinishedPurchases : VerificationResult + + data object VerificationStarted : VerificationResult + + // No verification was needed as there is no purchases to verify + data object NothingToVerify : VerificationResult + + data object Success : VerificationResult + + // Generic error, add more cases as needed + sealed interface Error : VerificationResult { + data class BillingError(val exception: Throwable?) : Error + + data class VerificationError(val exception: Throwable?) : Error + } +} diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index 503a66789a..160a34ea5f 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Køb kredit</string> <string name="buy_more_credit">Køb mere kredit</string> <string name="cancel">Annuller</string> - <string name="changes_dialog_dismiss_button">Forstået!</string> <string name="changes_dialog_subtitle">Ændringer i denne version:</string> <string name="confirm_local_dns">Den lokale DNS-server fungerer ikke, medmindre du aktiverer \"Lokal netværksdeling\" under Indstillinger.</string> <string name="confirm_no_email">Du er ved at sende rapporten om problemet, men har ikke angivet hvordan vi kan kontakte dig. Hvis du ønsker et svar på din rapport, skal du indtaste en e-mail-adresse.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Viser den aktuelle VPN-tunnelstatus</string> <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> <string name="go_to_login">Gå til login</string> + <string name="got_it">Forstået!</string> <string name="here_is_your_account_number">Her er dit kontonummer. Gem det!</string> <string name="hide_account_number">Skjul kontonummer</string> <string name="hint_default">Standard</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Tid udløbet</string> <string name="paid_until">Betalt indtil</string> <string name="pay_to_start_using">For at begynde at bruge appen skal du først føje tid til din konto.</string> + <string name="payment_completed_dialog_title">Tid blev tilføjet</string> <string name="port">Port</string> <string name="privacy_disclaimer_title">Privatliv</string> <string name="privacy_policy_label">Fortrolighedspolitik</string> diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml index fd9fc039e1..fd6b6f8d59 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Guthaben erwerben</string> <string name="buy_more_credit">Mehr Guthaben erwerben</string> <string name="cancel">Abbrechen</string> - <string name="changes_dialog_dismiss_button">Verstanden!</string> <string name="changes_dialog_subtitle">Änderungen in dieser Version:</string> <string name="confirm_local_dns">Der lokale DNS-Server wird nicht funktionieren, solange „Teilen im lokalen Netzwerk“ nicht in den Einstellungen aktiviert ist.</string> <string name="confirm_no_email">Sie wollen einen Problembericht senden, ohne uns die Möglichkeit zu geben, Sie zu erreichen. Wenn Sie sich eine Antwort zu Ihrem Problem wünschen, müssen Sie eine E-Mail-Adresse eingeben.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Zeigt den aktuellen Status des VPN-Tunnels an</string> <string name="foreground_notification_channel_name">Status des VPN-Tunnels</string> <string name="go_to_login">Zur Anmeldung</string> + <string name="got_it">Verstanden!</string> <string name="here_is_your_account_number">Hier ist Ihre Kontonummer. Verlieren Sie sie nicht!</string> <string name="hide_account_number">Kontonummer verbergen</string> <string name="hint_default">Standard</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Zeit abgelaufen</string> <string name="paid_until">Bezahlt bis</string> <string name="pay_to_start_using">Um mit der Nutzung dieser App zu beginnen, müssen Sie erst einmal Zeit zu Ihrem Konto hinzufügen.</string> + <string name="payment_completed_dialog_title">Zeit erfolgreich hinzugefügt</string> <string name="port">Port</string> <string name="privacy_disclaimer_title">Datenschutz</string> <string name="privacy_policy_label">Datenschutzrichtlinie</string> diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml index d936ca5ba9..4eff30768e 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Comprar créditos</string> <string name="buy_more_credit">Comprar más créditos</string> <string name="cancel">Cancelar</string> - <string name="changes_dialog_dismiss_button">¡Entendido!</string> <string name="changes_dialog_subtitle">Cambios en esta versión:</string> <string name="confirm_local_dns">El servidor DNS local no funcionará a no ser que habilite la opción «Uso compartido de red local» en Preferencias.</string> <string name="confirm_no_email">Va a enviar el informe de problemas sin indicar una forma de contacto. Para obtener una respuesta sobre el informe, necesita especificar su dirección de correo electrónico.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Muestra el estado actual del túnel VPN</string> <string name="foreground_notification_channel_name">Estado del túnel VPN</string> <string name="go_to_login">Iniciar sesión</string> + <string name="got_it">¡Entendido!</string> <string name="here_is_your_account_number">Este es un número de cuenta. ¡Guárdelo bien!</string> <string name="hide_account_number">Ocultar número de cuenta</string> <string name="hint_default">Predeterminado</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Tiempo agotado</string> <string name="paid_until">Pagado hasta</string> <string name="pay_to_start_using">Para empezar a usar la aplicación, primero necesita agregar tiempo a su cuenta.</string> + <string name="payment_completed_dialog_title">Se añadió correctamente el tiempo</string> <string name="port">Puerto</string> <string name="privacy_disclaimer_title">Privacidad</string> <string name="privacy_policy_label">Política de privacidad</string> diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml index c0c14178db..a48af8610e 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Osta käyttöaikaa</string> <string name="buy_more_credit">Uudista tilaus</string> <string name="cancel">Peruuta</string> - <string name="changes_dialog_dismiss_button">Selvä!</string> <string name="changes_dialog_subtitle">Muutokset tässä versiossa:</string> <string name="confirm_local_dns">Paikallinen DNS-palvelin ei toimi, ellet ota paikallisen verkon jakamisasetusta käyttöön asetuksissa.</string> <string name="confirm_no_email">Olet aikeissa lähettää ongelmaraportin ilman yhteystietojasi. Mikäli haluat vastauksen raporttiisi, anna sähköpostosoite.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Näyttää VPN-tunnelin nykyisen tilan</string> <string name="foreground_notification_channel_name">VPN-tunnelin tila</string> <string name="go_to_login">Siirry kirjautumiseen</string> + <string name="got_it">Selvä!</string> <string name="here_is_your_account_number">Tässä tulee tilisi numero. Laita se talteen!</string> <string name="hide_account_number">Piilota tilin numero</string> <string name="hint_default">Oletus</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Ei käyttöaikaa</string> <string name="paid_until">Maksu ennen</string> <string name="pay_to_start_using">Voit aloittaa sovelluksen käyttämisen lisäämällä ensin aikaa tilillesi.</string> + <string name="payment_completed_dialog_title">Aika lisättiin onnistuneesti</string> <string name="port">Portti</string> <string name="privacy_disclaimer_title">Tietosuoja</string> <string name="privacy_policy_label">Tietosuojakäytäntö</string> diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml index 26272030bd..0abbdf0037 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Acheter des crédits</string> <string name="buy_more_credit">Acheter plus de crédits</string> <string name="cancel">Annuler</string> - <string name="changes_dialog_dismiss_button">Compris !</string> <string name="changes_dialog_subtitle">Modifications dans cette version :</string> <string name="confirm_local_dns">Le serveur DNS local ne fonctionnera pas si vous n\'activez pas le « Partage du réseau local » dans les préférences.</string> <string name="confirm_no_email">Vous êtes sur le point d\'envoyer un signalement de problème sans nous fournir un moyen de vous contacter. Si vous désirez une réponse à votre signalement, vous devez saisir une adresse e-mail.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Affiche l\'état actuel du tunnel VPN</string> <string name="foreground_notification_channel_name">État du tunnel VPN</string> <string name="go_to_login">Aller à la connexion</string> + <string name="got_it">Compris !</string> <string name="here_is_your_account_number">Voici votre numéro de compte. Gardez-le !</string> <string name="hide_account_number">Masquer le numéro de compte</string> <string name="hint_default">Par défaut</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Plus de temps</string> <string name="paid_until">Payé jusqu\'au</string> <string name="pay_to_start_using">Pour commencer à utiliser l\'application, vous devez d\'abord ajouter du temps à votre compte.</string> + <string name="payment_completed_dialog_title">Le temps a bien été ajouté</string> <string name="port">Port</string> <string name="privacy_disclaimer_title">Confidentialité</string> <string name="privacy_policy_label">Politique de confidentialité</string> diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml index 02a9572498..8ae92f4880 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Acquista credito</string> <string name="buy_more_credit">Acquista altro credito</string> <string name="cancel">Annulla</string> - <string name="changes_dialog_dismiss_button">Ok!</string> <string name="changes_dialog_subtitle">Modifiche in questa versione:</string> <string name="confirm_local_dns">Il server DNS locale non funzionerà a meno che non si abiliti \"Condivisione rete locale\" in Preferenze.</string> <string name="confirm_no_email">Stai inviando la segnalazione di un problema senza averci indicato un modo per ricontattarti. Se desideri ricevere risposta, inserisci un indirizzo e-mail.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Mostra lo stato attuale del tunnel VPN</string> <string name="foreground_notification_channel_name">Stato del tunnel VPN</string> <string name="go_to_login">Vai al login</string> + <string name="got_it">Ok!</string> <string name="here_is_your_account_number">Ecco il tuo numero di account. Salvalo!</string> <string name="hide_account_number">Nascondi numero di account</string> <string name="hint_default">Predefinito</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Scaduto</string> <string name="paid_until">Pagato fino al</string> <string name="pay_to_start_using">Per iniziare a utilizzare l\'app, devi prima aggiungere tempo al tuo account.</string> + <string name="payment_completed_dialog_title">L\'ora è stata aggiunta correttamente</string> <string name="port">Porta</string> <string name="privacy_disclaimer_title">Privacy</string> <string name="privacy_policy_label">Informativa sulla privacy</string> diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml index a132977f84..a05cdc3db7 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">クレジットを購入</string> <string name="buy_more_credit">追加クレジットを購入</string> <string name="cancel">キャンセル</string> - <string name="changes_dialog_dismiss_button">了解</string> <string name="changes_dialog_subtitle">このバージョンでの変更内容:</string> <string name="confirm_local_dns">環境設定で \"ローカルネットワーク共有\" を有効にしない限り、ローカルDNSサーバーは機能しません。</string> <string name="confirm_no_email">お客様への返信先を入力せずに問題の報告を送信しようとしています。ご報告に対する返信が必要な場合は、返信先のメールアドレスを入力する必要があります。</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">現在のVPNトンネルのステータスを表示します</string> <string name="foreground_notification_channel_name">VPNトンネルのステータス</string> <string name="go_to_login">ログインに進む</string> + <string name="got_it">了解</string> <string name="here_is_your_account_number">これがあなたのアカウント番号です。保存してください!</string> <string name="hide_account_number">アカウント番号の非表示</string> <string name="hint_default">デフォルト</string> @@ -133,6 +133,7 @@ <string name="out_of_time">時間切れ</string> <string name="paid_until">次の日時まで支払い済み</string> <string name="pay_to_start_using">アプリを使い始めるには、まずはアカウントに時間を追加する必要があります。</string> + <string name="payment_completed_dialog_title">時間を正常に追加しました</string> <string name="port">ポート</string> <string name="privacy_disclaimer_title">プライバシー</string> <string name="privacy_policy_label">プライバシーポリシー</string> diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml index ea976767b2..b3a098c919 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">크레딧 구매</string> <string name="buy_more_credit">추가 크레딧 구매</string> <string name="cancel">취소</string> - <string name="changes_dialog_dismiss_button">확인!</string> <string name="changes_dialog_subtitle">이 버전의 변경 사항:</string> <string name="confirm_local_dns">환경 설정에서 ”로컬 네트워크 공유”를 활성화하지 않으면 로컬 DNS 서버가 작동하지 않습니다.</string> <string name="confirm_no_email">연락처 없이 문제 보고서를 보내려고 합니다. 보고서에 대한 답변을 원하면 이메일 주소를 입력해야 합니다.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">현재 VPN 터널 상태 표시</string> <string name="foreground_notification_channel_name">VPN 터널 상태</string> <string name="go_to_login">로그인하기</string> + <string name="got_it">확인!</string> <string name="here_is_your_account_number">계정 번호는 다음과 같습니다. 저장하세요!</string> <string name="hide_account_number">계정 번호 숨기기</string> <string name="hint_default">기본값</string> @@ -133,6 +133,7 @@ <string name="out_of_time">시간 초과</string> <string name="paid_until">유효 기간</string> <string name="pay_to_start_using">앱 사용을 시작하려면, 먼저 계정에 시간을 추가해야 합니다.</string> + <string name="payment_completed_dialog_title">시간이 성공적으로 추가되었습니다.</string> <string name="port">포트</string> <string name="privacy_disclaimer_title">개인 정보 보호</string> <string name="privacy_policy_label">개인정보 보호정책</string> diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml index 0b4014148f..bfd0e3e2bd 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">ခရက်ဒစ် ဝယ်ရန်</string> <string name="buy_more_credit">ခရက်ဒစ်များ ဝယ်ရန်</string> <string name="cancel">မလုပ်တော့ပါ</string> - <string name="changes_dialog_dismiss_button">ရပါပြီ။</string> <string name="changes_dialog_subtitle">ဤဗားရှင်းတွင် ပြောင်းလဲမှုများ-</string> <string name="confirm_local_dns">လိုကယ် DNS ဆာဗာသည် လိုလားမှုများအောက်ရှိ \"လိုကယ် ကွန်ရက် ဝေမျှမှု\"ကို မဖွင့်မချင်း အလုပ်လုပ်မည် မဟုတ်ပါ။</string> <string name="confirm_no_email">သင်သည် သင့်ထံ ကျွန်ုပ်တို့ ပြန်ဆက်သွယ်နိုင်မည့် နည်းလမ်း မပါဘဲ ပြဿနာ ရီပို့တ်ကို ပေးပို့တော့မည် ဖြစ်ပါသည်။ သင့်ရီပို့တ်အတွက် အဖြေ ရရှိလိုပါက အီမေးလိပ်စာ ဖြည့်သွင်းပေးရပါမည်။</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">လက်ရှိ VPN Tunnel အခြေအနေကို ပြသပေးပါသည်</string> <string name="foreground_notification_channel_name">VPN Tunnel အခြေအနေ</string> <string name="go_to_login">ဝင်ရောက်ရန် သွားပါ</string> + <string name="got_it">ရပါပြီ။</string> <string name="here_is_your_account_number">ဤသည်မှာ သင့်အကောင့်နံပါတ် ဖြစ်ပါသည်။ သိမ်းမှတ်ထားပါ။</string> <string name="hide_account_number">အကောင့်နံပါတ်ကို ဝှက်ရန်</string> <string name="hint_default">ပုံသေ</string> @@ -133,6 +133,7 @@ <string name="out_of_time">အချိန်စေ့သွားပါပြီ</string> <string name="paid_until">ဖော်ပြပါအထိ ပေးချေထားပြီး</string> <string name="pay_to_start_using">အက်ပ်ကို စသုံးရန်အတွက် ဦးစွာ သင့်အကောင့်တွင် အချိန်ပေါင်းထည့်ပေးရန် လိုအပ်ပါသည်။</string> + <string name="payment_completed_dialog_title">အချိန်ကို အောင်မြင်စွာ ပေါင်းထည့်ပြီးပြီ</string> <string name="port">ပေါ့တ်</string> <string name="privacy_disclaimer_title">ကိုယ်ရေးအချက်အလက် လုံခြုံရေး</string> <string name="privacy_policy_label">ကိုယ်ပိုင်အချက်အလက် မူဝါဒ</string> diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml index 47330dfb71..d9fa3d5e85 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Kjøp kreditt</string> <string name="buy_more_credit">Kjøp mer kreditt</string> <string name="cancel">Avbryt</string> - <string name="changes_dialog_dismiss_button">Forstått!</string> <string name="changes_dialog_subtitle">Endringer i denne versjonen:</string> <string name="confirm_local_dns">Den lokale DNS-serveren fungerer ikke med mindre du aktiverer «Deling av lokalt nettverk» under Innstillinger.</string> <string name="confirm_no_email">Problemrapporten blir nå sendt uten en måte for oss å kontakte deg på. Hvis du ønsker svar på rapporten, må du oppgi en e-postadresse.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Viser gjeldende VPN-tunnelstatus</string> <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> <string name="go_to_login">Gå til pålogging</string> + <string name="got_it">Forstått!</string> <string name="here_is_your_account_number">Dette er kontonummeret ditt. Ta vare på det!</string> <string name="hide_account_number">Skjul kontonummer</string> <string name="hint_default">Standard</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Tiden har utløpt</string> <string name="paid_until">Betalt fram til</string> <string name="pay_to_start_using">For å starte bruken av appen, må du først legge til tid til kontoen.</string> + <string name="payment_completed_dialog_title">Tid ble lagt til</string> <string name="port">Port</string> <string name="privacy_disclaimer_title">Personvern</string> <string name="privacy_policy_label">Retningslinjer for personvern</string> diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml index 0d9d53f35b..07de845443 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Krediet kopen</string> <string name="buy_more_credit">Meer krediet kopen</string> <string name="cancel">Annuleren</string> - <string name="changes_dialog_dismiss_button">Begrepen!</string> <string name="changes_dialog_subtitle">Wijzigingen in deze versie:</string> <string name="confirm_local_dns">De lokale DNS-server werkt niet tenzij u \"Lokale netwerken delen\" inschakelt onder Voorkeuren.</string> <string name="confirm_no_email">U staat op het punt om het probleemrapport te verzenden zonder een contactmethode op te geven. Voer een e-mailadres in als u een antwoord wenst op het rapport.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Toont de huidige status van de VPN-tunnel</string> <string name="foreground_notification_channel_name">Status VPN-tunnel</string> <string name="go_to_login">Ga naar aanmelden</string> + <string name="got_it">Begrepen!</string> <string name="here_is_your_account_number">Hier is uw accountnummer. Sla het op!</string> <string name="hide_account_number">Accountnummer verbergen</string> <string name="hint_default">Standaard</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Geen tijd meer</string> <string name="paid_until">Betaald tot</string> <string name="pay_to_start_using">Om de app te gebruiken, moet u eerst tijd toevoegen aan uw account.</string> + <string name="payment_completed_dialog_title">Tijd is toegevoegd</string> <string name="port">Poort</string> <string name="privacy_disclaimer_title">Privacy</string> <string name="privacy_policy_label">Privacybeleid</string> diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml index 062bfa926d..fdbc8b58bb 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Kup doładowanie</string> <string name="buy_more_credit">Doładuj konto</string> <string name="cancel">Anuluj</string> - <string name="changes_dialog_dismiss_button">Jasne!</string> <string name="changes_dialog_subtitle">Zmiany w tej wersji:</string> <string name="confirm_local_dns">Lokalny serwer DNS nie będzie działał, dopóki nie włączysz opcji „Udostępnianie sieci lokalnej” w Preferencjach.</string> <string name="confirm_no_email">Za chwilę wyślesz zgłoszenie problemu, nie umożliwiając nam skontaktowania się z Tobą. Aby uzyskać odpowiedź na zgłoszenie, musisz podać adres e-mail.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Pokazuje bieżący status tunelu VPN</string> <string name="foreground_notification_channel_name">Status tunelu VPN</string> <string name="go_to_login">Przejdź do logowania</string> + <string name="got_it">Jasne!</string> <string name="here_is_your_account_number">Oto Twój numer konta. Zachowaj go!</string> <string name="hide_account_number">Ukryj numer konta</string> <string name="hint_default">Domyślnie</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Koniec czasu</string> <string name="paid_until">Płatne do</string> <string name="pay_to_start_using">Aby rozpocząć korzystanie z aplikacji, musisz najpierw dodać czas do swojego konta.</string> + <string name="payment_completed_dialog_title">Dodano czas</string> <string name="port">Port</string> <string name="privacy_disclaimer_title">Prywatność</string> <string name="privacy_policy_label">Polityka prywatności</string> diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml index 79d4b57a3d..702161aeff 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Comprar crédito</string> <string name="buy_more_credit">Comprar mais crédito</string> <string name="cancel">Cancelar</string> - <string name="changes_dialog_dismiss_button">Entendido!</string> <string name="changes_dialog_subtitle">Alterações nesta versão:</string> <string name="confirm_local_dns">O servidor DNS local não funcionará exceto se ativar \"Partilha de rede local\" em Preferências.</string> <string name="confirm_no_email">Está prestes a enviar o relatório de problema sem que tenhamos uma forma de lhe responder. Se pretender uma resposta ao seu relatório, tem de introduzir um endereço de email.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Indica o estado atual do túnel VPN</string> <string name="foreground_notification_channel_name">Estado do túnel VPN</string> <string name="go_to_login">Ir para a ligação</string> + <string name="got_it">Entendido!</string> <string name="here_is_your_account_number">Aqui tem o seu número de conta. Guarde-o!</string> <string name="hide_account_number">Ocultar número de conta</string> <string name="hint_default">Padrão</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Sem tempo</string> <string name="paid_until">Pago até</string> <string name="pay_to_start_using">Para começar a utilizar a aplicação, primeiro tem de adicionar tempo à sua conta.</string> + <string name="payment_completed_dialog_title">Tempo adicionado com sucesso</string> <string name="port">Porta</string> <string name="privacy_disclaimer_title">Privacidade</string> <string name="privacy_policy_label">Política de privacidade</string> diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml index 95a4dc2d7a..c2f122a862 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Пополнить баланс</string> <string name="buy_more_credit">Пополнить баланс</string> <string name="cancel">Отмена</string> - <string name="changes_dialog_dismiss_button">Понятно!</string> <string name="changes_dialog_subtitle">Изменения в этой версии:</string> <string name="confirm_local_dns">Локальный DNS-сервер не будет работать, пока вы не включите «Обмен данными в локальной сети» в разделе «Параметры».</string> <string name="confirm_no_email">Вы собираетесь отправить отчет о проблеме, не оставив контакты. Если вы хотите получить ответ, введите свой адрес электронной почты.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Показывает текущее состояние VPN-туннеля</string> <string name="foreground_notification_channel_name">Состояние туннеля VPN</string> <string name="go_to_login">Войти</string> + <string name="got_it">Понятно!</string> <string name="here_is_your_account_number">Вот номер вашей учетной записи. Сохраните его!</string> <string name="hide_account_number">Скрыть номер учетной записи</string> <string name="hint_default">По умолчанию</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Закончилось время</string> <string name="paid_until">Оплачено до</string> <string name="pay_to_start_using">Чтобы пользоваться приложением, нужно добавить время на учетную запись.</string> + <string name="payment_completed_dialog_title">Время добавлено</string> <string name="port">Порт</string> <string name="privacy_disclaimer_title">Конфиденциальность</string> <string name="privacy_policy_label">Политика конфиденциальности</string> diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml index 4ae8326b6f..c453cb2fa9 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Köp kredit</string> <string name="buy_more_credit">Köp mer kredit</string> <string name="cancel">Avbryt</string> - <string name="changes_dialog_dismiss_button">Jag förstår!</string> <string name="changes_dialog_subtitle">Ändringar i den här versionen:</string> <string name="confirm_local_dns">Den lokala DNS-servern fungerar inte om du inte aktiverar \"Lokal nätverksdelning\" under Inställningar.</string> <string name="confirm_no_email">Du är på väg att skicka problemrapporten utan att vi har möjlighet att besvara dig. Om du vill ha svar på din rapport måste du ange en e-postadress.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Visar nuvarande status för VPN-tunnel</string> <string name="foreground_notification_channel_name">VPN-tunnelstatus</string> <string name="go_to_login">Gå till inloggning</string> + <string name="got_it">Jag förstår!</string> <string name="here_is_your_account_number">Här är ditt kontonummer. Spara det!</string> <string name="hide_account_number">Dölj kontonummer</string> <string name="hint_default">Standard</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Ingen tid kvar</string> <string name="paid_until">Betalat till</string> <string name="pay_to_start_using">Om du vill börja använda appen måste du först lägga till tid i ditt konto.</string> + <string name="payment_completed_dialog_title">Tid har lagts till</string> <string name="port">Port</string> <string name="privacy_disclaimer_title">Sekretess</string> <string name="privacy_policy_label">Sekretesspolicy</string> diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml index 0297f8be3e..cce7148a7f 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">ซื้อเครดิต</string> <string name="buy_more_credit">ซื้อเครดิตเพิ่ม</string> <string name="cancel">ยกเลิก</string> - <string name="changes_dialog_dismiss_button">รับทราบ!</string> <string name="changes_dialog_subtitle">การเปลี่ยนแปลงในเวอร์ชันนี้:</string> <string name="confirm_local_dns">เซิร์ฟเวอร์ DNS ท้องถิ่นจะไม่ทำงาน เว้นแต่คุณจะเปิดใช้ \"การแชร์ในเครือข่ายท้องถิ่น\" ซึ่งอยู่ในส่วนการกำหนดค่า</string> <string name="confirm_no_email">คุณกำลังจะส่งรายงานปัญหา โดยไม่มีการระบุวิธีการติดต่อกลับให้กับเรา และคุณจำเป็นต้องป้อนที่อยู่อีเมลของคุณ หากคุณอยากให้เราตอบกลับการรายงานของคุณ</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">แสดงสถานะอุโมงค์ VPN ในปัจจุบัน</string> <string name="foreground_notification_channel_name">สถานะอุโมงค์ VPN</string> <string name="go_to_login">ไปเข้าสู่ระบบ</string> + <string name="got_it">รับทราบ!</string> <string name="here_is_your_account_number">นี่คือหมายเลขบัญชีของคุณ จดบันทึกไว้ด้วยนะ!</string> <string name="hide_account_number">ซ่อนหมายเลขบัญชี</string> <string name="hint_default">ค่าเริ่มต้น</string> @@ -133,6 +133,7 @@ <string name="out_of_time">หมดเวลา</string> <string name="paid_until">ชำระเงินแล้วจนถึง</string> <string name="pay_to_start_using">คุณจำเป็นต้องเพิ่มเวลาไปยังบัญชีของคุณก่อน เพื่อที่จะเริ่มใช้งานแอป</string> + <string name="payment_completed_dialog_title">เพิ่มเวลาสำเร็จแล้ว</string> <string name="port">พอร์ต</string> <string name="privacy_disclaimer_title">ความเป็นส่วนตัว</string> <string name="privacy_policy_label">นโยบายความเป็นส่วนตัว</string> diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml index 46d9797b69..92a18749f5 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">Kredi satın alın</string> <string name="buy_more_credit">Daha fazla kredi satın alın</string> <string name="cancel">İptal et</string> - <string name="changes_dialog_dismiss_button">Anladım!</string> <string name="changes_dialog_subtitle">Bu sürümdeki değişiklikler:</string> <string name="confirm_local_dns">Tercihler sekmesinin altındaki \"Yerel Ağ Paylaşımı\" seçeneğini etkinleştirmediğiniz sürece yerel DNS sunucusu çalışmaz.</string> <string name="confirm_no_email">Sorun raporunu, size geri dönüş yapmamıza imkan vermeyen bir şekilde göndermek üzeresiniz. Sorununuz için yanıt almak istiyorsanız bir e-posta adresi girmelisiniz.</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">Mevcut VPN tünelinin durumunu gösterir</string> <string name="foreground_notification_channel_name">VPN tüneli durumu</string> <string name="go_to_login">Giriş sayfasına git</string> + <string name="got_it">Anladım!</string> <string name="here_is_your_account_number">İşte hesap numaranız. Kaydedin!</string> <string name="hide_account_number">Hesap numarasını gizle</string> <string name="hint_default">Varsayılan</string> @@ -133,6 +133,7 @@ <string name="out_of_time">Süre doldu</string> <string name="paid_until">Şu tarihe kadar ödendi:</string> <string name="pay_to_start_using">Uygulamayı kullanmaya başlamak için önce hesabınıza süre eklemeniz gerekir.</string> + <string name="payment_completed_dialog_title">Süre başarıyla eklendi</string> <string name="port">Port</string> <string name="privacy_disclaimer_title">Gizlilik</string> <string name="privacy_policy_label">Gizlilik politikası</string> diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml index 2134a212e3..26c61fa644 100644 --- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">购买额度</string> <string name="buy_more_credit">购买更多额度</string> <string name="cancel">取消</string> - <string name="changes_dialog_dismiss_button">知道了!</string> <string name="changes_dialog_subtitle">此版本中的变更:</string> <string name="confirm_local_dns">除非您在“偏好设置”下启用“本地网络共享”,否则本地 DNS 服务器将不会运行。</string> <string name="confirm_no_email">您即将发送问题报告,但没有提供让我们可以联系到您的方式。如果您希望获得回复,必须输入您的电子邮件地址。</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">显示当前的 VPN 隧道状态</string> <string name="foreground_notification_channel_name">VPN 隧道状态</string> <string name="go_to_login">前往登录</string> + <string name="got_it">知道了!</string> <string name="here_is_your_account_number">以下是您的帐号。请妥善保存!</string> <string name="hide_account_number">隐藏帐号</string> <string name="hint_default">默认</string> @@ -133,6 +133,7 @@ <string name="out_of_time">已没有时间</string> <string name="paid_until">到期时间</string> <string name="pay_to_start_using">要开始使用本应用,您首先需要向帐户中充入时间。</string> + <string name="payment_completed_dialog_title">时间已成功添加</string> <string name="port">端口</string> <string name="privacy_disclaimer_title">隐私</string> <string name="privacy_policy_label">隐私政策</string> diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml index 1949488b29..a3eee83dde 100644 --- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml @@ -32,7 +32,6 @@ <string name="buy_credit">購買點數</string> <string name="buy_more_credit">購買更多點數</string> <string name="cancel">取消</string> - <string name="changes_dialog_dismiss_button">知道了!</string> <string name="changes_dialog_subtitle">此版本中的變更:</string> <string name="confirm_local_dns">若要使本機 DNS 伺服器運作,需先在「偏好設定」下啟用「本機網路共用」。</string> <string name="confirm_no_email">您即將傳送的問題報告未包含回覆方式資訊。如果想收到您這份報告的回覆,請輸入您的電子郵件位址。</string> @@ -89,6 +88,7 @@ <string name="foreground_notification_channel_description">顯示目前的 VPN 通道狀態</string> <string name="foreground_notification_channel_name">VPN 通道狀態</string> <string name="go_to_login">前往登入</string> + <string name="got_it">知道了!</string> <string name="here_is_your_account_number">以下是您的帳號。請妥善保管!</string> <string name="hide_account_number">隱藏帳號</string> <string name="hint_default">預設</string> @@ -133,6 +133,7 @@ <string name="out_of_time">逾時</string> <string name="paid_until">支付至</string> <string name="pay_to_start_using">需先在帳戶中加時,才能開始使用本應用程式。</string> + <string name="payment_completed_dialog_title">已成功新增時間</string> <string name="port">連接埠</string> <string name="privacy_disclaimer_title">隱私權</string> <string name="privacy_policy_label">隱私權政策</string> diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index a769d91bcb..f3b0b0d157 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -151,7 +151,6 @@ <string name="show_account_number">Show account number</string> <string name="failed_to_remove_device">Failed to remove device</string> <string name="changes_dialog_subtitle">Changes in this version:</string> - <string name="changes_dialog_dismiss_button">Got it!</string> <string name="always_on_vpn_error_notification_title">Always-on VPN assigned to other app</string> <string name="always_on_vpn_error_notification_content"> <![CDATA[Unable to start tunnel connection. Please disable Always-on VPN for <b>%s</b> before using Mullvad VPN.]]> @@ -229,4 +228,19 @@ <string name="less_than_one_day">less than one day</string> <string name="top_bar_time_left">Time left: %s</string> <string name="top_bar_device_name">Device name: %s</string> + <string name="add_30_days_time_x">Add 30 days time (%s)</string> + <string name="add_30_days_time">Add 30 days time</string> + <string name="payment_completed_dialog_title">Time was successfully added</string> + <string name="payment_completed_dialog_message">30 days was added to your account.</string> + <string name="got_it">Got it!</string> + <string name="payment_billing_error_dialog_title">Google Play unavailable</string> + <string name="payment_billing_error_dialog_message">We were unable to start the payment process, please make sure you have the latest version of Google Play.</string> + <string name="payment_obfuscation_id_error_dialog_title">Mullvad services unavailable</string> + <string name="payment_obfuscation_id_error_dialog_message">We were unable to start the payment process, please try again later.</string> + <string name="payment_status_pending">Google Play payment pending</string> + <string name="payment_status_verification_in_progress">Verifying purchase</string> + <string name="payment_pending_dialog_title">Verifying purchase</string> + <string name="payment_pending_dialog_message">We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful.</string> + <string name="loading_connecting">Connecting...</string> + <string name="loading_verifying">Verifying purchase...</string> </resources> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index 20a1c2f3e1..404b556d94 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -32,6 +32,7 @@ data class Dimensions( val dialogIconSize: Dp = 48.dp, val expandableCellChevronSize: Dp = 30.dp, val iconFailSuccessTopMargin: Dp = 30.dp, + val iconHeight: Dp = 44.dp, val indentedCellStartPadding: Dp = 38.dp, val infoButtonVerticalPadding: Dp = 13.dp, val largePadding: Dp = 32.dp, diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/PlayPurchaseHandler.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/PlayPurchaseHandler.kt new file mode 100644 index 0000000000..9a1e34b62a --- /dev/null +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/PlayPurchaseHandler.kt @@ -0,0 +1,49 @@ +package net.mullvad.mullvadvpn.service.endpoint + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.ipc.Event +import net.mullvad.mullvadvpn.lib.ipc.Request +import net.mullvad.mullvadvpn.model.PlayPurchase + +class PlayPurchaseHandler( + private val endpoint: ServiceEndpoint, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + private val daemon + get() = endpoint.intermittentDaemon + + init { + scope.launch { + endpoint.dispatcher.parsedMessages + .filterIsInstance<Request.InitPlayPurchase>() + .collect { initializePurchase() } + } + + scope.launch { + endpoint.dispatcher.parsedMessages + .filterIsInstance<Request.VerifyPlayPurchase>() + .collect { verifyPlayPurchase(it.playPurchase) } + } + } + + fun onDestroy() { + scope.cancel() + } + + private suspend fun initializePurchase() { + val result = daemon.await().initPlayPurchase() + endpoint.sendEvent(Event.PlayPurchaseInitResultEvent(result)) + } + + private suspend fun verifyPlayPurchase(playPurchase: PlayPurchase) { + val result = daemon.await().verifyPlayPurchase(playPurchase) + endpoint.sendEvent(Event.PlayPurchaseVerifyResultEvent(result)) + } +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt index c4a733d919..70e7807ff9 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt @@ -51,6 +51,8 @@ class ServiceEndpoint( val splitTunneling = SplitTunneling(SplitTunnelingPersistence(context), this) val voucherRedeemer = VoucherRedeemer(this, accountCache) + private val playPurchaseHandler = PlayPurchaseHandler(this) + private val deviceRepositoryBackend = DaemonDeviceDataSource(this) init { @@ -80,6 +82,7 @@ class ServiceEndpoint( settingsListener.onDestroy() splitTunneling.onDestroy() voucherRedeemer.onDestroy() + playPurchaseHandler.onDestroy() } internal fun sendEvent(event: Event) { diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index e47f3c08f9..b598a49029 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -11,7 +11,9 @@ include( ":lib:resource", ":lib:talpid", ":lib:theme", - ":lib:common-test" + ":lib:common-test", + ":lib:billing", + ":lib:payment" ) include( ":test", diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index babbadbabb..09529d0eba 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -1660,6 +1660,9 @@ msgstr "" msgid "%s was added to your account." msgstr "" +msgid "30 days was added to your account." +msgstr "" + msgid "Account credit expires in a few minutes" msgstr "" @@ -1669,6 +1672,12 @@ msgstr "" msgid "Account time reminders" msgstr "" +msgid "Add 30 days time" +msgstr "" + +msgid "Add 30 days time (%s)" +msgstr "" + msgid "Add DNS server" msgstr "" @@ -1696,6 +1705,9 @@ msgstr "" msgid "Changes to DNS related settings might not go into effect immediately due to cached results." msgstr "" +msgid "Connecting..." +msgstr "" + msgid "Copied Mullvad account number to clipboard" msgstr "" @@ -1729,6 +1741,12 @@ msgstr "" msgid "Going to login will unblock the internet on this device." msgstr "" +msgid "Google Play payment pending" +msgstr "" + +msgid "Google Play unavailable" +msgstr "" + msgid "If the split tunneling feature is used, then the app queries your system for a list of all installed applications. This list is only retrieved in the split tunneling view. The list of installed applications is never sent from the device." msgstr "" @@ -1741,6 +1759,9 @@ msgstr "" msgid "Mullvad account number" msgstr "" +msgid "Mullvad services unavailable" +msgstr "" + msgid "Preferences" msgstr "" @@ -1825,12 +1846,27 @@ msgstr "" msgid "Valid ranges: %s" msgstr "" +msgid "Verifying purchase" +msgstr "" + +msgid "Verifying purchase..." +msgstr "" + msgid "Verifying voucher…" msgstr "" msgid "Virtual adapter error" msgstr "" +msgid "We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful." +msgstr "" + +msgid "We were unable to start the payment process, please make sure you have the latest version of Google Play." +msgstr "" + +msgid "We were unable to start the payment process, please try again later." +msgstr "" + msgid "While connected, your real location is masked with a private and secure location in the selected region." msgstr "" |
