From ed98934dc30771fd2078f069ccb159b739d8afd6 Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Thu, 7 Mar 2024 21:27:46 +0100 Subject: [PATCH] Refactor BillingClientWrapper (#4253) Task/Issue URL: https://app.asana.com/0/1205648422731273/1206760049330313/f ### Description See task. ### No UI changes --- subscriptions/subscriptions-impl/build.gradle | 1 + .../impl/SubscriptionsManager.kt | 21 +- .../impl/billing/BillingClientAdapter.kt | 76 +++++ .../impl/billing/BillingClientWrapper.kt | 318 ------------------ .../impl/billing/PlayBillingManager.kt | 216 ++++++++++++ .../impl/billing/RealBillingClientAdapter.kt | 189 +++++++++++ .../repository/SubscriptionsRepository.kt | 10 +- .../impl/RealSubscriptionsManagerTest.kt | 52 ++- .../billing/RealPlayBillingManagerTest.kt | 173 ++++++++++ .../RealSubscriptionsRepositoryTest.kt | 8 +- 10 files changed, 694 insertions(+), 370 deletions(-) create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientAdapter.kt delete mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientWrapper.kt create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt create mode 100644 subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt diff --git a/subscriptions/subscriptions-impl/build.gradle b/subscriptions/subscriptions-impl/build.gradle index 3f46669970d6..b20ce88e0c9d 100644 --- a/subscriptions/subscriptions-impl/build.gradle +++ b/subscriptions/subscriptions-impl/build.gradle @@ -76,6 +76,7 @@ dependencies { testImplementation AndroidX.core testImplementation AndroidX.test.ext.junit testImplementation "androidx.test:runner:_" + testImplementation "androidx.lifecycle:lifecycle-runtime-testing:_" testImplementation Testing.robolectric testImplementation 'app.cash.turbine:turbine:_' testImplementation project(path: ':common-test') diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 2a7aedbba69e..f1efe45a7262 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -30,7 +30,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Inactive import com.duckduckgo.subscriptions.impl.SubscriptionStatus.NotAutoRenewable import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Unknown import com.duckduckgo.subscriptions.impl.SubscriptionsData.* -import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper +import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AuthRepository @@ -146,7 +146,7 @@ class RealSubscriptionsManager @Inject constructor( private val authService: AuthService, private val subscriptionsService: SubscriptionsService, private val authRepository: AuthRepository, - private val billingClientWrapper: BillingClientWrapper, + private val playBillingManager: PlayBillingManager, private val emailManager: EmailManager, private val context: Context, @AppCoroutineScope private val coroutineScope: CoroutineScope, @@ -177,7 +177,7 @@ class RealSubscriptionsManager @Inject constructor( private suspend fun emitCurrentPurchaseValues() { purchaseStateJob?.cancel() purchaseStateJob = coroutineScope.launch(dispatcherProvider.io()) { - billingClientWrapper.purchaseState.collect { + playBillingManager.purchaseState.collect { when (it) { is PurchaseState.Purchased -> checkPurchase(it.packageName, it.purchaseToken) else -> { @@ -356,7 +356,7 @@ class RealSubscriptionsManager @Inject constructor( override suspend fun recoverSubscriptionFromStore(): SubscriptionsData { return try { - val purchase = billingClientWrapper.purchaseHistory.lastOrNull() + val purchase = playBillingManager.purchaseHistory.lastOrNull() return if (purchase != null) { val signature = purchase.signature val body = purchase.originalJson @@ -404,16 +404,15 @@ class RealSubscriptionsManager @Inject constructor( when (val response = prePurchaseFlow()) { is Success -> { if (response.entitlements.isEmpty()) { - val billingParams = billingClientWrapper.billingFlowParamsBuilder( - productDetails = productDetails, - offerToken = offerToken, - externalId = response.externalId, - isReset = isReset, - ).build() logcat(LogPriority.DEBUG) { "Subs: external id is ${response.externalId}" } _currentPurchaseState.emit(CurrentPurchase.PreFlowFinished) withContext(dispatcherProvider.main()) { - billingClientWrapper.launchBillingFlow(activity, billingParams) + playBillingManager.launchBillingFlow( + activity = activity, + productDetails = productDetails, + offerToken = offerToken, + externalId = response.externalId, + ) } } else { pixelSender.reportRestoreAfterPurchaseAttemptSuccess() diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientAdapter.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientAdapter.kt new file mode 100644 index 000000000000..52bec0685f15 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientAdapter.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.billing + +import android.app.Activity +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.PurchaseHistoryRecord + +interface BillingClientAdapter { + val ready: Boolean + + suspend fun connect( + purchasesListener: (PurchasesUpdateResult) -> Unit, + disconnectionListener: () -> Unit, + ): BillingInitResult + + suspend fun getSubscriptions(productIds: List): SubscriptionsResult + + suspend fun getSubscriptionsPurchaseHistory(): SubscriptionsPurchaseHistoryResult + + suspend fun launchBillingFlow( + activity: Activity, + productDetails: ProductDetails, + offerToken: String, + externalId: String, + ): LaunchBillingFlowResult +} + +sealed class BillingInitResult { + data object Success : BillingInitResult() + data object Failure : BillingInitResult() +} + +sealed class SubscriptionsResult { + data class Success(val products: List) : SubscriptionsResult() + + data class Failure( + val billingResponseCode: Int? = null, + val debugMessage: String? = null, + ) : SubscriptionsResult() +} + +sealed class SubscriptionsPurchaseHistoryResult { + data class Success(val history: List) : SubscriptionsPurchaseHistoryResult() + data object Failure : SubscriptionsPurchaseHistoryResult() +} + +sealed class LaunchBillingFlowResult { + data object Success : LaunchBillingFlowResult() + data object Failure : LaunchBillingFlowResult() +} + +sealed class PurchasesUpdateResult { + data class PurchasePresent( + val purchaseToken: String, + val packageName: String, + ) : PurchasesUpdateResult() + + data object PurchaseAbsent : PurchasesUpdateResult() + data object UserCancelled : PurchasesUpdateResult() + data object Failure : PurchasesUpdateResult() +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientWrapper.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientWrapper.kt deleted file mode 100644 index e4e423811094..000000000000 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientWrapper.kt +++ /dev/null @@ -1,318 +0,0 @@ -/* - * Copyright (c) 2023 DuckDuckGo - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.duckduckgo.subscriptions.impl.billing - -import android.app.Activity -import android.content.Context -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import com.android.billingclient.api.BillingClient -import com.android.billingclient.api.BillingClient.BillingResponseCode -import com.android.billingclient.api.BillingClient.ProductType -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.PurchaseHistoryRecord -import com.android.billingclient.api.PurchasesUpdatedListener -import com.android.billingclient.api.QueryProductDetailsParams -import com.android.billingclient.api.QueryProductDetailsParams.Product -import com.android.billingclient.api.QueryPurchaseHistoryParams -import com.android.billingclient.api.QueryPurchasesParams -import com.android.billingclient.api.queryProductDetails -import com.android.billingclient.api.queryPurchaseHistory -import com.duckduckgo.app.di.AppCoroutineScope -import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LIST_OF_PRODUCTS -import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled -import com.duckduckgo.subscriptions.impl.billing.PurchaseState.InProgress -import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Purchased -import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender -import com.squareup.anvil.annotations.ContributesBinding -import com.squareup.anvil.annotations.ContributesMultibinding -import dagger.SingleInstanceIn -import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.logcat - -interface BillingClientWrapper { - val products: Map - val purchases: Flow> - val purchaseHistory: List - val purchaseState: Flow - fun billingFlowParamsBuilder( - productDetails: ProductDetails, - offerToken: String, - externalId: String, - isReset: Boolean, - ): BillingFlowParams.Builder - suspend fun launchBillingFlow(activity: Activity, params: BillingFlowParams) -} - -@SingleInstanceIn(AppScope::class) -@ContributesBinding(AppScope::class, boundType = BillingClientWrapper::class) -@ContributesMultibinding(scope = AppScope::class, boundType = MainProcessLifecycleObserver::class) -class RealBillingClientWrapper @Inject constructor( - private val context: Context, - val dispatcherProvider: DispatcherProvider, - @AppCoroutineScope val coroutineScope: CoroutineScope, - private val pixelSender: SubscriptionPixelSender, -) : BillingClientWrapper, MainProcessLifecycleObserver { - - private var billingFlowInProcess = false - - // PurchaseState - private val _purchaseState = MutableSharedFlow() - override val purchaseState = _purchaseState.asSharedFlow() - - // New Subscription ProductDetails - override val products = mutableMapOf() - - // Current Purchases - private val _purchases = MutableStateFlow>(listOf()) - override val purchases = _purchases.asStateFlow() - - // Purchase History - override val purchaseHistory = mutableListOf() - private val purchasesUpdatedListener = - PurchasesUpdatedListener { billingResult, purchases -> - if (billingResult.responseCode == BillingResponseCode.OK && !purchases.isNullOrEmpty()) { - coroutineScope.launch(dispatcherProvider.io()) { - processPurchases(purchases) - } - } else if (billingResult.responseCode == BillingResponseCode.USER_CANCELED) { - coroutineScope.launch(dispatcherProvider.io()) { - _purchaseState.emit(Canceled) - } - // Handle an error caused by a user cancelling the purchase flow. - } else { - pixelSender.reportPurchaseFailureStore() - coroutineScope.launch(dispatcherProvider.io()) { - _purchaseState.emit(Canceled) - } - } - billingFlowInProcess = false - } - - private lateinit var billingClient: BillingClient - - override fun onCreate(owner: LifecycleOwner) { - billingClient = BillingClient.newBuilder(context) - .enablePendingPurchases() - .setListener(purchasesUpdatedListener) - .build() - - if (!billingClient.isReady) { - connect() - } - } - - override fun onResume(owner: LifecycleOwner) { - // Will call on resume coming back from a purchase flow - if (!billingFlowInProcess) { - if (billingClient.isReady) { - owner.lifecycleScope.launch(dispatcherProvider.io()) { - getSubscriptions() - queryPurchases() - queryPurchaseHistory() - } - } - } - } - - override fun onDestroy(owner: LifecycleOwner) { - if (billingClient.isReady) { - billingClient.endConnection() - } - } - - override suspend fun launchBillingFlow(activity: Activity, params: BillingFlowParams) { - if (!billingClient.isReady) { - logcat { "Service not ready" } - } - val billingFlow = billingClient.launchBillingFlow(activity, params) - if (billingFlow.responseCode == BillingResponseCode.OK) { - _purchaseState.emit(InProgress) - billingFlowInProcess = true - } else { - _purchaseState.emit(Canceled) - } - } - - private suspend fun processPurchases(purchases: List) { - // Post new purchase List to _purchases - _purchases.value = purchases - // Then, handle the purchases - for (purchase in purchases) { - if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - _purchaseState.emit( - Purchased( - purchaseToken = purchase.purchaseToken, - packageName = purchase.packageName, - ), - ) - } - } - } - - private fun connect() { - billingClient.startConnection( - object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult: BillingResult) { - val responseCode = billingResult.responseCode - if (responseCode == BillingResponseCode.OK) { - coroutineScope.launch(dispatcherProvider.io()) { - queryPurchases() - queryPurchaseHistory() - getSubscriptions() - } - } else { - logcat { "Service error" } - } - } - override fun onBillingServiceDisconnected() { - // TODO: Try reconnecting again - logcat { "Service disconnected" } - } - }, - ) - } - - private suspend fun getSubscriptions() { - val productList = mutableListOf() - val params = QueryProductDetailsParams.newBuilder() - - for (product in LIST_OF_PRODUCTS) { - productList.add( - Product.newBuilder() - .setProductId(product) - .setProductType(ProductType.SUBS) - .build(), - ) - } - - params.setProductList(productList).let { productDetailsParams -> - val productDetailsResult = withContext(dispatcherProvider.io()) { - billingClient.queryProductDetails(productDetailsParams.build()) - } - processProducts(productDetailsResult) - } - } - - private fun processProducts(productDetailsResult: ProductDetailsResult) { - val responseCode = productDetailsResult.billingResult.responseCode - val debugMessage = productDetailsResult.billingResult.debugMessage - val productDetailsList = productDetailsResult.productDetailsList.orEmpty() - when (responseCode) { - BillingResponseCode.OK -> { - var newMap = emptyMap() - if (productDetailsList.isEmpty()) { - logcat { "No products found" } - } else { - newMap = productDetailsList.associateBy { - it.productId - } - } - products.clear() - newMap.toMap(products) - } - else -> { - logcat { "onProductDetailsResponse: $responseCode $debugMessage" } - } - } - } - - private fun queryPurchases() { - if (!billingClient.isReady) { - // Handle client not ready - return - } - // Query for existing subscription products that have been purchased. - billingClient.queryPurchasesAsync( - QueryPurchasesParams.newBuilder().setProductType(ProductType.SUBS).build(), - ) { billingResult, purchaseList -> - if (billingResult.responseCode == BillingResponseCode.OK) { - if (purchaseList.isNotEmpty()) { - _purchases.value = purchaseList.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } - } else { - _purchases.value = emptyList() - } - } else { - // Handle error codes - } - } - } - - private suspend fun queryPurchaseHistory() { - if (!billingClient.isReady) { - // Handle client not ready - return - } - val (billingResult, purchaseList) = billingClient.queryPurchaseHistory( - QueryPurchaseHistoryParams.newBuilder().setProductType(ProductType.SUBS).build(), - ) - if (billingResult.responseCode == BillingResponseCode.OK) { - if (purchaseList?.isNotEmpty() == true) { - purchaseHistory.clear() - purchaseHistory.addAll(purchaseList) - } else { - purchaseHistory.clear() - } - } - } - - override fun billingFlowParamsBuilder( - productDetails: ProductDetails, - offerToken: String, - externalId: String, - isReset: Boolean, - ): BillingFlowParams.Builder { - val finalId = if (isReset) "randomId" else externalId - return BillingFlowParams.newBuilder() - .setProductDetailsParamsList( - listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerToken) - .build(), - ), - ) - .setObfuscatedAccountId(finalId) - .setObfuscatedProfileId(finalId) - } -} - -sealed class PurchaseState { - object InProgress : PurchaseState() - data class Purchased( - val purchaseToken: String, - val packageName: String, - ) : PurchaseState() - - object Canceled : PurchaseState() -} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt new file mode 100644 index 000000000000..7c9c0bf07663 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.billing + +import android.app.Activity +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.PurchaseHistoryRecord +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LIST_OF_PRODUCTS +import com.duckduckgo.subscriptions.impl.billing.BillingInitResult.Failure +import com.duckduckgo.subscriptions.impl.billing.BillingInitResult.Success +import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled +import com.duckduckgo.subscriptions.impl.billing.PurchaseState.InProgress +import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Purchased +import com.duckduckgo.subscriptions.impl.billing.PurchasesUpdateResult.PurchaseAbsent +import com.duckduckgo.subscriptions.impl.billing.PurchasesUpdateResult.PurchasePresent +import com.duckduckgo.subscriptions.impl.billing.PurchasesUpdateResult.UserCancelled +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import logcat.logcat + +interface PlayBillingManager { + val products: List + val purchaseHistory: List + val purchaseState: Flow + + suspend fun launchBillingFlow( + activity: Activity, + productDetails: ProductDetails, + offerToken: String, + externalId: String, + ) +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class, boundType = PlayBillingManager::class) +@ContributesMultibinding(scope = AppScope::class, boundType = MainProcessLifecycleObserver::class) +class RealPlayBillingManager @Inject constructor( + @AppCoroutineScope val coroutineScope: CoroutineScope, + private val pixelSender: SubscriptionPixelSender, + private val billingClient: BillingClientAdapter, +) : PlayBillingManager, MainProcessLifecycleObserver { + + private var billingFlowInProgress = false + + // PurchaseState + private val _purchaseState = MutableSharedFlow() + override val purchaseState = _purchaseState.asSharedFlow() + + // New Subscription ProductDetails + override var products = emptyList() + + // Purchase History + override var purchaseHistory = emptyList() + + override fun onCreate(owner: LifecycleOwner) { + coroutineScope.launch { connect() } + } + + override fun onResume(owner: LifecycleOwner) { + // Will call on resume coming back from a purchase flow + if (!billingFlowInProgress) { + if (billingClient.ready) { + owner.lifecycleScope.launch { + loadProducts() + loadPurchaseHistory() + } + } + } + } + + private suspend fun connect() { + val result = billingClient.connect( + purchasesListener = { result -> onPurchasesUpdated(result) }, + disconnectionListener = { onBillingClientDisconnected() }, + ) + + when (result) { + Success -> { + loadProducts() + loadPurchaseHistory() + } + + Failure -> { + logcat { "Service error" } + } + } + } + + override suspend fun launchBillingFlow( + activity: Activity, + productDetails: ProductDetails, + offerToken: String, + externalId: String, + ) { + if (!billingClient.ready) { + logcat { "Service not ready" } + } + + val launchBillingFlowResult = billingClient.launchBillingFlow( + activity = activity, + productDetails = productDetails, + offerToken = offerToken, + externalId = externalId, + ) + + when (launchBillingFlowResult) { + LaunchBillingFlowResult.Success -> { + _purchaseState.emit(InProgress) + billingFlowInProgress = true + } + + LaunchBillingFlowResult.Failure -> { + _purchaseState.emit(Canceled) + } + } + } + + private fun onPurchasesUpdated(result: PurchasesUpdateResult) { + coroutineScope.launch { + when (result) { + is PurchasePresent -> { + _purchaseState.emit( + Purchased( + purchaseToken = result.purchaseToken, + packageName = result.packageName, + ), + ) + } + + PurchaseAbsent -> {} + UserCancelled -> { + _purchaseState.emit(Canceled) + // Handle an error caused by a user cancelling the purchase flow. + } + + PurchasesUpdateResult.Failure -> { + pixelSender.reportPurchaseFailureStore() + _purchaseState.emit(Canceled) + } + } + } + + billingFlowInProgress = false + } + + private fun onBillingClientDisconnected() { + logcat { "Service disconnected" } + } + + private suspend fun loadProducts() { + when (val result = billingClient.getSubscriptions(LIST_OF_PRODUCTS)) { + is SubscriptionsResult.Success -> { + if (result.products.isEmpty()) { + logcat { "No products found" } + } + this.products = result.products + } + + is SubscriptionsResult.Failure -> { + logcat { "onProductDetailsResponse: ${result.billingResponseCode} ${result.debugMessage}" } + } + } + } + + private suspend fun loadPurchaseHistory() { + if (!billingClient.ready) { + // Handle client not ready + return + } + + when (val result = billingClient.getSubscriptionsPurchaseHistory()) { + is SubscriptionsPurchaseHistoryResult.Success -> { + purchaseHistory = result.history + } + SubscriptionsPurchaseHistoryResult.Failure -> { + } + } + } +} + +sealed class PurchaseState { + data object InProgress : PurchaseState() + data class Purchased( + val purchaseToken: String, + val packageName: String, + ) : PurchaseState() + + data object Canceled : PurchaseState() +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt new file mode 100644 index 000000000000..fb247c9a3345 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.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.BillingClient.ProductType +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.Purchase +import com.android.billingclient.api.Purchase.PurchaseState +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryProductDetailsParams.Product +import com.android.billingclient.api.QueryPurchaseHistoryParams +import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchaseHistory +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.billing.BillingInitResult.Failure +import com.duckduckgo.subscriptions.impl.billing.BillingInitResult.Success +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalCoroutinesApi::class) +@ContributesBinding(AppScope::class) +@SingleInstanceIn(AppScope::class) +class RealBillingClientAdapter @Inject constructor( + private val context: Context, + private val coroutineDispatchers: DispatcherProvider, +) : BillingClientAdapter { + + private var billingClient: BillingClient? = null + + override val ready: Boolean + get() = billingClient?.isReady == true + + override suspend fun connect( + purchasesListener: (PurchasesUpdateResult) -> Unit, + disconnectionListener: () -> Unit, + ): BillingInitResult { + reset() + + billingClient = BillingClient.newBuilder(context) + .enablePendingPurchases() + .setListener { billingResult, purchases -> + val purchasesUpdateResult = mapToPurchasesUpdateResult(billingResult, purchases) + purchasesListener.invoke(purchasesUpdateResult) + } + .build() + + return suspendCancellableCoroutine { continuation -> + billingClient?.startConnection( + object : BillingClientStateListener { + override fun onBillingServiceDisconnected() { + disconnectionListener.invoke() + } + + override fun onBillingSetupFinished(p0: BillingResult) { + val result = when (p0.responseCode) { + BillingResponseCode.OK -> Success + else -> Failure + } + + continuation.resume(result, onCancellation = null) + } + }, + ) + } + } + + override suspend fun getSubscriptions(productIds: List): SubscriptionsResult { + val client = billingClient + if (client == null || !client.isReady) return SubscriptionsResult.Failure() + + val queryParams = QueryProductDetailsParams.newBuilder() + .setProductList( + productIds.map { productId -> + Product.newBuilder() + .setProductId(productId) + .setProductType(ProductType.SUBS) + .build() + }, + ) + .build() + + val (billingResult, productDetails) = client.queryProductDetails(queryParams) + + return when (billingResult.responseCode) { + BillingResponseCode.OK -> SubscriptionsResult.Success(productDetails.orEmpty()) + else -> SubscriptionsResult.Failure(billingResult.responseCode, billingResult.debugMessage) + } + } + + override suspend fun getSubscriptionsPurchaseHistory(): SubscriptionsPurchaseHistoryResult { + val client = billingClient + if (client == null || !client.isReady) return SubscriptionsPurchaseHistoryResult.Failure + + val queryParams = QueryPurchaseHistoryParams.newBuilder() + .setProductType(ProductType.SUBS) + .build() + + val (billingResult, purchaseHistory) = client.queryPurchaseHistory(queryParams) + + return when (billingResult.responseCode) { + BillingResponseCode.OK -> SubscriptionsPurchaseHistoryResult.Success(history = purchaseHistory.orEmpty()) + else -> SubscriptionsPurchaseHistoryResult.Failure + } + } + + override suspend fun launchBillingFlow( + activity: Activity, + productDetails: ProductDetails, + offerToken: String, + externalId: String, + ): LaunchBillingFlowResult { + val client = billingClient + if (client == null || !client.isReady) return LaunchBillingFlowResult.Failure + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) + .build(), + ), + ) + .setObfuscatedAccountId(externalId) + .setObfuscatedProfileId(externalId) + .build() + + val result = withContext(coroutineDispatchers.main()) { + client.launchBillingFlow(activity, billingFlowParams) + } + + return when (result.responseCode) { + BillingResponseCode.OK -> LaunchBillingFlowResult.Success + else -> LaunchBillingFlowResult.Failure + } + } + + private fun reset() { + billingClient?.endConnection() + billingClient = null + } + + private fun mapToPurchasesUpdateResult( + billingResult: BillingResult, + purchases: List?, + ): PurchasesUpdateResult = + when (billingResult.responseCode) { + BillingResponseCode.OK -> { + val purchase = purchases?.lastOrNull { it.purchaseState == PurchaseState.PURCHASED } + if (purchase != null) { + PurchasesUpdateResult.PurchasePresent( + purchaseToken = purchase.purchaseToken, + packageName = purchase.packageName, + ) + } else { + PurchasesUpdateResult.PurchaseAbsent + } + } + + BillingResponseCode.USER_CANCELED -> PurchasesUpdateResult.UserCancelled + else -> PurchasesUpdateResult.Failure + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/SubscriptionsRepository.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/SubscriptionsRepository.kt index 8b7aaccfcae0..a42e7bc88fbc 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/SubscriptionsRepository.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/repository/SubscriptionsRepository.kt @@ -20,7 +20,7 @@ import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION -import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper +import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import javax.inject.Inject @@ -33,15 +33,11 @@ interface SubscriptionsRepository { @ContributesBinding(AppScope::class) @SingleInstanceIn(AppScope::class) class RealSubscriptionsRepository @Inject constructor( - private val billingClientWrapper: BillingClientWrapper, + private val playBillingManager: PlayBillingManager, ) : SubscriptionsRepository { override suspend fun subscriptionDetails(): ProductDetails? { - return if (billingClientWrapper.products.containsKey(BASIC_SUBSCRIPTION)) { - billingClientWrapper.products[BASIC_SUBSCRIPTION] - } else { - null - } + return playBillingManager.products.find { it.productId == BASIC_SUBSCRIPTION } } override suspend fun offerDetail(): Map { diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index b88d0180d94a..ed84d6dc851a 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -3,7 +3,6 @@ package com.duckduckgo.subscriptions.impl import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test -import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.PurchaseHistoryRecord import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.common.test.CoroutineTestRule @@ -16,7 +15,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Unknown import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsData.Failure import com.duckduckgo.subscriptions.impl.SubscriptionsData.Success -import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper +import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AuthRepository @@ -66,8 +65,7 @@ class RealSubscriptionsManagerTest { private val authDataStore: SubscriptionsDataStore = FakeSubscriptionsDataStore() private val authRepository = RealAuthRepository(authDataStore) private val emailManager: EmailManager = mock() - private val billingClient: BillingClientWrapper = mock() - private val billingBuilder: BillingFlowParams.Builder = mock() + private val playBillingManager: PlayBillingManager = mock() private val context: Context = mock() private val pixelSender: SubscriptionPixelSender = mock() private lateinit var subscriptionsManager: SubscriptionsManager @@ -76,13 +74,11 @@ class RealSubscriptionsManagerTest { fun before() { whenever(emailManager.getToken()).thenReturn(null) whenever(context.packageName).thenReturn("packageName") - whenever(billingClient.billingFlowParamsBuilder(any(), any(), any(), any())).thenReturn(billingBuilder) - whenever(billingBuilder.build()).thenReturn(mock()) subscriptionsManager = RealSubscriptionsManager( authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -252,8 +248,7 @@ class RealSubscriptionsManagerTest { subscriptionsManager.purchase(mock(), mock(), "", false) - verify(billingClient).billingFlowParamsBuilder(any(), any(), eq("1234"), any()) - verify(billingClient).launchBillingFlow(any(), any()) + verify(playBillingManager).launchBillingFlow(any(), any(), any(), externalId = eq("1234")) } @Test @@ -266,8 +261,7 @@ class RealSubscriptionsManagerTest { subscriptionsManager.purchase(mock(), mock(), "", false) - verify(billingClient).billingFlowParamsBuilder(any(), any(), eq("1234"), any()) - verify(billingClient).launchBillingFlow(any(), any()) + verify(playBillingManager).launchBillingFlow(any(), any(), any(), externalId = eq("1234")) } @Test @@ -281,8 +275,7 @@ class RealSubscriptionsManagerTest { subscriptionsManager.currentPurchaseState.test { subscriptionsManager.purchase(mock(), mock(), "", false) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) - verify(billingClient, never()).billingFlowParamsBuilder(any(), any(), eq("1234"), any()) - verify(billingClient, never()).launchBillingFlow(any(), any()) + verify(playBillingManager, never()).launchBillingFlow(any(), any(), any(), any()) assertTrue(awaitItem() is CurrentPurchase.Recovered) cancelAndConsumeRemainingEvents() } @@ -320,8 +313,7 @@ class RealSubscriptionsManagerTest { subscriptionsManager.currentPurchaseState.test { subscriptionsManager.purchase(mock(), mock(), "", false) assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) - verify(billingClient).billingFlowParamsBuilder(any(), any(), eq("1234"), any()) - verify(billingClient).launchBillingFlow(any(), any()) + verify(playBillingManager).launchBillingFlow(any(), any(), any(), externalId = eq("1234")) assertTrue(awaitItem() is CurrentPurchase.PreFlowFinished) cancelAndConsumeRemainingEvents() } @@ -417,7 +409,7 @@ class RealSubscriptionsManagerTest { authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -440,7 +432,7 @@ class RealSubscriptionsManagerTest { authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -463,7 +455,7 @@ class RealSubscriptionsManagerTest { authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -485,7 +477,7 @@ class RealSubscriptionsManagerTest { authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -507,7 +499,7 @@ class RealSubscriptionsManagerTest { authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -528,13 +520,13 @@ class RealSubscriptionsManagerTest { givenConfirmPurchaseSucceeds() val flowTest: MutableSharedFlow = MutableSharedFlow() - whenever(billingClient.purchaseState).thenReturn(flowTest) + whenever(playBillingManager.purchaseState).thenReturn(flowTest) val manager = RealSubscriptionsManager( authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -557,13 +549,13 @@ class RealSubscriptionsManagerTest { givenConfirmPurchaseFails() val flowTest: MutableSharedFlow = MutableSharedFlow() - whenever(billingClient.purchaseState).thenReturn(flowTest) + whenever(playBillingManager.purchaseState).thenReturn(flowTest) val manager = RealSubscriptionsManager( authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -763,7 +755,7 @@ class RealSubscriptionsManagerTest { authService, subscriptionsService, mockRepo, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -796,7 +788,7 @@ class RealSubscriptionsManagerTest { authService, subscriptionsService, authRepository, - billingClient, + playBillingManager, emailManager, context, TestScope(), @@ -818,7 +810,7 @@ class RealSubscriptionsManagerTest { givenValidateTokenSucceedsWithEntitlements() givenConfirmPurchaseSucceeds() - whenever(billingClient.purchaseState).thenReturn(flowOf(PurchaseState.Purchased("any", "any"))) + whenever(playBillingManager.purchaseState).thenReturn(flowOf(PurchaseState.Purchased("any", "any"))) subscriptionsManager.currentPurchaseState.test { assertTrue(awaitItem() is CurrentPurchase.InProgress) @@ -859,7 +851,7 @@ class RealSubscriptionsManagerTest { givenValidateTokenFails("failure") givenConfirmPurchaseFails() - whenever(billingClient.purchaseState).thenReturn(flowOf(PurchaseState.Purchased("validateToken", "packageName"))) + whenever(playBillingManager.purchaseState).thenReturn(flowOf(PurchaseState.Purchased("validateToken", "packageName"))) subscriptionsManager.currentPurchaseState.test { assertTrue(awaitItem() is CurrentPurchase.InProgress) @@ -1030,8 +1022,8 @@ class RealSubscriptionsManagerTest { """, "signature", ) - whenever(billingClient.products).thenReturn(mapOf()) - whenever(billingClient.purchaseHistory).thenReturn(listOf(purchaseRecord)) + whenever(playBillingManager.products).thenReturn(emptyList()) + whenever(playBillingManager.purchaseHistory).thenReturn(listOf(purchaseRecord)) } private suspend fun givenPurchaseStoredIsValid() { diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt new file mode 100644 index 000000000000..c59dc208d000 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt @@ -0,0 +1,173 @@ +package com.duckduckgo.subscriptions.impl.billing + +import android.app.Activity +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.testing.TestLifecycleOwner +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.PurchaseHistoryRecord +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LIST_OF_PRODUCTS +import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.Connect +import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.GetSubscriptions +import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.GetSubscriptionsPurchaseHistory +import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchBillingFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class RealPlayBillingManagerTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val billingClientAdapter = FakeBillingClientAdapter() + + private lateinit var processLifecycleOwner: TestLifecycleOwner + + private val subject = RealPlayBillingManager( + coroutineScope = coroutineRule.testScope, + pixelSender = mock(), + billingClient = billingClientAdapter, + ) + + @Before + fun setUp() { + processLifecycleOwner = TestLifecycleOwner(initialState = INITIALIZED) + processLifecycleOwner.lifecycle.addObserver(subject) + } + + @Test + fun `when process created then connects to billing service and loads data`() = runTest { + processLifecycleOwner.currentState = CREATED + + billingClientAdapter.verifyConnectInvoked() + billingClientAdapter.verifyGetSubscriptionsInvoked(productIds = LIST_OF_PRODUCTS) + billingClientAdapter.verifyGetSubscriptionPurchaseHistoryInvoked() + } + + @Test + fun `when connection failed then does not attempt loading anything`() = runTest { + billingClientAdapter.canConnect = false + + processLifecycleOwner.currentState = CREATED + + billingClientAdapter.verifyConnectInvoked() + billingClientAdapter.verifyGetSubscriptionsInvoked(times = 0) + billingClientAdapter.verifyGetSubscriptionPurchaseHistoryInvoked(times = 0) + } + + @Test + fun `when connected then returns products`() = runTest { + billingClientAdapter.subscriptions = listOf( + mock { + whenever(it.productId).thenReturn("test-sub") + }, + ) + + processLifecycleOwner.currentState = RESUMED + + assertEquals(1, subject.products.size) + assertEquals("test-sub", subject.products.single().productId) + } +} + +class FakeBillingClientAdapter : BillingClientAdapter { + + var canConnect = true + var connected = false + val methodInvocations = mutableListOf() + + var subscriptions: List = listOf( + mock { + whenever(it.productId).thenReturn(BASIC_SUBSCRIPTION) + }, + ) + + var subscriptionsPurchaseHistory: List = emptyList() + + var purchasesListener: ((PurchasesUpdateResult) -> Unit)? = null + var disconnectionListener: (() -> Unit)? = null + + override val ready: Boolean + get() = connected + + override suspend fun connect( + purchasesListener: (PurchasesUpdateResult) -> Unit, + disconnectionListener: () -> Unit, + ): BillingInitResult { + methodInvocations.add(Connect) + return if (canConnect) { + connected = true + this.purchasesListener = purchasesListener + this.disconnectionListener = disconnectionListener + BillingInitResult.Success + } else { + connected = false + BillingInitResult.Failure + } + } + + override suspend fun getSubscriptions(productIds: List): SubscriptionsResult { + methodInvocations.add(GetSubscriptions(productIds)) + return if (ready) { + SubscriptionsResult.Success(subscriptions) + } else { + SubscriptionsResult.Failure() + } + } + + override suspend fun getSubscriptionsPurchaseHistory(): SubscriptionsPurchaseHistoryResult { + methodInvocations.add(GetSubscriptionsPurchaseHistory) + return if (ready) { + SubscriptionsPurchaseHistoryResult.Success(subscriptionsPurchaseHistory) + } else { + SubscriptionsPurchaseHistoryResult.Failure + } + } + + override suspend fun launchBillingFlow( + activity: Activity, + productDetails: ProductDetails, + offerToken: String, + externalId: String, + ): LaunchBillingFlowResult { + methodInvocations.add(LaunchBillingFlow(productDetails, offerToken, externalId)) + return LaunchBillingFlowResult.Failure + } + + fun verifyConnectInvoked(times: Int = 1) { + val invocations = methodInvocations.filterIsInstance() + assertEquals(times, invocations.count()) + } + + fun verifyGetSubscriptionsInvoked(productIds: List? = null, times: Int = 1) { + val invocations = methodInvocations + .filterIsInstance() + .filter { productIds == null || it.productIds == productIds } + assertEquals(times, invocations.count()) + } + + fun verifyGetSubscriptionPurchaseHistoryInvoked(times: Int = 1) { + val invocations = methodInvocations.filterIsInstance() + assertEquals(times, invocations.count()) + } + + sealed class FakeMethodInvocation { + data object Connect : FakeMethodInvocation() + data class GetSubscriptions(val productIds: List) : FakeMethodInvocation() + data object GetSubscriptionsPurchaseHistory : FakeMethodInvocation() + + data class LaunchBillingFlow( + val productDetails: ProductDetails, + val offerToken: String, + val externalId: String, + ) : FakeMethodInvocation() + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealSubscriptionsRepositoryTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealSubscriptionsRepositoryTest.kt index 731bc2ffb72e..5163d634cb44 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealSubscriptionsRepositoryTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/repository/RealSubscriptionsRepositoryTest.kt @@ -5,7 +5,7 @@ import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION -import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper +import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import kotlinx.coroutines.test.runTest import org.junit.Assert.* import org.junit.Before @@ -22,7 +22,7 @@ class RealSubscriptionsRepositoryTest { val coroutineRule = CoroutineTestRule() private lateinit var repository: RealSubscriptionsRepository - private val billingClient: BillingClientWrapper = mock() + private val billingClient: PlayBillingManager = mock() @Before fun before() { @@ -71,7 +71,7 @@ class RealSubscriptionsRepositoryTest { return productDetails } private fun givenProductExist(productId: String = BASIC_SUBSCRIPTION) { - val testMap: Map = mapOf(productId to getProductDetails(productId)) - whenever(billingClient.products).thenReturn(testMap) + val mockProducts = listOf(getProductDetails(productId)) + whenever(billingClient.products).thenReturn(mockProducts) } }