From cdd0b8e0d55de25fd6d2fef975cc7dab26ec324d Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 00:48:29 +0100 Subject: [PATCH 01/22] Add billing client library and billing module --- .../buildSrc/src/main/kotlin/Dependencies.kt | 1 + android/buildSrc/src/main/kotlin/Versions.kt | 1 + android/lib/billing/build.gradle.kts | 73 +++++++++++++++++++ .../lib/billing/src/main/AndroidManifest.xml | 2 + android/settings.gradle.kts | 3 +- 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 android/lib/billing/build.gradle.kts create mode 100644 android/lib/billing/src/main/AndroidManifest.xml diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index d0748afc0a03..b72ae8f758d0 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}" diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index b9cab543a9e0..4ceb4f787f52 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/lib/billing/build.gradle.kts b/android/lib/billing/build.gradle.kts new file mode 100644 index 000000000000..a01053408579 --- /dev/null +++ b/android/lib/billing/build.gradle.kts @@ -0,0 +1,73 @@ +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)) + + // 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/main/AndroidManifest.xml b/android/lib/billing/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..b2d3ea123526 --- /dev/null +++ b/android/lib/billing/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index e47f3c08f950..53f385bae109 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -11,7 +11,8 @@ include( ":lib:resource", ":lib:talpid", ":lib:theme", - ":lib:common-test" + ":lib:common-test", + ":lib:billing" ) include( ":test", From c3e17fdf1965697c61708aa186a7ff2d4f9f1ae2 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 00:50:20 +0100 Subject: [PATCH 02/22] Add BillingRepository that handles calls to billing client --- .../lib/billing/BillingRepository.kt | 194 ++++++++++++++++++ .../lib/billing/model/BillingException.kt | 15 ++ .../lib/billing/model/PurchaseEvent.kt | 11 + 3 files changed, 220 insertions(+) create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingRepository.kt create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/BillingException.kt create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/PurchaseEvent.kt 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 000000000000..6274f8cb6f06 --- /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(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) { + 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): 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): 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/model/BillingException.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/model/BillingException.kt new file mode 100644 index 000000000000..08f6a89ccab5 --- /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 000000000000..b88f31cae61a --- /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) : PurchaseEvent +} From 4d70ef4349550ba88878ae17ee792af636fb2412 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 00:50:59 +0100 Subject: [PATCH 03/22] Add tests for BillingRepository --- .../lib/billing/BillingRepositoryTest.kt | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 android/lib/billing/src/androidTest/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingRepositoryTest.kt 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 000000000000..85982007b8ae --- /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 = + CapturingSlot() + + @Before + fun setUp() { + startKoin { modules(module { single { mockk() } }) } + + 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()) } 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()) } 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()) } 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(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(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(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() + .onBillingSetupFinished(mockStartConnectionResult) + } + every { mockStartConnectionResult.responseCode } returns BillingResponseCode.OK + coEvery { mockBillingClient.queryPurchasesAsync(any()) } returns + mockk(relaxed = true) + + // Act + billingRepository.queryPurchases() + + // Assert + verify { mockBillingClient.startConnection(any()) } + coVerify { mockBillingClient.queryPurchasesAsync(any()) } + } + + @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() + .onBillingSetupFinished(mockStartConnectionResult) + } + every { mockStartConnectionResult.responseCode } returns BillingResponseCode.OK + coEvery { mockBillingClient.queryPurchasesAsync(any()) } 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()) } + 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" + } +} From 5c5c2a95d676648ffbd953b5f9e8587a8a80bf66 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 00:53:05 +0100 Subject: [PATCH 04/22] Add play purchasing android ipc calls --- .../net/mullvad/mullvadvpn/di/UiModule.kt | 24 +++++++-- .../repository/AccountRepository.kt | 4 +- .../ui/serviceconnection/RelayListListener.kt | 2 + .../ServiceConnectionManager.kt | 1 + .../lib/billing/PlayPurchaseRepository.kt | 33 +++++++++++++ .../net/mullvad/mullvadvpn/lib/ipc/Event.kt | 9 +++- .../mullvadvpn/lib/ipc}/MessageHandler.kt | 4 +- .../net/mullvad/mullvadvpn/lib/ipc/Request.kt | 5 ++ .../service/endpoint/PlayPurchaseHandler.kt | 49 +++++++++++++++++++ .../service/endpoint/ServiceEndpoint.kt | 3 ++ 10 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/PlayPurchaseRepository.kt rename android/{app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection => lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc}/MessageHandler.kt (68%) create mode 100644 android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/PlayPurchaseHandler.kt 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 bfd3f061d5ea..5be527ac0cc9 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 { + val paymentRepository = get().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 1d8c49224a72..eb618ea0f84e 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/serviceconnection/RelayListListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/RelayListListener.kt index 0a1767624c64..30b8540bf986 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 556d69ecfe33..7f44b0c7d486 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/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 000000000000..ac71372f76f8 --- /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().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().first().result + } else { + PlayPurchaseVerifyResult.Error(PlayPurchaseVerifyError.OtherError) + } + } +} 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 c5ede203275b..e1079807f169 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 similarity index 68% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/MessageHandler.kt rename to android/lib/ipc/src/main/kotlin/net/mullvad/mullvadvpn/lib/ipc/MessageHandler.kt index 3bbcae9361b0..04de35e3bda2 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 events(klass: KClass): Flow 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 38237e84b3e2..511c57494c8e 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 @@ -10,6 +10,7 @@ import net.mullvad.mullvadvpn.model.GeographicLocationConstraint import net.mullvad.mullvadvpn.model.ObfuscationSettings import net.mullvad.mullvadvpn.model.Ownership import net.mullvad.mullvadvpn.model.Providers +import net.mullvad.mullvadvpn.model.PlayPurchase 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/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 000000000000..9a1e34b62a76 --- /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() + .collect { initializePurchase() } + } + + scope.launch { + endpoint.dispatcher.parsedMessages + .filterIsInstance() + .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 c4a733d91987..70e7807ff921 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) { From ae7471c50a653133aae6472199d7b0d16ad2a145 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 00:56:10 +0100 Subject: [PATCH 05/22] Add payment module and billing payment repository --- android/app/build.gradle.kts | 1 + .../net/mullvad/mullvadvpn/di/UiModule.kt | 18 +- .../buildSrc/src/main/kotlin/Dependencies.kt | 2 + android/lib/billing/build.gradle.kts | 3 + .../lib/billing/BillingPaymentRepository.kt | 167 ++++++++++++++++++ .../ProductDetailsResultExtensions.kt | 13 ++ ...oductDetailsResultToPaymentAvailability.kt | 37 ++++ .../ProductDetailsToPaymentProduct.kt | 17 ++ .../PurchaseEventToPurchaseResult.kt | 11 ++ .../extension/PurchaseStateToPaymentStatus.kt | 11 ++ .../extension/PurchasesResultExtensions.kt | 13 ++ android/lib/payment/build.gradle.kts | 44 +++++ .../lib/payment/src/main/AndroidManifest.xml | 2 + .../lib/payment/PaymentRepository.kt | 20 +++ .../mullvadvpn/lib/payment/ProductIds.kt | 5 + .../lib/payment/model/PaymentAvailability.kt | 26 +++ .../lib/payment/model/PaymentProduct.kt | 7 + .../lib/payment/model/PaymentStatus.kt | 6 + .../mullvadvpn/lib/payment/model/ProductId.kt | 4 + .../lib/payment/model/ProductPrice.kt | 4 + .../lib/payment/model/PurchaseResult.kt | 34 ++++ .../lib/payment/model/VerificationResult.kt | 19 ++ android/settings.gradle.kts | 3 +- 23 files changed, 449 insertions(+), 18 deletions(-) create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepository.kt create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultExtensions.kt create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultToPaymentAvailability.kt create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsToPaymentProduct.kt create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchaseEventToPurchaseResult.kt create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchaseStateToPaymentStatus.kt create mode 100644 android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/PurchasesResultExtensions.kt create mode 100644 android/lib/payment/build.gradle.kts create mode 100644 android/lib/payment/src/main/AndroidManifest.xml create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentRepository.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/ProductIds.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentAvailability.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentProduct.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentStatus.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductId.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductPrice.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PurchaseResult.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/VerificationResult.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6250d03ee079..3da373a36894 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -315,6 +315,7 @@ dependencies { implementation(project(Dependencies.Mullvad.resourceLib)) implementation(project(Dependencies.Mullvad.talpidLib)) implementation(project(Dependencies.Mullvad.themeLib)) + implementation(project(Dependencies.Mullvad.paymentLib)) implementation(Dependencies.androidMaterial) implementation(Dependencies.commonsValidator) 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 5be527ac0cc9..de8801557a47 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 @@ -12,7 +12,6 @@ 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 @@ -23,10 +22,7 @@ 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 @@ -104,20 +100,8 @@ val uiModule = module { single { RelayListListener(get()) } - // Will be resolved using from either of the two PaymentModule.kt classes. - single { PaymentProvider(get()) } - - single { - val paymentRepository = get().paymentRepository - if (paymentRepository != null) { - PlayPaymentUseCase(paymentRepository = paymentRepository) - } else { - EmptyPaymentUseCase() - } - } - // View models - viewModel { AccountViewModel(get(), get(), get(), get()) } + viewModel { AccountViewModel(get(), get(), get()) } viewModel { ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG) } diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index b72ae8f758d0..57af45997b17 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -103,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/lib/billing/build.gradle.kts b/android/lib/billing/build.gradle.kts index a01053408579..255459f45324 100644 --- a/android/lib/billing/build.gradle.kts +++ b/android/lib/billing/build.gradle.kts @@ -54,6 +54,9 @@ dependencies { //IPC implementation(project(Dependencies.Mullvad.ipcLib)) + //Payment library + implementation(project(Dependencies.Mullvad.paymentLib)) + // Test dependencies testImplementation(project(Dependencies.Mullvad.commonTestLib)) testImplementation(Dependencies.Kotlin.test) 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 000000000000..76df623ada75 --- /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 = 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 = 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 = 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/extension/ProductDetailsResultExtensions.kt b/android/lib/billing/src/main/kotlin/net/mullvad/mullvadvpn/lib/billing/extension/ProductDetailsResultExtensions.kt new file mode 100644 index 000000000000..3e4aee180a34 --- /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 000000000000..37cc7017242b --- /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 +) = + 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 000000000000..fa9a20613f32 --- /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) = + PaymentProduct( + productId = ProductId(this.productId), + price = ProductPrice(this.oneTimePurchaseOfferDetails?.formattedPrice ?: ""), + productIdToStatus[this.productId] + ) + +fun List.toPaymentProducts(productIdToStatus: Map) = + 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 000000000000..e0e4bf0a7784 --- /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 000000000000..701e5fde3dc1 --- /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 000000000000..d76d1a8b7e61 --- /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 = + 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/payment/build.gradle.kts b/android/lib/payment/build.gradle.kts new file mode 100644 index 000000000000..23f945b4f9f6 --- /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 000000000000..b2d3ea123526 --- /dev/null +++ b/android/lib/payment/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + 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 000000000000..73fd0c061db0 --- /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 + + fun verifyPurchases(): Flow + + fun queryPaymentAvailability(): Flow +} 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 000000000000..87549688913e --- /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 000000000000..cee280067775 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/PaymentAvailability.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.lib.payment.model + +sealed interface PaymentAvailability { + data object Loading : PaymentAvailability + + data class ProductsAvailable(val products: List) : 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 000000000000..8945453d3773 --- /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 000000000000..37574249a631 --- /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 000000000000..49a367f7c2d2 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductId.kt @@ -0,0 +1,4 @@ +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 000000000000..4939eac3a523 --- /dev/null +++ b/android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/model/ProductPrice.kt @@ -0,0 +1,4 @@ +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 000000000000..f5b89bffe64a --- /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 000000000000..725ea0af68f2 --- /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/settings.gradle.kts b/android/settings.gradle.kts index 53f385bae109..b598a49029ce 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -12,7 +12,8 @@ include( ":lib:talpid", ":lib:theme", ":lib:common-test", - ":lib:billing" + ":lib:billing", + ":lib:payment" ) include( ":test", From 81beb5e1d5aa729d9a0985c010c446ac87f54fe8 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 00:56:43 +0100 Subject: [PATCH 06/22] Add unit tests for BillingPaymentRepository --- .../billing/BillingPaymentRepositoryTest.kt | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt 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 000000000000..6a6b11010f92 --- /dev/null +++ b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt @@ -0,0 +1,378 @@ +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.After +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(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(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(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(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductStartPurchaseFetchProductsError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk() + 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(awaitItem()) + val result = awaitItem() + assertIs(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductStartPurchaseNoProductsFoundError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk() + 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(awaitItem()) + val result = awaitItem() + assertIs(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductStartPurchaseTransactionIdError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk() + 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(awaitItem()) + assertIs(awaitItem()) + val result = awaitItem() + assertIs(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductStartPurchaseFlowBillingError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk() + 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(awaitItem()) + assertIs(awaitItem()) + val result = awaitItem() + assertIs(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductPurchaseCanceled() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk() + 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(awaitItem()) + assertIs(awaitItem()) + assertIs(awaitItem()) + purchaseEventFlow.tryEmit(PurchaseEvent.UserCanceled) + val result = awaitItem() + assertIs(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductVerificationError() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk() + 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(awaitItem()) + assertIs(awaitItem()) + assertIs(awaitItem()) + purchaseEventFlow.tryEmit(PurchaseEvent.Completed(listOf(mockBillingPurchase))) + assertIs(awaitItem()) + val result = awaitItem() + assertIs(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductPurchaseCompleted() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk() + 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(awaitItem()) + assertIs(awaitItem()) + assertIs(awaitItem()) + purchaseEventFlow.tryEmit(PurchaseEvent.Completed(listOf(mockBillingPurchase))) + assertIs(awaitItem()) + val result = awaitItem() + assertIs(result) + awaitComplete() + } + } + + @Test + fun testPurchaseBillingProductPurchasePending() = runTest { + // Arrange + val mockProductId = ProductId("MOCK") + val mockProductDetailsResult = mockk() + 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(awaitItem()) + assertIs(awaitItem()) + assertIs(awaitItem()) + purchaseEventFlow.tryEmit(PurchaseEvent.Completed(listOf(mockBillingPurchase))) + val result = awaitItem() + assertIs(result) + awaitComplete() + } + } + + companion object { + private const val PRODUCT_DETAILS_TO_PAYMENT_PRODUCT_EXT = + "net.mullvad.mullvadvpn.lib.billing.extension.ProductDetailsToPaymentProductKt" + } +} From 2a0c1b1e84d28fa8f3db3dda0c115059c678eb35 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 00:57:13 +0100 Subject: [PATCH 07/22] Add PaymentUseCase --- .../mullvadvpn/usecase/PaymentUseCase.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt 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 000000000000..151e2caec7cd --- /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 + val purchaseResult: Flow + + 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(null) + private val _purchaseResult = MutableStateFlow(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(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 + } +} From 868e35cb9a814fea6c313eb93849ab426326be53 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 00:57:41 +0100 Subject: [PATCH 08/22] Add unit tests for PlayPaymentUseCase --- .../usecase/PlayPaymentUseCaseTest.kt | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt 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 000000000000..a1d8bee37a41 --- /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() } + } +} From 91a030a18450c5b7bc531544c54df0445746f1fa Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 02:02:30 +0100 Subject: [PATCH 09/22] Add payment support to AccountViewModel --- android/app/build.gradle.kts | 3 + .../compose/dialog/ChangelogDialog.kt | 5 +- .../mullvadvpn/compose/dialog/InfoDialog.kt | 2 +- .../compose/dialog/RedeemVoucherDialog.kt | 2 +- .../dialog/payment/PaymentDialogData.kt | 26 ++++++ .../mullvadvpn/compose/state/PaymentState.kt | 19 +++++ .../net/mullvad/mullvadvpn/di/UiModule.kt | 22 +++++- .../net/mullvad/mullvadvpn/ui/MainActivity.kt | 4 +- .../util/PaymentAvailabilityExtensions.kt | 19 +++++ .../util/PurchaseResultExtensions.kt | 78 ++++++++++++++++++ .../mullvadvpn/viewmodel/AccountViewModel.kt | 79 ++++++++++++++++--- .../mullvad/mullvadvpn/di/PaymentModule.kt | 6 ++ .../mullvad/mullvadvpn/di/PaymentModule.kt | 14 ++++ .../buildSrc/src/main/kotlin/Extensions.kt | 3 + .../mullvadvpn/lib/payment/PaymentProvider.kt | 3 + .../resource/src/main/res/values/strings.xml | 16 +++- 16 files changed, 278 insertions(+), 23 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/PaymentState.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt create mode 100644 android/app/src/oss/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt create mode 100644 android/app/src/play/kotlin/net/mullvad/mullvadvpn/di/PaymentModule.kt create mode 100644 android/lib/payment/src/main/kotlin/net/mullvad/mullvadvpn/lib/payment/PaymentProvider.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3da373a36894..a3035f0a12da 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -317,6 +317,9 @@ dependencies { implementation(project(Dependencies.Mullvad.themeLib)) implementation(project(Dependencies.Mullvad.paymentLib)) + // Play implementation + playImplementation(project(Dependencies.Mullvad.billingLib)) + implementation(Dependencies.androidMaterial) implementation(Dependencies.commonsValidator) implementation(Dependencies.AndroidX.appcompat) 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 89af2eafe910..9ce21c6bac1c 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, 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 ad129324052d..d032a9fa8e08 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 14afdbcf24c1..c5b619a9cd2d 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/PaymentDialogData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt new file mode 100644 index 000000000000..987696461049 --- /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/state/PaymentState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/PaymentState.kt new file mode 100644 index 000000000000..60f8d5864f16 --- /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) : PaymentState + + sealed interface Error : PaymentState { + data object Generic : Error + + data object Billing : Error + } +} 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 de8801557a47..6ed744aef929 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 @@ -12,6 +12,7 @@ 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 @@ -22,7 +23,10 @@ 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 { + val paymentRepository = get().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(), get()) } + viewModel { WelcomeViewModel(get(), get(), get()) } viewModel { ReportProblemViewModel(get()) } viewModel { ViewLogsViewModel(get()) } - viewModel { OutOfTimeViewModel(get(), get(), get(), get()) } + viewModel { OutOfTimeViewModel(get(), get(), get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" 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 98b0c0576c37..f299b8c956bd 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/util/PaymentAvailabilityExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt new file mode 100644 index 000000000000..6a69a807f132 --- /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 000000000000..bf6dbec35e73 --- /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 fb3e3d6393f5..5f721674990a 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(extraBufferCapacity = 1) private val _enterTransitionEndAction = MutableSharedFlow() + val uiSideEffect = _uiSideEffect.asSharedFlow() - val uiState = - combine(deviceRepository.deviceState, accountRepository.accountExpiryState) { - deviceState, - accountExpiry -> + val uiState: StateFlow = + 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/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 000000000000..cb5cb649a65b --- /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 000000000000..82738b5246a8 --- /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/buildSrc/src/main/kotlin/Extensions.kt b/android/buildSrc/src/main/kotlin/Extensions.kt index 8659cb8ecb54..0115aa9f300d 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/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 000000000000..431b406dc0a0 --- /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/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index a769d91bcbc9..f3b0b0d157fa 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 @@ Show account number Failed to remove device Changes in this version: - Got it! Always-on VPN assigned to other app %s before using Mullvad VPN.]]> @@ -229,4 +228,19 @@ less than one day Time left: %s Device name: %s + Add 30 days time (%s) + Add 30 days time + Time was successfully added + 30 days was added to your account. + Got it! + Google Play unavailable + We were unable to start the payment process, please make sure you have the latest version of Google Play. + Mullvad services unavailable + We were unable to start the payment process, please try again later. + Google Play payment pending + Verifying purchase + Verifying purchase + We are currently verifying your purchase, this might take some time. Your time will be added if the verification is successful. + Connecting... + Verifying purchase... From 06a950f5982d0d56031803a6d96a7bce6701bc08 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:00:20 +0100 Subject: [PATCH 10/22] Add payment unit tests for AccountViewModel --- .../viewmodel/AccountViewModelTest.kt | 141 +++++++++++++++++- 1 file changed, 137 insertions(+), 4 deletions(-) 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 fc1fd5e99b04..c02e75595158 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 = MutableStateFlow(DeviceState.Initial) + private val paymentAvailability = MutableStateFlow(null) + private val purchaseResult = MutableStateFlow(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(result) + } + } + + @Test + fun testBillingProductsGenericErrorState() = runTest { + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + paymentAvailability.tryEmit(PaymentAvailability.Error.Other(mockk())) + val result = awaitItem().billingPaymentState + assertIs(result) + } + } + + @Test + fun testBillingProductsBillingErrorState() = runTest { + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + paymentAvailability.tryEmit(PaymentAvailability.Error.BillingUnavailable) + val result = awaitItem().billingPaymentState + assertIs(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(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" } } From 52e2705f246b390a7c84597498677226057f76c3 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:02:13 +0100 Subject: [PATCH 11/22] Add payment support to account screen --- .../compose/component/PlayPayment.kt | 188 ++++++++++++++++++ .../compose/dialog/payment/PaymentDialog.kt | 186 +++++++++++++++++ .../payment/VerificationPendingDialog.kt | 48 +++++ .../compose/screen/AccountScreen.kt | 72 ++++++- .../mullvadvpn/ui/fragment/AccountFragment.kt | 4 + .../lib/theme/dimensions/Dimensions.kt | 1 + 6 files changed, 495 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt 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 000000000000..a56d3dff74eb --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt @@ -0,0 +1,188 @@ +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.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.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 + ) { + 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/payment/PaymentDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt new file mode 100644 index 000000000000..7e94b7455efe --- /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/VerificationPendingDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/VerificationPendingDialog.kt new file mode 100644 index 000000000000..112afeebf5c0 --- /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 34ba02d75628..fecd23406a8f 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().asSharedFlow(), enterTransitionEndAction = MutableSharedFlow() @@ -70,12 +96,18 @@ private fun PreviewAccountScreen() { @ExperimentalMaterial3Api @Composable fun AccountScreen( + showSitePayment: Boolean, uiState: AccountUiState, uiSideEffect: SharedFlow, enterTransitionEndAction: SharedFlow, 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/ui/fragment/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt index efdc0783a3ce..5225368dacbb 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/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 20a1c2f3e1ed..404b556d941e 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, From fc8828f91e2bce52ada92ffb96af4cb69c673368 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:02:53 +0100 Subject: [PATCH 12/22] Add ui tests for account screen --- .../compose/screen/AccountScreenTest.kt | 234 +++++++++++++++++- .../compose/component/PlayPayment.kt | 5 +- .../compose/test/ComposeTestTagConstants.kt | 3 + 3 files changed, 237 insertions(+), 5 deletions(-) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt index aec6c85595d4..e997ae29e4e3 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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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().asSharedFlow(), + enterTransitionEndAction = MutableSharedFlow().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/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt index a56d3dff74eb..3f396cf69853 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt @@ -13,12 +13,14 @@ 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 @@ -156,7 +158,8 @@ fun PlayPayment( modifier = Modifier.padding(bottom = Dimens.smallPadding) ) IconButton( - onClick = onInfoClick + onClick = onInfoClick, + modifier = Modifier.testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG) ) { Icon( painter = painterResource(id = R.drawable.icon_info), 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 dea9e12a3d82..14a42403e179 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" From 9fefdc3d5a9b7f6815d67db121d73cea90edbff7 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:03:32 +0100 Subject: [PATCH 13/22] Add billing payment to welcome screen and view model --- .../compose/screen/WelcomeScreen.kt | 74 +++++++++++++++++-- .../compose/state/WelcomeUiState.kt | 5 +- .../net/mullvad/mullvadvpn/di/UiModule.kt | 2 +- .../mullvadvpn/ui/fragment/WelcomeFragment.kt | 4 +- .../mullvadvpn/viewmodel/WelcomeViewModel.kt | 57 ++++++++++++-- 5 files changed, 129 insertions(+), 13 deletions(-) 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 f3c9f9dc7ee6..d26e8c826539 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().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/WelcomeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt index c6959f23e016..bd1c19e9c9e3 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/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 6ed744aef929..26912652db28 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 @@ -130,7 +130,7 @@ 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()) } 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 d04c5de53afd..5c5e0c83f85b 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/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt index 6c9b2ea75db0..b02a1599a4c3 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(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 = @@ -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 From 3455fda161edca6f575cc78796ffc87331bcbea7 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:04:09 +0100 Subject: [PATCH 14/22] Add payment unit tests for welcome view model --- .../viewmodel/WelcomeViewModelTest.kt | 132 +++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) 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 b16eeec2f890..e958df9337c1 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.Disconnected) private val deviceState = MutableStateFlow(DeviceState.Initial) private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing) + private val purchaseResult = MutableStateFlow(null) + private val paymentAvailability = MutableStateFlow(null) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -50,15 +64,17 @@ class WelcomeViewModelTest { // Event notifiers private val eventNotifierTunnelUiState = EventNotifier(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(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(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(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(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" } } From f95bf57f088b9e7bd5dfb958707853ed63408fd4 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:04:42 +0100 Subject: [PATCH 15/22] Add payment ui tests to Welcome Screen --- .../compose/screen/WelcomeScreenTest.kt | 301 +++++++++++++++++- 1 file changed, 294 insertions(+), 7 deletions(-) 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 8331794cab0f..a54c41c20d71 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().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().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().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().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().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()) } + } } From 8afe089bb7990328e7d38b8c3133289e36f83203 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:05:49 +0100 Subject: [PATCH 16/22] Add billing payment to out of time screen and view model --- .../compose/screen/OutOfTimeScreen.kt | 46 ++++++++++++- .../compose/state/OutOfTimeUiState.kt | 5 +- .../net/mullvad/mullvadvpn/di/UiModule.kt | 2 +- .../ui/fragment/OutOfTimeFragment.kt | 4 +- .../viewmodel/OutOfTimeViewModel.kt | 64 +++++++++++++++---- 5 files changed, 106 insertions(+), 15 deletions(-) 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 efb07acfa23c..a7fd6bae2fa0 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/state/OutOfTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt index f7794e5a5599..0491f80ea0de 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/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 26912652db28..5be527ac0cc9 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 @@ -133,7 +133,7 @@ val uiModule = module { 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/ui/fragment/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt index 53df05c5f3fe..5a1ae49e1a19 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/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt index b1df2d222586..e570f7a0fe5d 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 = @@ -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 From 5aac2637cbb9c3acf5504570d8a0014d609ca33b Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:06:15 +0100 Subject: [PATCH 17/22] Add payment unit tests for OutOfTimeViewModel --- .../viewmodel/OutOfTimeViewModelTest.kt | 152 +++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) 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 8c1ec10f5a22..dad51eab59ae 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.Disconnected) private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing) private val deviceState = MutableStateFlow(DeviceState.Initial) + private val paymentAvailability = MutableStateFlow(null) + private val purchaseResult = MutableStateFlow(null) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -50,15 +64,17 @@ class OutOfTimeViewModelTest { // Event notifiers private val eventNotifierTunnelRealState = EventNotifier(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(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(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(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(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" } } From dc3f2b311ad56f98861f720e614eb82c66fd0a09 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:06:57 +0100 Subject: [PATCH 18/22] Add payment ui tests to OutOfTimeScreen --- .../compose/screen/OutOfTimeScreenTest.kt | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) 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 b0198316e393..28e2519c8142 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().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().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().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().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().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()) } + } } From 3bf12a6fc041760057a631961bd3460da9d58800 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:07:17 +0100 Subject: [PATCH 19/22] Update gradle lockfile --- android/gradle/verification-metadata.xml | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index d26a920adddc..074e8a71a28e 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -1468,6 +1468,16 @@ + + + + + + + + + + @@ -1971,6 +1981,21 @@ + + + + + + + + + + + + + + + @@ -2062,6 +2087,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -3363,6 +3408,11 @@ + + + + + From 382111e2db5354dc23684c277beb8daf1d51789a Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 01:07:34 +0100 Subject: [PATCH 20/22] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 353a90c912f5..3c81c2e56ee1 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. From b9c41d53425459f17593e69f99b4cf72d5611af9 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 16 Nov 2023 08:09:55 +0100 Subject: [PATCH 21/22] Add translations and update messages.pot --- .../src/main/res/values-da/strings.xml | 3 +- .../src/main/res/values-de/strings.xml | 3 +- .../src/main/res/values-es/strings.xml | 3 +- .../src/main/res/values-fi/strings.xml | 3 +- .../src/main/res/values-fr/strings.xml | 3 +- .../src/main/res/values-it/strings.xml | 3 +- .../src/main/res/values-ja/strings.xml | 3 +- .../src/main/res/values-ko/strings.xml | 3 +- .../src/main/res/values-my/strings.xml | 3 +- .../src/main/res/values-nb/strings.xml | 3 +- .../src/main/res/values-nl/strings.xml | 3 +- .../src/main/res/values-pl/strings.xml | 3 +- .../src/main/res/values-pt/strings.xml | 3 +- .../src/main/res/values-ru/strings.xml | 3 +- .../src/main/res/values-sv/strings.xml | 3 +- .../src/main/res/values-th/strings.xml | 3 +- .../src/main/res/values-tr/strings.xml | 3 +- .../src/main/res/values-zh-rCN/strings.xml | 3 +- .../src/main/res/values-zh-rTW/strings.xml | 3 +- gui/locales/messages.pot | 36 +++++++++++++++++++ 20 files changed, 74 insertions(+), 19 deletions(-) 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 503a66789a9f..160a34ea5f9e 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 @@ Køb kredit Køb mere kredit Annuller - Forstået! Ændringer i denne version: Den lokale DNS-server fungerer ikke, medmindre du aktiverer \"Lokal netværksdeling\" under Indstillinger. 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. @@ -89,6 +88,7 @@ Viser den aktuelle VPN-tunnelstatus VPN-tunnelstatus Gå til login + Forstået! Her er dit kontonummer. Gem det! Skjul kontonummer Standard @@ -133,6 +133,7 @@ Tid udløbet Betalt indtil For at begynde at bruge appen skal du først føje tid til din konto. + Tid blev tilføjet Port Privatliv Fortrolighedspolitik 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 fd9fc039e126..fd6b6f8d59e6 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 @@ Guthaben erwerben Mehr Guthaben erwerben Abbrechen - Verstanden! Änderungen in dieser Version: Der lokale DNS-Server wird nicht funktionieren, solange „Teilen im lokalen Netzwerk“ nicht in den Einstellungen aktiviert ist. 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. @@ -89,6 +88,7 @@ Zeigt den aktuellen Status des VPN-Tunnels an Status des VPN-Tunnels Zur Anmeldung + Verstanden! Hier ist Ihre Kontonummer. Verlieren Sie sie nicht! Kontonummer verbergen Standard @@ -133,6 +133,7 @@ Zeit abgelaufen Bezahlt bis Um mit der Nutzung dieser App zu beginnen, müssen Sie erst einmal Zeit zu Ihrem Konto hinzufügen. + Zeit erfolgreich hinzugefügt Port Datenschutz Datenschutzrichtlinie 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 d936ca5ba953..4eff30768e37 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 @@ Comprar créditos Comprar más créditos Cancelar - ¡Entendido! Cambios en esta versión: El servidor DNS local no funcionará a no ser que habilite la opción «Uso compartido de red local» en Preferencias. 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. @@ -89,6 +88,7 @@ Muestra el estado actual del túnel VPN Estado del túnel VPN Iniciar sesión + ¡Entendido! Este es un número de cuenta. ¡Guárdelo bien! Ocultar número de cuenta Predeterminado @@ -133,6 +133,7 @@ Tiempo agotado Pagado hasta Para empezar a usar la aplicación, primero necesita agregar tiempo a su cuenta. + Se añadió correctamente el tiempo Puerto Privacidad Política de privacidad 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 c0c14178dbf1..a48af8610ea8 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 @@ Osta käyttöaikaa Uudista tilaus Peruuta - Selvä! Muutokset tässä versiossa: Paikallinen DNS-palvelin ei toimi, ellet ota paikallisen verkon jakamisasetusta käyttöön asetuksissa. Olet aikeissa lähettää ongelmaraportin ilman yhteystietojasi. Mikäli haluat vastauksen raporttiisi, anna sähköpostosoite. @@ -89,6 +88,7 @@ Näyttää VPN-tunnelin nykyisen tilan VPN-tunnelin tila Siirry kirjautumiseen + Selvä! Tässä tulee tilisi numero. Laita se talteen! Piilota tilin numero Oletus @@ -133,6 +133,7 @@ Ei käyttöaikaa Maksu ennen Voit aloittaa sovelluksen käyttämisen lisäämällä ensin aikaa tilillesi. + Aika lisättiin onnistuneesti Portti Tietosuoja Tietosuojakäytäntö 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 26272030bd12..0abbdf0037b4 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 @@ Acheter des crédits Acheter plus de crédits Annuler - Compris ! Modifications dans cette version : Le serveur DNS local ne fonctionnera pas si vous n\'activez pas le « Partage du réseau local » dans les préférences. 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. @@ -89,6 +88,7 @@ Affiche l\'état actuel du tunnel VPN État du tunnel VPN Aller à la connexion + Compris ! Voici votre numéro de compte. Gardez-le ! Masquer le numéro de compte Par défaut @@ -133,6 +133,7 @@ Plus de temps Payé jusqu\'au Pour commencer à utiliser l\'application, vous devez d\'abord ajouter du temps à votre compte. + Le temps a bien été ajouté Port Confidentialité Politique de confidentialité 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 02a9572498b9..8ae92f4880ee 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 @@ Acquista credito Acquista altro credito Annulla - Ok! Modifiche in questa versione: Il server DNS locale non funzionerà a meno che non si abiliti \"Condivisione rete locale\" in Preferenze. Stai inviando la segnalazione di un problema senza averci indicato un modo per ricontattarti. Se desideri ricevere risposta, inserisci un indirizzo e-mail. @@ -89,6 +88,7 @@ Mostra lo stato attuale del tunnel VPN Stato del tunnel VPN Vai al login + Ok! Ecco il tuo numero di account. Salvalo! Nascondi numero di account Predefinito @@ -133,6 +133,7 @@ Scaduto Pagato fino al Per iniziare a utilizzare l\'app, devi prima aggiungere tempo al tuo account. + L\'ora è stata aggiunta correttamente Porta Privacy Informativa sulla privacy 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 a132977f847f..a05cdc3db722 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 @@ クレジットを購入 追加クレジットを購入 キャンセル - 了解 このバージョンでの変更内容: 環境設定で \"ローカルネットワーク共有\" を有効にしない限り、ローカルDNSサーバーは機能しません。 お客様への返信先を入力せずに問題の報告を送信しようとしています。ご報告に対する返信が必要な場合は、返信先のメールアドレスを入力する必要があります。 @@ -89,6 +88,7 @@ 現在のVPNトンネルのステータスを表示します VPNトンネルのステータス ログインに進む + 了解 これがあなたのアカウント番号です。保存してください! アカウント番号の非表示 デフォルト @@ -133,6 +133,7 @@ 時間切れ 次の日時まで支払い済み アプリを使い始めるには、まずはアカウントに時間を追加する必要があります。 + 時間を正常に追加しました ポート プライバシー プライバシーポリシー 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 ea976767b2f6..b3a098c919b2 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 @@ 크레딧 구매 추가 크레딧 구매 취소 - 확인! 이 버전의 변경 사항: 환경 설정에서 ”로컬 네트워크 공유”를 활성화하지 않으면 로컬 DNS 서버가 작동하지 않습니다. 연락처 없이 문제 보고서를 보내려고 합니다. 보고서에 대한 답변을 원하면 이메일 주소를 입력해야 합니다. @@ -89,6 +88,7 @@ 현재 VPN 터널 상태 표시 VPN 터널 상태 로그인하기 + 확인! 계정 번호는 다음과 같습니다. 저장하세요! 계정 번호 숨기기 기본값 @@ -133,6 +133,7 @@ 시간 초과 유효 기간 앱 사용을 시작하려면, 먼저 계정에 시간을 추가해야 합니다. + 시간이 성공적으로 추가되었습니다. 포트 개인 정보 보호 개인정보 보호정책 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 0b4014148fde..bfd0e3e2bdb1 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 @@ ခရက်ဒစ် ဝယ်ရန် ခရက်ဒစ်များ ဝယ်ရန် မလုပ်တော့ပါ - ရပါပြီ။ ဤဗားရှင်းတွင် ပြောင်းလဲမှုများ- လိုကယ် DNS ဆာဗာသည် လိုလားမှုများအောက်ရှိ \"လိုကယ် ကွန်ရက် ဝေမျှမှု\"ကို မဖွင့်မချင်း အလုပ်လုပ်မည် မဟုတ်ပါ။ သင်သည် သင့်ထံ ကျွန်ုပ်တို့ ပြန်ဆက်သွယ်နိုင်မည့် နည်းလမ်း မပါဘဲ ပြဿနာ ရီပို့တ်ကို ပေးပို့တော့မည် ဖြစ်ပါသည်။ သင့်ရီပို့တ်အတွက် အဖြေ ရရှိလိုပါက အီမေးလိပ်စာ ဖြည့်သွင်းပေးရပါမည်။ @@ -89,6 +88,7 @@ လက်ရှိ VPN Tunnel အခြေအနေကို ပြသပေးပါသည် VPN Tunnel အခြေအနေ ဝင်ရောက်ရန် သွားပါ + ရပါပြီ။ ဤသည်မှာ သင့်အကောင့်နံပါတ် ဖြစ်ပါသည်။ သိမ်းမှတ်ထားပါ။ အကောင့်နံပါတ်ကို ဝှက်ရန် ပုံသေ @@ -133,6 +133,7 @@ အချိန်စေ့သွားပါပြီ ဖော်ပြပါအထိ ပေးချေထားပြီး အက်ပ်ကို စသုံးရန်အတွက် ဦးစွာ သင့်အကောင့်တွင် အချိန်ပေါင်းထည့်ပေးရန် လိုအပ်ပါသည်။ + အချိန်ကို အောင်မြင်စွာ ပေါင်းထည့်ပြီးပြီ ပေါ့တ် ကိုယ်ရေးအချက်အလက် လုံခြုံရေး ကိုယ်ပိုင်အချက်အလက် မူဝါဒ 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 47330dfb71e2..d9fa3d5e8557 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 @@ Kjøp kreditt Kjøp mer kreditt Avbryt - Forstått! Endringer i denne versjonen: Den lokale DNS-serveren fungerer ikke med mindre du aktiverer «Deling av lokalt nettverk» under Innstillinger. 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. @@ -89,6 +88,7 @@ Viser gjeldende VPN-tunnelstatus VPN-tunnelstatus Gå til pålogging + Forstått! Dette er kontonummeret ditt. Ta vare på det! Skjul kontonummer Standard @@ -133,6 +133,7 @@ Tiden har utløpt Betalt fram til For å starte bruken av appen, må du først legge til tid til kontoen. + Tid ble lagt til Port Personvern Retningslinjer for personvern 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 0d9d53f35b64..07de8454437d 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 @@ Krediet kopen Meer krediet kopen Annuleren - Begrepen! Wijzigingen in deze versie: De lokale DNS-server werkt niet tenzij u \"Lokale netwerken delen\" inschakelt onder Voorkeuren. 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. @@ -89,6 +88,7 @@ Toont de huidige status van de VPN-tunnel Status VPN-tunnel Ga naar aanmelden + Begrepen! Hier is uw accountnummer. Sla het op! Accountnummer verbergen Standaard @@ -133,6 +133,7 @@ Geen tijd meer Betaald tot Om de app te gebruiken, moet u eerst tijd toevoegen aan uw account. + Tijd is toegevoegd Poort Privacy Privacybeleid 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 062bfa926d1a..fdbc8b58bbaa 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 @@ Kup doładowanie Doładuj konto Anuluj - Jasne! Zmiany w tej wersji: Lokalny serwer DNS nie będzie działał, dopóki nie włączysz opcji „Udostępnianie sieci lokalnej” w Preferencjach. 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. @@ -89,6 +88,7 @@ Pokazuje bieżący status tunelu VPN Status tunelu VPN Przejdź do logowania + Jasne! Oto Twój numer konta. Zachowaj go! Ukryj numer konta Domyślnie @@ -133,6 +133,7 @@ Koniec czasu Płatne do Aby rozpocząć korzystanie z aplikacji, musisz najpierw dodać czas do swojego konta. + Dodano czas Port Prywatność Polityka prywatności 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 79d4b57a3d31..702161aeff98 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 @@ Comprar crédito Comprar mais crédito Cancelar - Entendido! Alterações nesta versão: O servidor DNS local não funcionará exceto se ativar \"Partilha de rede local\" em Preferências. 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. @@ -89,6 +88,7 @@ Indica o estado atual do túnel VPN Estado do túnel VPN Ir para a ligação + Entendido! Aqui tem o seu número de conta. Guarde-o! Ocultar número de conta Padrão @@ -133,6 +133,7 @@ Sem tempo Pago até Para começar a utilizar a aplicação, primeiro tem de adicionar tempo à sua conta. + Tempo adicionado com sucesso Porta Privacidade Política de privacidade 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 95a4dc2d7a06..c2f122a86265 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 @@ Пополнить баланс Пополнить баланс Отмена - Понятно! Изменения в этой версии: Локальный DNS-сервер не будет работать, пока вы не включите «Обмен данными в локальной сети» в разделе «Параметры». Вы собираетесь отправить отчет о проблеме, не оставив контакты. Если вы хотите получить ответ, введите свой адрес электронной почты. @@ -89,6 +88,7 @@ Показывает текущее состояние VPN-туннеля Состояние туннеля VPN Войти + Понятно! Вот номер вашей учетной записи. Сохраните его! Скрыть номер учетной записи По умолчанию @@ -133,6 +133,7 @@ Закончилось время Оплачено до Чтобы пользоваться приложением, нужно добавить время на учетную запись. + Время добавлено Порт Конфиденциальность Политика конфиденциальности 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 4ae8326b6f22..c453cb2fa9fa 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 @@ Köp kredit Köp mer kredit Avbryt - Jag förstår! Ändringar i den här versionen: Den lokala DNS-servern fungerar inte om du inte aktiverar \"Lokal nätverksdelning\" under Inställningar. 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. @@ -89,6 +88,7 @@ Visar nuvarande status för VPN-tunnel VPN-tunnelstatus Gå till inloggning + Jag förstår! Här är ditt kontonummer. Spara det! Dölj kontonummer Standard @@ -133,6 +133,7 @@ Ingen tid kvar Betalat till Om du vill börja använda appen måste du först lägga till tid i ditt konto. + Tid har lagts till Port Sekretess Sekretesspolicy 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 0297f8be3e29..cce7148a7f83 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 @@ ซื้อเครดิต ซื้อเครดิตเพิ่ม ยกเลิก - รับทราบ! การเปลี่ยนแปลงในเวอร์ชันนี้: เซิร์ฟเวอร์ DNS ท้องถิ่นจะไม่ทำงาน เว้นแต่คุณจะเปิดใช้ \"การแชร์ในเครือข่ายท้องถิ่น\" ซึ่งอยู่ในส่วนการกำหนดค่า คุณกำลังจะส่งรายงานปัญหา โดยไม่มีการระบุวิธีการติดต่อกลับให้กับเรา และคุณจำเป็นต้องป้อนที่อยู่อีเมลของคุณ หากคุณอยากให้เราตอบกลับการรายงานของคุณ @@ -89,6 +88,7 @@ แสดงสถานะอุโมงค์ VPN ในปัจจุบัน สถานะอุโมงค์ VPN ไปเข้าสู่ระบบ + รับทราบ! นี่คือหมายเลขบัญชีของคุณ จดบันทึกไว้ด้วยนะ! ซ่อนหมายเลขบัญชี ค่าเริ่มต้น @@ -133,6 +133,7 @@ หมดเวลา ชำระเงินแล้วจนถึง คุณจำเป็นต้องเพิ่มเวลาไปยังบัญชีของคุณก่อน เพื่อที่จะเริ่มใช้งานแอป + เพิ่มเวลาสำเร็จแล้ว พอร์ต ความเป็นส่วนตัว นโยบายความเป็นส่วนตัว 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 46d9797b696f..92a18749f5c7 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 @@ Kredi satın alın Daha fazla kredi satın alın İptal et - Anladım! Bu sürümdeki değişiklikler: Tercihler sekmesinin altındaki \"Yerel Ağ Paylaşımı\" seçeneğini etkinleştirmediğiniz sürece yerel DNS sunucusu çalışmaz. 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. @@ -89,6 +88,7 @@ Mevcut VPN tünelinin durumunu gösterir VPN tüneli durumu Giriş sayfasına git + Anladım! İşte hesap numaranız. Kaydedin! Hesap numarasını gizle Varsayılan @@ -133,6 +133,7 @@ Süre doldu Şu tarihe kadar ödendi: Uygulamayı kullanmaya başlamak için önce hesabınıza süre eklemeniz gerekir. + Süre başarıyla eklendi Port Gizlilik Gizlilik politikası 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 2134a212e382..26c61fa644b1 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 @@ 购买额度 购买更多额度 取消 - 知道了! 此版本中的变更: 除非您在“偏好设置”下启用“本地网络共享”,否则本地 DNS 服务器将不会运行。 您即将发送问题报告,但没有提供让我们可以联系到您的方式。如果您希望获得回复,必须输入您的电子邮件地址。 @@ -89,6 +88,7 @@ 显示当前的 VPN 隧道状态 VPN 隧道状态 前往登录 + 知道了! 以下是您的帐号。请妥善保存! 隐藏帐号 默认 @@ -133,6 +133,7 @@ 已没有时间 到期时间 要开始使用本应用,您首先需要向帐户中充入时间。 + 时间已成功添加 端口 隐私 隐私政策 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 1949488b299c..a3eee83ddea8 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 @@ 購買點數 購買更多點數 取消 - 知道了! 此版本中的變更: 若要使本機 DNS 伺服器運作,需先在「偏好設定」下啟用「本機網路共用」。 您即將傳送的問題報告未包含回覆方式資訊。如果想收到您這份報告的回覆,請輸入您的電子郵件位址。 @@ -89,6 +88,7 @@ 顯示目前的 VPN 通道狀態 VPN 通道狀態 前往登入 + 知道了! 以下是您的帳號。請妥善保管! 隱藏帳號 預設 @@ -133,6 +133,7 @@ 逾時 支付至 需先在帳戶中加時,才能開始使用本應用程式。 + 已成功新增時間 連接埠 隱私權 隱私權政策 diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index babbadbabb63..09529d0ebaac 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 "" From c8c896bc00b6b23ffaaf3a8708f03efb3ab3a0f2 Mon Sep 17 00:00:00 2001 From: Albin Date: Thu, 16 Nov 2023 10:39:40 +0100 Subject: [PATCH 22/22] Fix kotlin formatting --- .../mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt | 1 - .../main/kotlin/net/mullvad/mullvadvpn/lib/ipc/Request.kt | 2 +- .../mullvadvpn/lib/payment/model/PaymentAvailability.kt | 5 ++--- .../net/mullvad/mullvadvpn/lib/payment/model/ProductId.kt | 3 +-- .../net/mullvad/mullvadvpn/lib/payment/model/ProductPrice.kt | 3 +-- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt index 6a6b11010f92..fe25457e49eb 100644 --- a/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt +++ b/android/lib/billing/src/test/kotlin/net/mullvad/mullvadvpn/lib/billing/BillingPaymentRepositoryTest.kt @@ -25,7 +25,6 @@ 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.After import org.junit.Before import org.junit.Rule import org.junit.Test 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 511c57494c8e..b73010785a5d 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,8 +9,8 @@ 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.Providers import net.mullvad.mullvadvpn.model.PlayPurchase +import net.mullvad.mullvadvpn.model.Providers import net.mullvad.mullvadvpn.model.QuantumResistantState import net.mullvad.mullvadvpn.model.WireguardConstraints 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 index cee280067775..012237d82590 100644 --- 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 @@ -9,7 +9,7 @@ sealed interface PaymentAvailability { data object NoProductsFounds : PaymentAvailability - sealed interface Error: PaymentAvailability { + sealed interface Error : PaymentAvailability { data object BillingUnavailable : Error data object ServiceUnavailable : Error @@ -20,7 +20,6 @@ sealed interface PaymentAvailability { data object ItemUnavailable : Error - data class Other(val exception: Throwable) : - Error + data class Other(val exception: Throwable) : Error } } 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 index 49a367f7c2d2..f14fefab28b3 100644 --- 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 @@ -1,4 +1,3 @@ package net.mullvad.mullvadvpn.lib.payment.model -@JvmInline -value class ProductId(val value: String) +@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 index 4939eac3a523..5dc90db5fb6f 100644 --- 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 @@ -1,4 +1,3 @@ package net.mullvad.mullvadvpn.lib.payment.model -@JvmInline -value class ProductPrice(val value: String) +@JvmInline value class ProductPrice(val value: String)