diff --git a/app/build.gradle b/app/build.gradle index 7535e1052..df37db992 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,10 +15,10 @@ buildscript { } android { - compileSdkVersion 33 + compileSdk 33 defaultConfig { applicationId "com.qonversion.sample" - minSdkVersion 16 + minSdkVersion 19 targetSdkVersion 33 versionCode 1 versionName "1.0.0" diff --git a/app/src/main/java/com/qonversion/android/app/HomeFragment.kt b/app/src/main/java/com/qonversion/android/app/HomeFragment.kt index 31fe695cf..373e45a4e 100644 --- a/app/src/main/java/com/qonversion/android/app/HomeFragment.kt +++ b/app/src/main/java/com/qonversion/android/app/HomeFragment.kt @@ -22,6 +22,7 @@ import com.qonversion.android.sdk.automations.dto.QActionResult import com.qonversion.android.sdk.automations.dto.QActionResultType import com.qonversion.android.sdk.automations.dto.QScreenPresentationConfig import com.qonversion.android.sdk.automations.dto.QScreenPresentationStyle +import com.qonversion.android.sdk.dto.QPurchaseModel import com.qonversion.android.sdk.dto.entitlements.QEntitlement import com.qonversion.android.sdk.dto.QonversionError import com.qonversion.android.sdk.dto.products.QProduct @@ -137,8 +138,11 @@ class HomeFragment : Fragment() { val subscription = products[productIdSubs] if (subscription != null) { binding.buttonSubscribe.text = String.format( - "%s %s / %s", getStr(R.string.subscribe_for), - subscription.prettyPrice, subscription.duration?.name + "%s %s / %d %s", + getStr(R.string.subscribe_for), + subscription.prettyPrice, + subscription.subscriptionPeriod?.unitCount, + subscription.subscriptionPeriod?.unit?.name, ) } @@ -172,7 +176,7 @@ class HomeFragment : Fragment() { private fun purchase(productId: String) { Qonversion.shared.purchase( requireActivity(), - productId, + QPurchaseModel(productId), callback = object : QonversionEntitlementsCallback { override fun onSuccess(entitlements: Map) { when (productId) { diff --git a/app/src/main/java/com/qonversion/android/app/ManualTrackingActivity.java b/app/src/main/java/com/qonversion/android/app/ManualTrackingActivity.java index 54bf0a5e3..0b93113ec 100644 --- a/app/src/main/java/com/qonversion/android/app/ManualTrackingActivity.java +++ b/app/src/main/java/com/qonversion/android/app/ManualTrackingActivity.java @@ -11,8 +11,8 @@ import com.android.billingclient.api.BillingClientStateListener; import com.android.billingclient.api.BillingFlowParams; import com.android.billingclient.api.BillingResult; -import com.android.billingclient.api.*; -import com.android.billingclient.api.SkuDetailsParams; +import com.android.billingclient.api.ProductDetails; +import com.android.billingclient.api.QueryProductDetailsParams; import com.qonversion.android.sdk.Qonversion; import java.util.Collections; @@ -22,12 +22,11 @@ public class ManualTrackingActivity extends AppCompatActivity { - private static final String SKU_ID = "your_sku_id"; + private static final String PRODUCT_ID = "your_product_id"; private BillingClient client; - @SuppressWarnings("deprecation") - private final Map skuDetails = new HashMap<>(); + private final Map productDetails = new HashMap<>(); @Override public void onCreate(@Nullable Bundle savedInstanceState, @Nullable PersistableBundle persistentState) { @@ -36,18 +35,16 @@ public void onCreate(@Nullable Bundle savedInstanceState, @Nullable PersistableB client = BillingClient .newBuilder(this) .enablePendingPurchases() - .setListener((billingResult, list) -> { + .setListener((billingResult, purchases) -> { if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - if (list != null && !list.isEmpty()) { + if (purchases != null && !purchases.isEmpty()) { Qonversion.getSharedInstance().syncPurchases(); } } }) .build(); - launchBilling(); - } private void launchBilling() { @@ -56,18 +53,21 @@ private void launchBilling() { public void onBillingSetupFinished(@NonNull BillingResult billingResult) { if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - @SuppressWarnings("deprecation") - final SkuDetailsParams params = SkuDetailsParams + final QueryProductDetailsParams.Product product = QueryProductDetailsParams.Product + .newBuilder() + .setProductId(PRODUCT_ID) + .setProductType(BillingClient.ProductType.INAPP) + .build(); + + final QueryProductDetailsParams params = QueryProductDetailsParams .newBuilder() - .setSkusList(Collections.singletonList(SKU_ID)) - .setType(BillingClient.SkuType.INAPP) + .setProductList(Collections.singletonList(product)) .build(); - //noinspection deprecation - client.querySkuDetailsAsync(params, (queryBillingResult, list) -> { + client.queryProductDetailsAsync(params, (queryBillingResult, details) -> { if (queryBillingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - if (list != null && !list.isEmpty()) { - skuDetails.put(SKU_ID, list.get(0)); + if (!details.isEmpty()) { + productDetails.put(PRODUCT_ID, details.get(0)); } launchBillingFlow(); } @@ -84,11 +84,16 @@ public void onBillingServiceDisconnected() { } private void launchBillingFlow() { - @SuppressWarnings("deprecation") + final BillingFlowParams.ProductDetailsParams productDetailsParams = BillingFlowParams.ProductDetailsParams + .newBuilder() + .setProductDetails(Objects.requireNonNull(productDetails.get(PRODUCT_ID))) + .build(); + final BillingFlowParams params = BillingFlowParams .newBuilder() - .setSkuDetails(Objects.requireNonNull(skuDetails.get(SKU_ID))) + .setProductDetailsParamsList(Collections.singletonList(productDetailsParams)) .build(); + client.launchBillingFlow(this, params); } } diff --git a/app/src/main/java/com/qonversion/android/app/ManualTrackingActivityKt.kt b/app/src/main/java/com/qonversion/android/app/ManualTrackingActivityKt.kt index 5a9370fbb..00361b399 100644 --- a/app/src/main/java/com/qonversion/android/app/ManualTrackingActivityKt.kt +++ b/app/src/main/java/com/qonversion/android/app/ManualTrackingActivityKt.kt @@ -3,64 +3,66 @@ package com.qonversion.android.app import android.os.Bundle import android.os.PersistableBundle import androidx.appcompat.app.AppCompatActivity -import com.android.billingclient.api.* +import com.android.billingclient.api.BillingClient +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.QueryProductDetailsParams import com.qonversion.android.sdk.Qonversion -import java.util.* class ManualTrackingActivityKt : AppCompatActivity() { - private var client: BillingClient? = null - @Suppress("DEPRECATION") - private val skuDetails: MutableMap = - HashMap() + private lateinit var client: BillingClient - override fun onCreate( - savedInstanceState: Bundle?, - persistentState: PersistableBundle? - ) { + private val productDetails: MutableMap = HashMap() + + override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { super.onCreate(savedInstanceState, persistentState) client = BillingClient .newBuilder(this) .enablePendingPurchases() - .setListener { billingResult, list -> + .setListener { billingResult, purchases -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - if (!list.isNullOrEmpty()) { + if (!purchases.isNullOrEmpty()) { Qonversion.shared.syncPurchases() } } } .build() + launchBilling() } private fun launchBilling() { - client!!.startConnection(object : BillingClientStateListener { + client.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - querySkuDetailsAsync() + queryProductDetailsAsync() } } - override fun onBillingServiceDisconnected() { // ignore in example + override fun onBillingServiceDisconnected() { + // ignore in example } }) } - private fun querySkuDetailsAsync() { - @Suppress("DEPRECATION") - val params = - SkuDetailsParams - .newBuilder() - .setSkusList(listOf(SKU_ID)) - .setType(BillingClient.SkuType.INAPP) - .build() + private fun queryProductDetailsAsync() { + val product = QueryProductDetailsParams.Product + .newBuilder() + .setProductId(PRODUCT_ID) + .setProductType(BillingClient.ProductType.INAPP) + .build() + + val params = QueryProductDetailsParams + .newBuilder() + .setProductList(listOf(product)) + .build() - @Suppress("DEPRECATION") - client!!.querySkuDetailsAsync( - params - ) { billingResult, list -> + client.queryProductDetailsAsync(params) { billingResult, details -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - if (list!!.isNotEmpty()) { - skuDetails[SKU_ID] = list[0] + if (details.isNotEmpty()) { + productDetails[PRODUCT_ID] = details.first() } launchBillingFlow() } @@ -68,16 +70,20 @@ class ManualTrackingActivityKt : AppCompatActivity() { } private fun launchBillingFlow() { - @Suppress("DEPRECATION") - val params = - BillingFlowParams - .newBuilder() - .setSkuDetails(skuDetails[SKU_ID]!!) - .build() - client!!.launchBillingFlow(this, params) + val productDetailsParams = BillingFlowParams.ProductDetailsParams + .newBuilder() + .setProductDetails(productDetails[PRODUCT_ID]!!) + .build() + + val params = BillingFlowParams + .newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParams)) + .build() + + client.launchBillingFlow(this, params) } companion object { - private const val SKU_ID = "your_sku_id" + private const val PRODUCT_ID = "your_product_id" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/qonversion/android/app/OfferingsFragment.kt b/app/src/main/java/com/qonversion/android/app/OfferingsFragment.kt index 588df5eda..fe79eb417 100644 --- a/app/src/main/java/com/qonversion/android/app/OfferingsFragment.kt +++ b/app/src/main/java/com/qonversion/android/app/OfferingsFragment.kt @@ -58,7 +58,7 @@ class OfferingsFragment : Fragment() { } private fun purchase(product: QProduct) { - Qonversion.shared.purchase(requireActivity(), product, callback = object : + Qonversion.shared.purchase(requireActivity(), product.toPurchaseModel(), callback = object : QonversionEntitlementsCallback { override fun onSuccess(entitlements: Map) { Toast.makeText(context, "Purchase succeeded", Toast.LENGTH_LONG).show() diff --git a/build.gradle b/build.gradle index a4adeedff..9111bbeaf 100644 --- a/build.gradle +++ b/build.gradle @@ -6,11 +6,11 @@ import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask buildscript { ext { release = [ - versionName: "6.3.2", + versionName: "7.0.0", versionCode: 1 ] } - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.8.22' repositories { mavenCentral() google() diff --git a/config/detekt/baseline.xml b/config/detekt/baseline.xml index 6b92830c7..466ca274e 100644 --- a/config/detekt/baseline.xml +++ b/config/detekt/baseline.xml @@ -3,7 +3,6 @@ CommentSpacing:com.qonversion.android.sdk.internal.QIdentityManagerTest.kt:171 - ComplexCondition:DefaultRepository.kt$DefaultRepository$(purchase.freeTrialPeriod.isNotEmpty() || purchase.introductoryAvailable) && purchase.introductoryPeriodUnit != null && purchase.introductoryPeriodUnitsCount != null ComplexMethod:ScreenPresenter.kt$ScreenPresenter$override fun shouldOverrideUrlLoading(url: String?): Boolean ComplexMethod:errors.kt$internal fun BillingError.toQonversionError(): QonversionError ConstructorParameterNaming:Environment.kt$Environment$@Json(name = "app_version") val app_version: String @@ -14,53 +13,32 @@ EmptyFunctionBlock:QIdentityManagerTest.kt$QIdentityManagerTest.Identify.<no name provided>${} EmptyFunctionBlock:QProductCenterManager.kt$QProductCenterManager.<no name provided>${} EmptyFunctionBlock:QonversionInternal.kt$QonversionInternal.<no name provided>${} - Filename:com.qonversion.android.sdk.internal.converter.util.kt:1 - Filename:com.qonversion.android.sdk.internal.requests.queue.util.kt:1 Filename:com.qonversion.android.sdk.internal.storage.util.kt:1 - Filename:com.qonversion.android.sdk.internal.validator.util.kt:1 FinalNewline:com.qonversion.android.sdk.automations.internal.AutomationsEventMapperTest.kt:1 FinalNewline:com.qonversion.android.sdk.automations.internal.QAutomationsManagerTest.kt:1 FinalNewline:com.qonversion.android.sdk.automations.mvp.ScreenPresenterTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.ConsumerTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.IncrementalDelayCalculatorTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.InternalConfigTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.QHandledPurchasesCacheTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.QUserPropertiesManagerTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.api.ApiHelperTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.billing.mockUtils.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.converter.SkuDetailsTokenExtractorTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.converter.util.kt:1 FinalNewline:com.qonversion.android.sdk.internal.requests.AppRequestTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.requests.AttributionRequestTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.requests.EnvironmentRequestTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.requests.InitRequestTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.requests.OsRequestTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.requests.ProviderDataRequestTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.requests.queue.RequestQueueTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.requests.queue.util.kt:1 FinalNewline:com.qonversion.android.sdk.internal.services.QUserInfoServiceTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.storage.TokenExtractorTest.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.storage.TokenStorageTest.kt:1 FinalNewline:com.qonversion.android.sdk.internal.storage.util.kt:1 - FinalNewline:com.qonversion.android.sdk.internal.validator.util.kt:1 FinalNewline:com.qonversion.android.sdk.utils.kt:1 - ForbiddenComment:AttributionRequestTest.kt$AttributionRequestTest$// TODO: Update test for new AttributionRequest format - ForbiddenComment:EnvironmentRequestTest.kt$EnvironmentRequestTest$// TODO: Update test for new Environment format - ForbiddenComment:InitRequestTest.kt$InitRequestTest$// TODO: Update test for new InitRequest format - ForbiddenComment:RequestQueueTest.kt$RequestQueueTest$// TODO: Update test for new AttributionRequest format - ForbiddenComment:RequestValidatorTest.kt$RequestValidatorTest$// TODO: Update test for new AttributionRequest format - ForbiddenComment:util.kt$Util.Companion$// TODO: Update test for new AttributionRequest format - ImplicitDefaultLocale:GooglePurchaseConverter.kt$GooglePurchaseConverter$String.format("%.2f", divideResult) LargeClass:QProductCenterManager.kt$QProductCenterManager : PurchasesListenerUserStateProvider - LongMethod:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.Purchase$@Test fun `purchase with oldSkuDetails billing flow params is correct`() + LongParameterList:IBillingClientWrapper.kt$IBillingClientWrapper$( activity: Activity, product: QProduct, offerId: String?, applyOffer: Boolean, updatePurchaseInfo: UpdatePurchaseInfo?, onFailed: (error: BillingError) -> Unit ) LongParameterList:ManagersModule.kt$ManagersModule$( appContext: Application, repository: QRepository, propertiesStorage: UserPropertiesStorage, incrementalDelayCalculator: IncrementalDelayCalculator, appStateProvider: AppStateProvider, logger: Logger ) LongParameterList:ManagersModule.kt$ManagersModule$( repository: QRepository, preferences: SharedPreferences, eventMapper: AutomationsEventMapper, appContext: Application, activityProvider: ActivityProvider, appStateProvider: AppStateProvider ) - LongParameterList:RepositoryModule.kt$RepositoryModule$( retrofit: Retrofit, environmentProvider: EnvironmentProvider, config: InternalConfig, logger: Logger, purchasesCache: PurchasesCache, apiErrorMapper: ApiErrorMapper, sharedPreferences: SharedPreferences, delayCalculator: IncrementalDelayCalculator ) - LongParameterList:RepositoryModule.kt$RepositoryModule$( retrofit: Retrofit, environmentProvider: EnvironmentProvider, config: InternalConfig, logger: Logger, purchasesCache: PurchasesCache, apiErrorMapper: ApiErrorMapper, sharedPreferences: SharedPreferences, delayCalculator: IncrementalDelayCalculator, rateLimiter: RateLimiter ) + LongParameterList:PurchaseModelInternalEnriched.kt$PurchaseModelInternalEnriched$( productId: String, val product: QProduct, offerId: String?, applyOffer: Boolean, oldProductId: String?, val oldProduct: QProduct?, updatePolicy: QPurchaseUpdatePolicy?, ) + LongParameterList:QonversionBillingService.kt$QonversionBillingService$( activity: Activity, product: QProduct, offerId: String?, applyOffer: Boolean, oldProduct: QProduct, updatePolicy: QPurchaseUpdatePolicy? ) + LongParameterList:QonversionBillingService.kt$QonversionBillingService$( private val mainHandler: Handler, private val purchasesListener: PurchasesListener, private val logger: Logger, private val isAnalyticsMode: Boolean, private val billingClientHolder: BillingClientHolder, private val billingClientWrapper: BillingClientWrapper, private val legacyBillingClientWrapper: LegacyBillingClientWrapper ) + LongParameterList:RepositoryModule.kt$RepositoryModule$( retrofit: Retrofit, environmentProvider: EnvironmentProvider, config: InternalConfig, logger: Logger, apiErrorMapper: ApiErrorMapper, sharedPreferences: SharedPreferences, delayCalculator: IncrementalDelayCalculator ) + LongParameterList:RepositoryModule.kt$RepositoryModule$( retrofit: Retrofit, environmentProvider: EnvironmentProvider, config: InternalConfig, logger: Logger, apiErrorMapper: ApiErrorMapper, sharedPreferences: SharedPreferences, delayCalculator: IncrementalDelayCalculator, rateLimiter: RateLimiter ) MagicNumber:ApiErrorMapper.kt$ApiErrorMapper$10002 MagicNumber:ApiErrorMapper.kt$ApiErrorMapper$10003 MagicNumber:ApiErrorMapper.kt$ApiErrorMapper$10004 @@ -82,10 +60,6 @@ MagicNumber:ApiErrorMapper.kt$ApiErrorMapper$20300 MagicNumber:ApiErrorMapper.kt$ApiErrorMapper$20303 MagicNumber:ApiErrorMapper.kt$ApiErrorMapper$20399 - MagicNumber:GoogleBillingPeriodConverter.kt$GoogleBillingPeriodConverter$30 - MagicNumber:GoogleBillingPeriodConverter.kt$GoogleBillingPeriodConverter$365 - MagicNumber:GoogleBillingPeriodConverter.kt$GoogleBillingPeriodConverter$7 - MagicNumber:GooglePurchaseConverter.kt$GooglePurchaseConverter$3 MagicNumber:QEntitlementsCacheLifetime.kt$QEntitlementsCacheLifetime.Month$30 MagicNumber:QEntitlementsCacheLifetime.kt$QEntitlementsCacheLifetime.SixMonths$180 MagicNumber:QEntitlementsCacheLifetime.kt$QEntitlementsCacheLifetime.ThreeMonths$90 @@ -93,30 +67,16 @@ MagicNumber:QEntitlementsCacheLifetime.kt$QEntitlementsCacheLifetime.TwoWeeks$14 MagicNumber:QEntitlementsCacheLifetime.kt$QEntitlementsCacheLifetime.Week$7 MagicNumber:QEntitlementsCacheLifetime.kt$QEntitlementsCacheLifetime.Year$365 - MagicNumber:QProductDuration.kt$QProductDuration.Annual$4 - MagicNumber:QProductDuration.kt$QProductDuration.Companion$3 - MagicNumber:QProductDuration.kt$QProductDuration.Companion$4 - MagicNumber:QProductDuration.kt$QProductDuration.SixMonthly$3 + MagicNumber:QProductPricingPhase.kt$QProductPricingPhase.RecurrenceMode.NonRecurring$3 MagicNumber:QProductRenewState.kt$QProductRenewState.BillingIssue$3 MagicNumber:QProductRenewState.kt$QProductRenewState.Companion$3 - MagicNumber:QTrialDuration.kt$QTrialDuration.Companion$3 - MagicNumber:QTrialDuration.kt$QTrialDuration.Companion$4 - MagicNumber:QTrialDuration.kt$QTrialDuration.Companion$5 - MagicNumber:QTrialDuration.kt$QTrialDuration.Companion$6 - MagicNumber:QTrialDuration.kt$QTrialDuration.Companion$7 - MagicNumber:QTrialDuration.kt$QTrialDuration.Companion$8 - MagicNumber:QTrialDuration.kt$QTrialDuration.Companion$9 - MagicNumber:QTrialDuration.kt$QTrialDuration.Month$4 - MagicNumber:QTrialDuration.kt$QTrialDuration.Other$9 - MagicNumber:QTrialDuration.kt$QTrialDuration.SixMonths$7 - MagicNumber:QTrialDuration.kt$QTrialDuration.ThreeMonths$6 - MagicNumber:QTrialDuration.kt$QTrialDuration.TwoMonths$5 - MagicNumber:QTrialDuration.kt$QTrialDuration.TwoWeeks$3 - MagicNumber:QTrialDuration.kt$QTrialDuration.Year$8 MagicNumber:extensions.kt$1000 MagicNumber:utils.kt$1000 MagicNumber:utils.kt$24L + MagicNumber:utils.kt$30 + MagicNumber:utils.kt$365 MagicNumber:utils.kt$60 + MagicNumber:utils.kt$7 MatchingDeclarationName:util.kt$Util MaxLineLength:ApiErrorMapper.kt$ApiErrorMapper$20201 -> "For more details please check our guide [Troubleshooting](https://documentation.qonversion.io/docs/troubleshooting)" MaxLineLength:ApiErrorMapper.kt$ApiErrorMapper$20203 -> "Possible reasons for this error are fraud purchases and incorrect configuration of the project key in the Qonversion Dashboard" @@ -131,12 +91,12 @@ MaxLineLength:AutomationsEventMapperTest.kt$AutomationsEventMapperTest.GetEventFromRemoteMessage$"{\"name\": \"subscription_started\", \"happened\": $timeInSec}" to AutomationsEventType.SubscriptionStarted MaxLineLength:AutomationsEventMapperTest.kt$AutomationsEventMapperTest.GetEventFromRemoteMessage$"{\"name\": \"subscription_upgraded\", \"happened\": $timeInSec}" to AutomationsEventType.SubscriptionUpgraded MaxLineLength:AutomationsEventMapperTest.kt$AutomationsEventMapperTest.GetEventFromRemoteMessage$"{\"name\": \"trial_billing_retry_entered\", \"happened\": $timeInSec}" to AutomationsEventType.TrialBillingRetry + MaxLineLength:BillingClientWrapperBase.kt$BillingClientWrapperBase$logger.release("launchBillingFlow() -> Failed to launch billing flow. ${billingResult.getDescription()}") MaxLineLength:OutagerIntegrationTest.kt$OutagerIntegrationTest$"lgeigljfpmeoddkcebkcepjc.AO-J1Oy305qZj99jXTPEVBN8UZGoYAtjDLj4uTjRQvUFaG0vie-nr6VBlN0qnNDMU8eJR-sI7o3CwQyMOEHKl8eJsoQ86KSFzxKBR07PSpHLI_o7agXhNKY" - MaxLineLength:OutagerIntegrationTest.kt$OutagerIntegrationTest$detailsToken = "AEuhp4Kd9cZ3ZlkS2MylEXHBcZVLjwwllncPBm4a6lrVvj3uYGICnsE5w87i81qNsa38DPOW08BcZfLxJFxIWeISVwoBkT55tA2Bb6cKGsip724=" MaxLineLength:OutagerIntegrationTest.kt$OutagerIntegrationTest$purchaseToken = "lgeigljfpmeoddkcebkcepjc.AO-J1Oy305qZj99jXTPEVBN8UZGoYAtjDLj4uTjRQvUFaG0vie-nr6VBlN0qnNDMU8eJR-sI7o3CwQyMOEHKl8eJsoQ86KSFzxKBR07PSpHLI_o7agXhNKY" MaxLineLength:OutagerIntegrationTest.kt$OutagerIntegrationTest$val token = "dt70kovLQdKymNnhIY6I94:APA91bGfg6m108VFio2ZdgLR6U0B2PtqAn0hIPVU7M4jKklkMxqDUrjoThpX_K60M7CfH8IVZqtku31ei2hmjdJZDfm-bdAl7uxLDWFU8yVcA6-3wBMn3nsYmUrhYWom-qgGC7yIUYzR" MaxLineLength:OutagerIntegrationTest.kt$OutagerIntegrationTest.<no name provided>$assertEquals(error.additionalMessage, """HTTP status code=503, data={"message":"Service Unavailable","code":0,"status":503}. """) - MaxLineLength:PurchasesCacheTest.kt$PurchasesCacheTest$"\"purchaseToken\":\"gfegjilekkmecbonpfjiaakm.AO-J1OxQCaAn0NPlHTh5CoOiXK0p19X7qEymW9SHtssrggp7S9YafjA1oPBPlWO4Ur3W5rtyNJBzIrVoLOb5In0Jxofv4xV_7t1HaUYYd_f8xOBk7nRIY7g\"," + MaxLineLength:PurchasesCacheTest.kt$PurchasesCacheTest$"\"purchaseToken\":\"gfegjilekkmecbonpfjiaakm.AO-J1OxQCaAn0NPlHTh5CoOiXK0p19X7qEymW9SHtssrggp7S9YafjA1oPBPlWO4Ur3W5rtyNJBzIrVoLOb5In0Jxofv4xV_7t1HaUYYd_f8xOBk7nRIY7g\"}" MaxLineLength:PurchasesCacheTest.kt$PurchasesCacheTest$private val fourPurchasesStr = "[${generatePurchaseJson()},${generatePurchaseJson("2")},${generatePurchaseJson("3")},${generatePurchaseJson("4")}]" MaxLineLength:PurchasesCacheTest.kt$PurchasesCacheTest$purchaseToken = "gfegjilekkmecbonpfjiaakm.AO-J1OxQCaAn0NPlHTh5CoOiXK0p19X7qEymW9SHtssrggp7S9YafjA1oPBPlWO4Ur3W5rtyNJBzIrVoLOb5In0Jxofv4xV_7t1HaUYYd_f8xOBk7nRIY7g" MaxLineLength:PurchasesCacheTest.kt$PurchasesCacheTest.SavePurchase$"[${generatePurchaseJson("2")},${generatePurchaseJson("3")},${generatePurchaseJson("4")},${generatePurchaseJson("5")}]" @@ -145,27 +105,17 @@ MaxLineLength:QEntitlementsUpdateListener.kt$QEntitlementsUpdateListener$* MaxLineLength:QProductCenterManagerTest.kt$QProductCenterManagerTest${ Assert.assertEquals("Wrong installDate value", installDate.milliSecondsToSeconds(), installDateSlot.captured) } MaxLineLength:QProductCenterManagerTest.kt$QProductCenterManagerTest${ Assert.assertEquals("Wrong purchaseToken value", purchaseToken, entityPurchaseSlot.captured.purchaseToken) } + MaxLineLength:QProductStoreDetails.kt$QProductStoreDetails$basePlanSubscriptionOfferDetails?.basePlan?.recurrenceMode == QProductPricingPhase.RecurrenceMode.NonRecurring MaxLineLength:QUserPropertiesManagerTest.kt$QUserPropertiesManagerTest$fun MaxLineLength:Qonversion.kt$Qonversion$* - MaxLineLength:QonversionBillingService.kt$QonversionBillingService$"Failed to acknowledge purchase with token $purchaseToken ${billingResult.getDescription()}" - MaxLineLength:QonversionBillingService.kt$QonversionBillingService$logger.debug("queryPurchaseHistoryAsync() -> purchase history for $skuType is retrieved ${record.getDescription()}") - MaxLineLength:QonversionBillingService.kt$QonversionBillingService$logger.debug("querySkuDetailsAsync() -> Querying skuDetails for type $productType, identifiers: ${skuList.joinToString()}") - MaxLineLength:QonversionBillingService.kt$QonversionBillingService$logger.release("launchBillingFlow() -> Failed to launch billing flow. ${billingResult.getDescription()}") - MaxLineLength:QonversionBillingService.kt$QonversionBillingService$queryPurchasesAsync - MaxLineLength:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.GetSkuDetailsFromPurchases$assertThat(billingError!!.billingResponseCode).isEqualTo(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) - MaxLineLength:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.GetSkuDetailsFromPurchases$billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)) - MaxLineLength:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.LoadProducts$assertThat(billingError!!.billingResponseCode).isEqualTo(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) - MaxLineLength:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.LoadProducts$billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)) - MaxLineLength:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.QueryPurchases$assertThat(billingError!!.billingResponseCode).isEqualTo(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) - MaxLineLength:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.QueryPurchases$billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)) - MaxLineLength:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.QueryPurchasesHistory$assertThat(billingError!!.billingResponseCode).isEqualTo(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) - MaxLineLength:QonversionBillingServiceTest.kt$QonversionBillingServiceTest.QueryPurchasesHistory$billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)) + MaxLineLength:QonversionBillingService.kt$QonversionBillingService$"updatePurchase() -> Purchase was found successfully for store product: ${purchaseHistoryRecord.productId}" + MaxLineLength:QonversionBillingService.kt$QonversionBillingService$logger.debug("queryPurchaseHistoryAsync() -> purchase history for $productType is retrieved ${record.getDescription()}") + MaxLineLength:QonversionBillingService.kt$QonversionBillingService${ error -> logger.release("Failed to fetch product type for purchase $productId - " + error.message) } MaxLineLength:QonversionConfig.kt$QonversionConfig.Builder$* MaxLineLength:QonversionError.kt$QonversionErrorCode$* MaxLineLength:QonversionRepositoryIntegrationTest.kt$QonversionRepositoryIntegrationTest$"""HTTP status code=400, data={"message":"Invalid access token received","code":10003,"status":400,"extra":[]}. """ MaxLineLength:QonversionRepositoryIntegrationTest.kt$QonversionRepositoryIntegrationTest$"lcbfeigohklhpdgmpildjabg.AO-J1OyV-EE2bKGqDcRCvqjZ2NI1uHDRuvonRn5RorP6LNsyK7yHK8FaFlXp6bjTEX3-4JvZKtbY_bpquKBfux09Mfkx05M9YGZsfsr5BJk74r719m77Oyo" MaxLineLength:QonversionRepositoryIntegrationTest.kt$QonversionRepositoryIntegrationTest$"lgeigljfpmeoddkcebkcepjc.AO-J1Oy305qZj99jXTPEVBN8UZGoYAtjDLj4uTjRQvUFaG0vie-nr6VBlN0qnNDMU8eJR-sI7o3CwQyMOEHKl8eJsoQ86KSFzxKBR07PSpHLI_o7agXhNKY" - MaxLineLength:QonversionRepositoryIntegrationTest.kt$QonversionRepositoryIntegrationTest$detailsToken = "AEuhp4Kd9cZ3ZlkS2MylEXHBcZVLjwwllncPBm4a6lrVvj3uYGICnsE5w87i81qNsa38DPOW08BcZfLxJFxIWeISVwoBkT55tA2Bb6cKGsip724=" MaxLineLength:QonversionRepositoryIntegrationTest.kt$QonversionRepositoryIntegrationTest$purchaseToken = "lgeigljfpmeoddkcebkcepjc.AO-J1Oy305qZj99jXTPEVBN8UZGoYAtjDLj4uTjRQvUFaG0vie-nr6VBlN0qnNDMU8eJR-sI7o3CwQyMOEHKl8eJsoQ86KSFzxKBR07PSpHLI_o7agXhNKY" MaxLineLength:QonversionRepositoryIntegrationTest.kt$QonversionRepositoryIntegrationTest$val token = "dt70kovLQdKymNnhIY6I94:APA91bGfg6m108VFio2ZdgLR6U0B2PtqAn0hIPVU7M4jKklkMxqDUrjoThpX_K60M7CfH8IVZqtku31ei2hmjdJZDfm-bdAl7uxLDWFU8yVcA6-3wBMn3nsYmUrhYWom-qgGC7yIUYzR" MaxLineLength:ScreenPresenterTest.kt$ScreenPresenterTest$fun @@ -173,11 +123,10 @@ MaxLineLength:SharedPreferencesCacheTest.kt$SharedPreferencesCacheTest.Object${ Assert.assertEquals("Wrong offering products value", expectedValue.offerings?.main?.products, realValue?.offerings?.main?.products) } MaxLineLength:SharedPreferencesCacheTest.kt$SharedPreferencesCacheTest.Object${ Assert.assertEquals("Wrong offeringID value", expectedValue.offerings?.main?.offeringID, realValue?.offerings?.main?.offeringID) } MaxLineLength:SharedPreferencesCacheTest.kt$SharedPreferencesCacheTest.Object${ Assert.assertEquals("Wrong userProducts value", expectedValue.userProducts, realValue?.userProducts) } - MaxLineLength:TokenExtractorTest.kt$TokenExtractorTest$private lateinit var tokenExtractor: Extractor<Response<BaseResponse<com.qonversion.android.sdk.internal.dto.Response>>> MaxLineLength:errors.kt$"Please make sure that you are using the google account where purchases are allowed and the application was correctly signed and properly set up for billing." - MaxLineLength:util.kt$Util.Companion$"\"offerings\":[{\"id\":\"main\",\"tag\":1,\"products\":[{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2},{\"id\":\"main\",\"store_id\":\"qonversion_subs_weekly\",\"type\":0,\"duration\":0}],\"experiment\":{\"uid\":\"secondary\",\"attached\":false}" - MaxLineLength:util.kt$Util.Companion$"\"permissions\":[{\"id\":\"standart\",\"associated_product\":\"in_app\",\"renew_state\":-1,\"started_timestamp\":1612880300,\"source\":\"playstore\",\"active\":1},{\"id\":\"Test Permission\",\"associated_product\":\"in_app\",\"renew_state\":-1,\"started_timestamp\":1612880300,\"source\":\"appstore\",\"active\":1}],\"user_products\":[{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2}],\"experiments\":[]," - MaxLineLength:utils.kt$"ProductId: ${this.sku}; PurchaseTime: ${this.purchaseTime.convertLongToTime()}; PurchaseToken: ${this.purchaseToken}" + MaxLineLength:util.kt$Util.Companion$"\"offerings\":[{\"id\":\"main\",\"tag\":1,\"products\":[{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2},{\"id\":\"main\",\"store_id\":\"qonversion_subs_weekly\",\"type\":0,\"duration\":0}]" + MaxLineLength:util.kt$Util.Companion$"\"permissions\":[{\"id\":\"standart\",\"associated_product\":\"in_app\",\"renew_state\":-1,\"started_timestamp\":1612880300,\"source\":\"playstore\",\"active\":1},{\"id\":\"Test Permission\",\"associated_product\":\"in_app\",\"renew_state\":-1,\"started_timestamp\":1612880300,\"source\":\"appstore\",\"active\":1}],\"user_products\":[{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2}]," + MaxLineLength:utils.kt$"ProductId: ${this.productId}; PurchaseTime: ${this.purchaseTime.convertLongToTime()}; PurchaseToken: ${this.purchaseToken}" MaximumLineLength:com.qonversion.android.sdk.automations.internal.AutomationsEventMapperTest.kt:105 MaximumLineLength:com.qonversion.android.sdk.automations.internal.AutomationsEventMapperTest.kt:106 MaximumLineLength:com.qonversion.android.sdk.automations.internal.AutomationsEventMapperTest.kt:107 @@ -191,58 +140,42 @@ MaximumLineLength:com.qonversion.android.sdk.automations.internal.QAutomationsManager.kt:135 MaximumLineLength:com.qonversion.android.sdk.automations.internal.QAutomationsManager.kt:142 MaximumLineLength:com.qonversion.android.sdk.automations.mvp.ScreenPresenterTest.kt:159 - MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:113 - MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:231 - MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:382 - MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:441 - MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:89 - MaximumLineLength:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:160 - MaximumLineLength:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:162 + MaximumLineLength:com.qonversion.android.sdk.dto.products.QProductStoreDetails.kt:130 + MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:213 + MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:370 + MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:429 + MaximumLineLength:com.qonversion.android.sdk.internal.OutagerIntegrationTest.kt:90 + MaximumLineLength:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:147 + MaximumLineLength:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:148 MaximumLineLength:com.qonversion.android.sdk.internal.QUserPropertiesManagerTest.kt:175 - MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:116 - MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:324 - MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:387 - MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:718 - MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:92 - MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:939 + MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:286 + MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:355 + MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:684 + MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:89 + MaximumLineLength:com.qonversion.android.sdk.internal.QonversionRepositoryIntegrationTest.kt:905 MaximumLineLength:com.qonversion.android.sdk.internal.api.ApiErrorMapper.kt:117 MaximumLineLength:com.qonversion.android.sdk.internal.api.ApiErrorMapper.kt:118 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:109 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:137 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:315 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:390 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:434 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:178 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:181 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:319 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:322 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:543 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:546 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:715 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:718 - MaximumLineLength:com.qonversion.android.sdk.internal.billing.utils.kt:16 + MaximumLineLength:com.qonversion.android.sdk.internal.billing.BillingClientWrapperBase.kt:59 + MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:143 + MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:253 + MaximumLineLength:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:371 + MaximumLineLength:com.qonversion.android.sdk.internal.billing.utils.kt:22 MaximumLineLength:com.qonversion.android.sdk.internal.errors.kt:33 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:199 + MaximumLineLength:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:166 + MaximumLineLength:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:188 MaximumLineLength:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:21 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:245 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:82 + MaximumLineLength:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:72 + MaximumLineLength:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:219 + MaximumLineLength:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:220 + MaximumLineLength:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:221 MaximumLineLength:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:222 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:223 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:224 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:225 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.TokenExtractorTest.kt:19 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.util.kt:134 - MaximumLineLength:com.qonversion.android.sdk.internal.storage.util.kt:135 + MaximumLineLength:com.qonversion.android.sdk.internal.storage.util.kt:137 + MaximumLineLength:com.qonversion.android.sdk.internal.storage.util.kt:138 NestedBlockDepth:ApiErrorMapper.kt$ApiErrorMapper$fun <T> getErrorFromResponse(value: Response<T>): QonversionError NewLineAtEndOfFile:ApiHelperTest.kt$com.qonversion.android.sdk.internal.api.ApiHelperTest.kt NewLineAtEndOfFile:AppRequestTest.kt$com.qonversion.android.sdk.internal.requests.AppRequestTest.kt - NewLineAtEndOfFile:AttributionRequestTest.kt$com.qonversion.android.sdk.internal.requests.AttributionRequestTest.kt NewLineAtEndOfFile:AutomationsEventMapperTest.kt$com.qonversion.android.sdk.automations.internal.AutomationsEventMapperTest.kt - NewLineAtEndOfFile:ConsumerTest.kt$com.qonversion.android.sdk.internal.ConsumerTest.kt - NewLineAtEndOfFile:EnvironmentRequestTest.kt$com.qonversion.android.sdk.internal.requests.EnvironmentRequestTest.kt - NewLineAtEndOfFile:GooglePurchaseConverterTest.kt$com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt NewLineAtEndOfFile:IncrementalDelayCalculatorTest.kt$com.qonversion.android.sdk.internal.IncrementalDelayCalculatorTest.kt - NewLineAtEndOfFile:InitRequestTest.kt$com.qonversion.android.sdk.internal.requests.InitRequestTest.kt NewLineAtEndOfFile:InternalConfigTest.kt$com.qonversion.android.sdk.internal.InternalConfigTest.kt NewLineAtEndOfFile:OsRequestTest.kt$com.qonversion.android.sdk.internal.requests.OsRequestTest.kt NewLineAtEndOfFile:ProviderDataRequestTest.kt$com.qonversion.android.sdk.internal.requests.ProviderDataRequestTest.kt @@ -252,38 +185,22 @@ NewLineAtEndOfFile:QProductCenterManagerTest.kt$com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt NewLineAtEndOfFile:QUserInfoServiceTest.kt$com.qonversion.android.sdk.internal.services.QUserInfoServiceTest.kt NewLineAtEndOfFile:QUserPropertiesManagerTest.kt$com.qonversion.android.sdk.internal.QUserPropertiesManagerTest.kt - NewLineAtEndOfFile:QonversionBillingServiceTest.kt$com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt - NewLineAtEndOfFile:RequestQueueTest.kt$com.qonversion.android.sdk.internal.requests.queue.RequestQueueTest.kt NewLineAtEndOfFile:ScreenPresenterTest.kt$com.qonversion.android.sdk.automations.mvp.ScreenPresenterTest.kt - NewLineAtEndOfFile:SkuDetailsTokenExtractorTest.kt$com.qonversion.android.sdk.internal.converter.SkuDetailsTokenExtractorTest.kt - NewLineAtEndOfFile:TokenExtractorTest.kt$com.qonversion.android.sdk.internal.storage.TokenExtractorTest.kt - NewLineAtEndOfFile:TokenStorageTest.kt$com.qonversion.android.sdk.internal.storage.TokenStorageTest.kt - NewLineAtEndOfFile:mockUtils.kt$com.qonversion.android.sdk.internal.billing.mockUtils.kt - NewLineAtEndOfFile:util.kt$com.qonversion.android.sdk.internal.converter.util.kt - NewLineAtEndOfFile:util.kt$com.qonversion.android.sdk.internal.requests.queue.util.kt NewLineAtEndOfFile:util.kt$com.qonversion.android.sdk.internal.storage.util.kt - NewLineAtEndOfFile:util.kt$com.qonversion.android.sdk.internal.validator.util.kt NewLineAtEndOfFile:utils.kt$com.qonversion.android.sdk.utils.kt NoBlankLineBeforeRbrace:com.qonversion.android.sdk.automations.internal.QAutomationsManagerTest.kt:270 - NoBlankLineBeforeRbrace:com.qonversion.android.sdk.internal.converter.util.kt:55 - NoBlankLineBeforeRbrace:com.qonversion.android.sdk.internal.validator.RequestValidatorTest.kt:17 NoBlankLineBeforeRbrace:com.qonversion.android.sdk.internal.validator.TokenValidatorTest.kt:20 NoConsecutiveBlankLines:com.qonversion.android.sdk.QonversionConfigTest.kt:118 NoConsecutiveBlankLines:com.qonversion.android.sdk.automations.internal.QAutomationsManagerTest.kt:396 NoConsecutiveBlankLines:com.qonversion.android.sdk.internal.QAttributionManagerTest.kt:141 NoConsecutiveBlankLines:com.qonversion.android.sdk.internal.requests.ProviderDataRequestTest.kt:18 - NoConsecutiveBlankLines:com.qonversion.android.sdk.internal.storage.TokenStorageTest.kt:124 - NoConsecutiveBlankLines:com.qonversion.android.sdk.internal.storage.TokenStorageTest.kt:63 - NoConsecutiveBlankLines:com.qonversion.android.sdk.internal.validator.RequestValidatorTest.kt:3 + NoUnusedImports:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:5 NoWildcardImports:com.qonversion.android.sdk.automations.internal.AutomationsEventMapperTest.kt:12 NoWildcardImports:com.qonversion.android.sdk.automations.internal.AutomationsEventMapperTest.kt:6 NoWildcardImports:com.qonversion.android.sdk.automations.internal.QAutomationsManagerTest.kt:23 NoWildcardImports:com.qonversion.android.sdk.automations.internal.QAutomationsManagerTest.kt:9 NoWildcardImports:com.qonversion.android.sdk.dto.products.QProduct.kt:3 NoWildcardImports:com.qonversion.android.sdk.internal.AdvertisingProvider.kt:7 - NoWildcardImports:com.qonversion.android.sdk.internal.Consumer.kt:5 - NoWildcardImports:com.qonversion.android.sdk.internal.ConsumerTest.kt:11 - NoWildcardImports:com.qonversion.android.sdk.internal.ConsumerTest.kt:6 NoWildcardImports:com.qonversion.android.sdk.internal.EnvironmentProvider.kt:11 NoWildcardImports:com.qonversion.android.sdk.internal.IncrementalDelayCalculator.kt:3 NoWildcardImports:com.qonversion.android.sdk.internal.IncrementalDelayCalculatorTest.kt:3 @@ -293,56 +210,34 @@ NoWildcardImports:com.qonversion.android.sdk.internal.QHandledPurchasesCacheTest.kt:4 NoWildcardImports:com.qonversion.android.sdk.internal.QIdentityManagerTest.kt:7 NoWildcardImports:com.qonversion.android.sdk.internal.QIdentityManagerTest.kt:9 - NoWildcardImports:com.qonversion.android.sdk.internal.QProductCenterManager.kt:10 - NoWildcardImports:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:22 - NoWildcardImports:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:30 + NoWildcardImports:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:21 + NoWildcardImports:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:29 NoWildcardImports:com.qonversion.android.sdk.internal.QProductCenterManagerTest.kt:9 NoWildcardImports:com.qonversion.android.sdk.internal.QUserPropertiesManagerTest.kt:18 NoWildcardImports:com.qonversion.android.sdk.internal.QUserPropertiesManagerTest.kt:19 - NoWildcardImports:com.qonversion.android.sdk.internal.billing.BillingService.kt:6 - NoWildcardImports:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:6 - NoWildcardImports:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:5 - NoWildcardImports:com.qonversion.android.sdk.internal.billing.QonversionBillingServiceTest.kt:8 - NoWildcardImports:com.qonversion.android.sdk.internal.billing.mockUtils.kt:4 - NoWildcardImports:com.qonversion.android.sdk.internal.billing.utils.kt:8 - NoWildcardImports:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverter.kt:4 - NoWildcardImports:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:4 - NoWildcardImports:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:7 - NoWildcardImports:com.qonversion.android.sdk.internal.converter.PurchaseConverter.kt:3 - NoWildcardImports:com.qonversion.android.sdk.internal.purchase.PurchaseHistory.kt:5 + NoWildcardImports:com.qonversion.android.sdk.internal.billing.LegacyBillingClientWrapper.kt:4 + NoWildcardImports:com.qonversion.android.sdk.internal.billing.QonversionBillingService.kt:5 NoWildcardImports:com.qonversion.android.sdk.internal.services.QUserInfoServiceTest.kt:11 NoWildcardImports:com.qonversion.android.sdk.internal.services.QUserInfoServiceTest.kt:5 NoWildcardImports:com.qonversion.android.sdk.internal.storage.LaunchResultCacheWrapperTest.kt:7 NoWildcardImports:com.qonversion.android.sdk.internal.storage.PurchasesCacheTest.kt:8 NoWildcardImports:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:5 - NoWildcardImports:com.qonversion.android.sdk.internal.storage.util.kt:27 + NoWildcardImports:com.qonversion.android.sdk.internal.storage.util.kt:24 NoWildcardImports:com.qonversion.android.sdk.utils.kt:4 ReturnCount:AutomationsEventMapper.kt$AutomationsEventMapper$fun getEventFromRemoteMessage(messageData: Map<String, String>): AutomationsEvent? + ReturnCount:BillingClientWrapper.kt$BillingClientWrapper$override fun makePurchase( activity: Activity, product: QProduct, offerId: String?, applyOffer: Boolean, updatePurchaseInfo: UpdatePurchaseInfo?, onFailed: (error: BillingError) -> Unit ) ReturnCount:ExceptionHandler.kt$ExceptionHandler$private fun isQonversionException(exception: Throwable): Boolean ReturnCount:QExceptionManager.kt$QExceptionManager$private fun getContentOfCrashReport(filename: String): CrashRequest.ExceptionInfo? ReturnCount:QProductCenterManager.kt$QProductCenterManager$@Synchronized private fun executeProductsBlocks(loadStoreProductsError: QonversionError? = null) - ReturnCount:QProductCenterManager.kt$QProductCenterManager$private fun loadStoreProductsIfPossible( @Suppress("DEPRECATION") onLoadCompleted: ((products: List<SkuDetails>) -> Unit)? = null, onLoadFailed: ((error: QonversionError) -> Unit)? = null ) - ReturnCount:QProductCenterManager.kt$QProductCenterManager$private fun processPurchase( context: Activity, productId: String, oldProductId: String?, @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int?, callback: QonversionEntitlementsCallback ) ReturnCount:ScreenPresenter.kt$ScreenPresenter$override fun shouldOverrideUrlLoading(url: String?): Boolean - ReturnCount:SkuDetailsTokenExtractor.kt$SkuDetailsTokenExtractor$override fun extract(response: String?): String SpacingAroundColon:com.qonversion.android.sdk.internal.requests.ProviderDataRequestTest.kt:45 - SpacingAroundComma:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:146 SpacingAroundCurly:com.qonversion.android.sdk.automations.internal.QAutomationsManagerTest.kt:263 - SpacingAroundCurly:com.qonversion.android.sdk.internal.ConsumerTest.kt:109 - SpacingAroundCurly:com.qonversion.android.sdk.internal.ConsumerTest.kt:125 - SpacingAroundCurly:com.qonversion.android.sdk.internal.ConsumerTest.kt:140 SpacingAroundCurly:com.qonversion.android.sdk.internal.QAttributionManagerTest.kt:39 SpacingAroundCurly:com.qonversion.android.sdk.internal.QAttributionManagerTest.kt:54 - SpacingAroundCurly:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:139 - SpacingAroundCurly:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:145 - SpacingAroundCurly:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:297 - SpacingAroundCurly:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:303 SpacingAroundParens:com.qonversion.android.sdk.internal.QUserPropertiesManagerTest.kt:497 - SpacingAroundParens:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:207 - SpacingAroundParens:com.qonversion.android.sdk.internal.converter.GooglePurchaseConverterTest.kt:308 - SpacingAroundParens:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:212 - SpacingAroundParens:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:241 - SpacingAroundParens:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:260 + SpacingAroundParens:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:209 + SpacingAroundParens:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:238 + SpacingAroundParens:com.qonversion.android.sdk.internal.storage.SharedPreferencesCacheTest.kt:257 SpacingAroundParens:com.qonversion.android.sdk.utils.kt:66 StringTemplate:com.qonversion.android.sdk.utils.kt:50 SwallowedException:ApiErrorMapper.kt$ApiErrorMapper$catch (e: JSONException) { errorMessage = "$ERROR=failed to parse the backend response" } @@ -368,19 +263,22 @@ TooGenericExceptionCaught:ScreenFragment.kt$ScreenFragment$e: Exception TooManyFunctions:Api.kt$Api TooManyFunctions:AppComponent.kt$AppComponent + TooManyFunctions:BillingClientWrapper.kt$BillingClientWrapper : BillingClientWrapperBaseIBillingClientWrapper TooManyFunctions:Cache.kt$Cache TooManyFunctions:DefaultRepository.kt$DefaultRepository : QRepository TooManyFunctions:LaunchResultCacheWrapper.kt$LaunchResultCacheWrapper + TooManyFunctions:LegacyBillingClientWrapper.kt$LegacyBillingClientWrapper : BillingClientWrapperBaseIBillingClientWrapper TooManyFunctions:QAutomationsManager.kt$QAutomationsManager TooManyFunctions:QProductCenterManager.kt$QProductCenterManager : PurchasesListenerUserStateProvider TooManyFunctions:QRepository.kt$QRepository TooManyFunctions:Qonversion.kt$Qonversion - TooManyFunctions:QonversionBillingService.kt$QonversionBillingService : PurchasesUpdatedListenerBillingClientStateListenerBillingService + TooManyFunctions:QonversionBillingService.kt$QonversionBillingService : PurchasesUpdatedListenerConnectionListenerBillingService TooManyFunctions:QonversionInternal.kt$QonversionInternal : QonversionLifecycleDelegateAppStateProvider TooManyFunctions:RepositoryWithRateLimits.kt$RepositoryWithRateLimits : QRepository TooManyFunctions:ScreenFragment.kt$ScreenFragment : FragmentView TooManyFunctions:SharedPreferencesCache.kt$SharedPreferencesCache : Cache TooManyFunctions:extensions.kt$com.qonversion.android.sdk.internal.extensions.kt + UnnecessaryAbstractClass:BillingClientWrapperBase.kt$BillingClientWrapperBase UnnecessaryAbstractClass:RequestData.kt$RequestData UnusedPrivateMember:AutomationsEvent.kt$AutomationsEvent$private val productId: String? // Temporarily inaccessible UnusedPrivateMember:QonversionMappingAdapters.kt$QDateAdapter$@ToJson private fun toJson(date: Date): Long @@ -392,9 +290,7 @@ UnusedPrivateMember:QonversionMappingAdapters.kt$QOfferingTagAdapter$@ToJson private fun toJson(enum: QOfferingTag): Int? UnusedPrivateMember:QonversionMappingAdapters.kt$QOfferingsAdapter$@ToJson private fun toJson(offerings: QOfferings?): List<QOffering> UnusedPrivateMember:QonversionMappingAdapters.kt$QPermissionsAdapter$@ToJson private fun toJson(permissions: Map<String, QPermission>): List<QPermission> - UnusedPrivateMember:QonversionMappingAdapters.kt$QProductDurationAdapter$@ToJson private fun toJson(enum: QProductDuration): Int UnusedPrivateMember:QonversionMappingAdapters.kt$QProductRenewStateAdapter$@ToJson private fun toJson(enum: QProductRenewState): Int - UnusedPrivateMember:QonversionMappingAdapters.kt$QProductTypeAdapter$@ToJson private fun toJson(enum: QProductType): Int UnusedPrivateMember:QonversionMappingAdapters.kt$QProductsAdapter$@ToJson private fun toJson(products: Map<String, QProduct>): List<QProduct> UnusedPrivateMember:QonversionMappingAdapters.kt$QRemoteConfigurationSourceAssignmentTypeAdapter$@ToJson private fun toJson(enum: QRemoteConfigurationAssignmentType): String UnusedPrivateMember:QonversionMappingAdapters.kt$QRemoteConfigurationSourceTypeAdapter$@ToJson private fun toJson(enum: QRemoteConfigurationSourceType): String @@ -403,13 +299,8 @@ UnusedPrivateMember:QonversionMappingAdapters.kt$QTransactionTypeAdapter$@ToJson private fun toJson(enum: QTransactionType): String UtilityClassWithPublicConstructor:util.kt$Util WildcardImport:AdvertisingProvider.kt$import android.os.* - WildcardImport:BillingService.kt$import com.android.billingclient.api.* - WildcardImport:Consumer.kt$import com.android.billingclient.api.* - WildcardImport:GooglePurchaseConverter.kt$import com.android.billingclient.api.* - WildcardImport:PurchaseConverter.kt$import com.android.billingclient.api.* - WildcardImport:PurchaseHistory.kt$import com.android.billingclient.api.* + WildcardImport:LegacyBillingClientWrapper.kt$import com.android.billingclient.api.* WildcardImport:QProduct.kt$import com.android.billingclient.api.* - WildcardImport:QProductCenterManager.kt$import com.android.billingclient.api.* WildcardImport:QonversionBillingService.kt$import com.android.billingclient.api.* diff --git a/fastlane/report.xml b/fastlane/report.xml index c0fbb9d7d..4b1059ca7 100644 --- a/fastlane/report.xml +++ b/fastlane/report.xml @@ -5,7 +5,7 @@ - + diff --git a/scripts/maven-release.gradle b/scripts/maven-release.gradle index b2b4eae4e..a40a04985 100644 --- a/scripts/maven-release.gradle +++ b/scripts/maven-release.gradle @@ -1,11 +1,6 @@ apply plugin: 'maven-publish' apply plugin: 'signing' -task androidSourcesJar(type: Jar) { - classifier = 'sources' - from android.sourceSets.main.java.sourceFiles -} - afterEvaluate { publishing { publications { @@ -16,8 +11,6 @@ afterEvaluate { artifactId PUBLISH_ARTIFACT_ID version android.defaultConfig.versionName - artifact androidSourcesJar - pom { name = POM_NAME packaging = POM_PACKAGING diff --git a/sdk/build.gradle b/sdk/build.gradle index 5f2025e2f..8b92e9f93 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -3,10 +3,10 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' android { - compileSdkVersion 33 + compileSdk 33 defaultConfig { - minSdkVersion 16 + minSdkVersion 19 targetSdkVersion 33 consumerProguardFiles 'consumer-rules.pro' @@ -68,7 +68,7 @@ ext { moshiVersion = '1.14.0' retrofit_version = '2.9.0' okhttp_version = '3.12.13' - billing = '6.0.1' + billing = '6.1.0' lifecycle_version = '2.1.0' assertj_version = '3.16.1' junit_version = '5.6.2' diff --git a/sdk/src/androidTest/java/com/qonversion/android/sdk/internal/OutagerIntegrationTest.kt b/sdk/src/androidTest/java/com/qonversion/android/sdk/internal/OutagerIntegrationTest.kt index 834defc0c..a867ce780 100644 --- a/sdk/src/androidTest/java/com/qonversion/android/sdk/internal/OutagerIntegrationTest.kt +++ b/sdk/src/androidTest/java/com/qonversion/android/sdk/internal/OutagerIntegrationTest.kt @@ -13,12 +13,11 @@ import com.qonversion.android.sdk.dto.properties.QUserPropertyKey import com.qonversion.android.sdk.dto.QonversionError import com.qonversion.android.sdk.dto.QonversionErrorCode import com.qonversion.android.sdk.dto.eligibility.QEligibility +import com.qonversion.android.sdk.dto.entitlements.QEntitlementGrantType import com.qonversion.android.sdk.dto.offerings.QOffering import com.qonversion.android.sdk.dto.offerings.QOfferingTag import com.qonversion.android.sdk.dto.offerings.QOfferings import com.qonversion.android.sdk.dto.products.QProduct -import com.qonversion.android.sdk.dto.products.QProductDuration -import com.qonversion.android.sdk.dto.products.QProductType import com.qonversion.android.sdk.internal.di.QDependencyInjector import com.qonversion.android.sdk.internal.dto.QLaunchResult import com.qonversion.android.sdk.internal.dto.QPermission @@ -56,12 +55,10 @@ internal class OutagerIntegrationTest { private val monthlyProduct = QProduct( "test_monthly", "google_monthly", - QProductType.Subscription, - QProductDuration.Monthly + null, ) - private val annualProduct = - QProduct("test_annual", "google_annual", QProductType.Trial, QProductDuration.Annual) - private val inappProduct = QProduct("test_inapp", "google_inapp", QProductType.InApp, null) + private val annualProduct = QProduct("test_annual", "google_annual", null) + private val inappProduct = QProduct("test_inapp", "google_inapp", null) private val expectedProducts = mapOf( monthlyProduct.qonversionID to monthlyProduct, annualProduct.qonversionID to annualProduct, @@ -86,34 +83,11 @@ internal class OutagerIntegrationTest { ) private val purchase = Purchase( - detailsToken = "AEuhp4Kd9cZ3ZlkS2MylEXHBcZVLjwwllncPBm4a6lrVvj3uYGICnsE5w87i81qNsa38DPOW08BcZfLxJFxIWeISVwoBkT55tA2Bb6cKGsip724=", - title = "DONT CHANGE! Sub for integration tests. (Qonversion Sample)", - description = "", - productId = "google_monthly", - type = "subs", - originalPrice = "$6.99", - originalPriceAmountMicros = 6990000, - priceCurrencyCode = "SGD", - price = "6.99", - priceAmountMicros = 6990000, - periodUnit = 2, - periodUnitsCount = 1, - freeTrialPeriod = "", - introductoryAvailable = false, - introductoryPriceAmountMicros = 0, - introductoryPrice = "0.00", - introductoryPriceCycles = 0, - introductoryPeriodUnit = 0, - introductoryPeriodUnitsCount = null, + storeProductId = "google_monthly", orderId = "GPA.3307-0767-0668-99058", originalOrderId = "GPA.3307-0767-0668-99058", - packageName = "com.qonversion.sample", purchaseTime = 1679933171, - purchaseState = 1, purchaseToken = "lgeigljfpmeoddkcebkcepjc.AO-J1Oy305qZj99jXTPEVBN8UZGoYAtjDLj4uTjRQvUFaG0vie-nr6VBlN0qnNDMU8eJR-sI7o3CwQyMOEHKl8eJsoQ86KSFzxKBR07PSpHLI_o7agXhNKY", - acknowledged = false, - autoRenewing = true, - paymentMode = 0 ) @Test @@ -171,9 +145,17 @@ internal class OutagerIntegrationTest { "test_monthly", QProductRenewState.Unknown, Date(1679933171000), - Date(1682525171000), // plus month + Date(1680537971000), // plus week, as we don't send duration QEntitlementSource.Unknown, - 1 + 1, + 2, + null, + null, + null, + null, + QEntitlementGrantType.Purchase, + null, + emptyList() ) ) @@ -230,8 +212,6 @@ internal class OutagerIntegrationTest { "google_monthly", "lgeigljfpmeoddkcebkcepjc.AO-J1Oy305qZj99jXTPEVBN8UZGoYAtjDLj4uTjRQvUFaG0vie-nr6VBlN0qnNDMU8eJR-sI7o3CwQyMOEHKl8eJsoQ86KSFzxKBR07PSpHLI_o7agXhNKY", 1679933171, - "SGD", - "6.99" ) ) val expectedPermissions = mapOf( @@ -242,7 +222,15 @@ internal class OutagerIntegrationTest { Date(1679933171000), Date(1680537971000), // plus seven days QEntitlementSource.Unknown, - 1 + 1, + 0, + null, + null, + null, + null, + QEntitlementGrantType.Purchase, + null, + emptyList() ) ) diff --git a/sdk/src/androidTest/java/com/qonversion/android/sdk/internal/QonversionRepositoryIntegrationTest.kt b/sdk/src/androidTest/java/com/qonversion/android/sdk/internal/QonversionRepositoryIntegrationTest.kt index bf9287f17..cb3b4fb9d 100644 --- a/sdk/src/androidTest/java/com/qonversion/android/sdk/internal/QonversionRepositoryIntegrationTest.kt +++ b/sdk/src/androidTest/java/com/qonversion/android/sdk/internal/QonversionRepositoryIntegrationTest.kt @@ -14,12 +14,11 @@ import com.qonversion.android.sdk.dto.QonversionError import com.qonversion.android.sdk.dto.QonversionErrorCode import com.qonversion.android.sdk.dto.eligibility.QEligibility import com.qonversion.android.sdk.dto.eligibility.QIntroEligibilityStatus +import com.qonversion.android.sdk.dto.entitlements.QEntitlementGrantType import com.qonversion.android.sdk.dto.offerings.QOffering import com.qonversion.android.sdk.dto.offerings.QOfferingTag import com.qonversion.android.sdk.dto.offerings.QOfferings import com.qonversion.android.sdk.dto.products.QProduct -import com.qonversion.android.sdk.dto.products.QProductDuration -import com.qonversion.android.sdk.dto.products.QProductType import com.qonversion.android.sdk.dto.properties.QUserProperty import com.qonversion.android.sdk.internal.di.QDependencyInjector import com.qonversion.android.sdk.internal.dto.QLaunchResult @@ -56,15 +55,9 @@ internal class QonversionRepositoryIntegrationTest { private val noCodeScreenId = "lsarjYcU" - private val monthlyProduct = QProduct( - "test_monthly", - "google_monthly", - QProductType.Subscription, - QProductDuration.Monthly - ) - private val annualProduct = - QProduct("test_annual", "google_annual", QProductType.Trial, QProductDuration.Annual) - private val inappProduct = QProduct("test_inapp", "google_inapp", QProductType.InApp, null) + private val monthlyProduct = QProduct("test_monthly", "google_monthly", null) + private val annualProduct = QProduct("test_annual", "google_annual", null) + private val inappProduct = QProduct("test_inapp", "google_inapp", null) private val expectedProducts = mapOf( monthlyProduct.qonversionID to monthlyProduct, annualProduct.qonversionID to annualProduct, @@ -89,34 +82,11 @@ internal class QonversionRepositoryIntegrationTest { ) private val purchase = Purchase( - detailsToken = "AEuhp4Kd9cZ3ZlkS2MylEXHBcZVLjwwllncPBm4a6lrVvj3uYGICnsE5w87i81qNsa38DPOW08BcZfLxJFxIWeISVwoBkT55tA2Bb6cKGsip724=", - title = "DONT CHANGE! Sub for integration tests. (Qonversion Sample)", - description = "", - productId = "google_monthly", - type = "subs", - originalPrice = "$6.99", - originalPriceAmountMicros = 6990000, - priceCurrencyCode = "SGD", - price = "6.99", - priceAmountMicros = 6990000, - periodUnit = 2, - periodUnitsCount = 1, - freeTrialPeriod = "", - introductoryAvailable = false, - introductoryPriceAmountMicros = 0, - introductoryPrice = "0.00", - introductoryPriceCycles = 0, - introductoryPeriodUnit = 0, - introductoryPeriodUnitsCount = null, + storeProductId = "google_monthly", orderId = "GPA.3307-0767-0668-99058", originalOrderId = "GPA.3307-0767-0668-99058", - packageName = "com.qonversion.sample", purchaseTime = 1679933171, - purchaseState = 1, purchaseToken = "lgeigljfpmeoddkcebkcepjc.AO-J1Oy305qZj99jXTPEVBN8UZGoYAtjDLj4uTjRQvUFaG0vie-nr6VBlN0qnNDMU8eJR-sI7o3CwQyMOEHKl8eJsoQ86KSFzxKBR07PSpHLI_o7agXhNKY", - acknowledged = false, - autoRenewing = true, - paymentMode = 0 ) @Test @@ -242,15 +212,7 @@ internal class QonversionRepositoryIntegrationTest { val signal = CountDownLatch(1) val expectedPermissions = mapOf( - "premium" to QPermission( - "premium", - "test_monthly", - QProductRenewState.Canceled, - Date(1679933171000), - Date(1679935273000), - QEntitlementSource.PlayStore, - 0 - ) + "premium" to expectedPremiumPermission() ) val uid = "QON_test_uid1679992132407" @@ -323,8 +285,6 @@ internal class QonversionRepositoryIntegrationTest { "google_inapp", "lcbfeigohklhpdgmpildjabg.AO-J1OyV-EE2bKGqDcRCvqjZ2NI1uHDRuvonRn5RorP6LNsyK7yHK8FaFlXp6bjTEX3-4JvZKtbY_bpquKBfux09Mfkx05M9YGZsfsr5BJk74r719m77Oyo", 1685953401, - "GBP", - "48.9" ) ) @@ -336,7 +296,15 @@ internal class QonversionRepositoryIntegrationTest { Date(1685953401000), null, QEntitlementSource.PlayStore, - 1 + 1, + 0, + null, + null, + null, + null, + QEntitlementGrantType.Purchase, + null, + emptyList() ) ) @@ -386,8 +354,6 @@ internal class QonversionRepositoryIntegrationTest { "google_monthly", "lgeigljfpmeoddkcebkcepjc.AO-J1Oy305qZj99jXTPEVBN8UZGoYAtjDLj4uTjRQvUFaG0vie-nr6VBlN0qnNDMU8eJR-sI7o3CwQyMOEHKl8eJsoQ86KSFzxKBR07PSpHLI_o7agXhNKY", 1679933171, - "SGD", - "6.99" ) ) @@ -949,4 +915,24 @@ internal class QonversionRepositoryIntegrationTest { "HTTP status code=401, error=User with specified access token does not exist. " ).contains(error.additionalMessage)) } + + private fun expectedPremiumPermission(): QPermission { + return QPermission( + "premium", + "test_monthly", + QProductRenewState.Canceled, + Date(1679933171000), + Date(1679935273000), + QEntitlementSource.PlayStore, + 0, + 0, + null, + null, + null, + null, + QEntitlementGrantType.Purchase, + null, + emptyList() + ) + } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt index fe88b4b9e..2ff93d219 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/Qonversion.kt @@ -2,10 +2,10 @@ package com.qonversion.android.sdk import android.app.Activity import android.util.Log -import com.android.billingclient.api.BillingFlowParams import com.qonversion.android.sdk.dto.QAttributionProvider +import com.qonversion.android.sdk.dto.QPurchaseModel +import com.qonversion.android.sdk.dto.QPurchaseUpdateModel import com.qonversion.android.sdk.dto.properties.QUserPropertyKey -import com.qonversion.android.sdk.dto.products.QProduct import com.qonversion.android.sdk.internal.InternalConfig import com.qonversion.android.sdk.internal.QonversionInternal import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback @@ -77,86 +77,23 @@ interface Qonversion { /** * Make a purchase and validate it through server-to-server using Qonversion's Backend * @param context current activity context - * @param id Qonversion product identifier for purchase + * @param purchaseModel necessary information for purchase * @param callback - callback that will be called when response is received - * @see [Product Center](https://qonversion.io/docs/product-center) - */ - fun purchase(context: Activity, id: String, callback: QonversionEntitlementsCallback) - - /** - * Make a purchase and validate it through server-to-server using Qonversion's Backend - * @param context current activity context - * @param product Qonversion product for purchase - * @param callback - callback that will be called when response is received - * @see [Product Center](https://qonversion.io/docs/product-center) + * @see [Making Purchases](https://documentation.qonversion.io/docs/making-purchases) */ - fun purchase(context: Activity, product: QProduct, callback: QonversionEntitlementsCallback) + fun purchase(context: Activity, purchaseModel: QPurchaseModel, callback: QonversionEntitlementsCallback) /** * Update (upgrade/downgrade) subscription and validate it through server-to-server using Qonversion's Backend * @param context current activity context - * @param productId Qonversion product identifier for purchase - * @param oldProductId Qonversion product identifier from which the upgrade/downgrade will be initialized - * @param callback - callback that will be called when response is received - * @see [Proration mode](https://developer.android.com/google/play/billing/subscriptions#proration) - * @see [Product Center](https://qonversion.io/docs/product-center) - */ - fun updatePurchase( - context: Activity, - productId: String, - oldProductId: String, - callback: QonversionEntitlementsCallback - ) = updatePurchase(context, productId, oldProductId, null, callback) - - /** - * Update (upgrade/downgrade) subscription and validate it through server-to-server using Qonversion's Backend - * @param context current activity context - * @param productId Qonversion product identifier for purchase - * @param oldProductId Qonversion product identifier from which the upgrade/downgrade will be initialized - * @param prorationMode proration mode - * @param callback - callback that will be called when response is received - * @see [Proration mode](https://developer.android.com/google/play/billing/subscriptions#proration) - * @see [Product Center](https://qonversion.io/docs/product-center) - */ - fun updatePurchase( - context: Activity, - productId: String, - oldProductId: String, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int? = null, - callback: QonversionEntitlementsCallback - ) - - /** - * Update (upgrade/downgrade) subscription and validate that through server-to-server using Qonversion's Backend - * @param context current activity context - * @param product Qonversion product for purchase - * @param oldProductId Qonversion product identifier from which the upgrade/downgrade will be initialized + * @param purchaseUpdateModel necessary information for purchase update * @param callback - callback that will be called when response is received - * @see [Proration mode](https://developer.android.com/google/play/billing/subscriptions#proration) - * @see [Product Center](https://qonversion.io/docs/product-center) - */ - fun updatePurchase( - context: Activity, - product: QProduct, - oldProductId: String, - callback: QonversionEntitlementsCallback - ) = updatePurchase(context, product, oldProductId, null, callback) - - /** - * Update (upgrade/downgrade) subscription and validate that through server-to-server using Qonversion's Backend - * @param context current activity context - * @param product Qonversion product for purchase - * @param oldProductId Qonversion product identifier from which the upgrade/downgrade will be initialized - * @param prorationMode proration mode - * @param callback - callback that will be called when response is received - * @see [Proration mode](https://developer.android.com/google/play/billing/subscriptions#proration) - * @see [Product Center](https://qonversion.io/docs/product-center) + * @see [Update policy](https://developer.android.com/google/play/billing/subscriptions#replacement-modes) + * @see [Making Purchases](https://documentation.qonversion.io/docs/making-purchases) */ fun updatePurchase( context: Activity, - product: QProduct, - oldProductId: String, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int? = null, + purchaseUpdateModel: QPurchaseUpdateModel, callback: QonversionEntitlementsCallback ) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/automations/AutomationsDelegate.java b/sdk/src/main/java/com/qonversion/android/sdk/automations/AutomationsDelegate.java index ea126225d..8c9612d43 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/automations/AutomationsDelegate.java +++ b/sdk/src/main/java/com/qonversion/android/sdk/automations/AutomationsDelegate.java @@ -1,7 +1,5 @@ package com.qonversion.android.sdk.automations; -import android.content.Context; - import com.qonversion.android.sdk.automations.dto.AutomationsEvent; import com.qonversion.android.sdk.automations.dto.QActionResult; @@ -9,8 +7,6 @@ import java.util.Map; -import kotlin.Deprecated; - public interface AutomationsDelegate { /** @@ -68,12 +64,4 @@ default void automationsFinished() { default Boolean shouldHandleEvent(@NotNull AutomationsEvent event, @NotNull Map payload) { return true; } - - /** - * Returns the context for screen intent - */ - @Deprecated(message = "Is not used anymore") - default Context contextForScreenIntent() { - return null; - } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/automations/mvp/ScreenFragment.kt b/sdk/src/main/java/com/qonversion/android/sdk/automations/mvp/ScreenFragment.kt index b3ed2b471..e87de608d 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/automations/mvp/ScreenFragment.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/automations/mvp/ScreenFragment.kt @@ -17,6 +17,7 @@ import com.qonversion.android.sdk.automations.dto.QActionResultType import com.qonversion.android.sdk.automations.internal.QAutomationsManager import com.qonversion.android.sdk.automations.internal.macros.ScreenProcessor import com.qonversion.android.sdk.databinding.QFragmentScreenBinding +import com.qonversion.android.sdk.dto.QPurchaseModel import com.qonversion.android.sdk.dto.entitlements.QEntitlement import com.qonversion.android.sdk.dto.QonversionError import com.qonversion.android.sdk.dto.QonversionErrorCode @@ -116,7 +117,7 @@ class ScreenFragment : Fragment(), ScreenContract.View { activity?.let { Qonversion.shared.purchase( it, - productId, + QPurchaseModel(productId), object : QonversionEntitlementsCallback { override fun onSuccess(entitlements: Map) = close(actionResult) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseModel.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseModel.kt new file mode 100644 index 000000000..9c637d76d --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseModel.kt @@ -0,0 +1,28 @@ +package com.qonversion.android.sdk.dto + +import com.qonversion.android.sdk.Qonversion +import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.dto.products.QProductStoreDetails + +/** + * Used to provide all the necessary purchase data to the [Qonversion.purchase] method. + * Can be created manually or using the [QProduct.toPurchaseModel] method. + * + * Requires Qonversion product identifier - [productId]. + * + * If [offerId] is not specified, then the default offer will be applied. To know how we choose + * the default offer, see [QProductStoreDetails.defaultSubscriptionOfferDetails]. + * + * If you want to remove any intro/trial offer from the purchase (use only a bare base plan), + * call the [removeOffer] method. + */ +data class QPurchaseModel @JvmOverloads constructor( + val productId: String, + var offerId: String? = null +) { + internal var applyOffer = true + + fun removeOffer(): QPurchaseModel = apply { + applyOffer = false + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseUpdateModel.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseUpdateModel.kt new file mode 100644 index 000000000..702635a30 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseUpdateModel.kt @@ -0,0 +1,34 @@ +package com.qonversion.android.sdk.dto + +import com.qonversion.android.sdk.Qonversion +import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.dto.products.QProductStoreDetails + +/** + * Used to provide all the necessary purchase data to the [Qonversion.updatePurchase] method. + * Can be created manually or using the [QProduct.toPurchaseUpdateModel] method. + * + * Requires Qonversion product identifiers - [productId] for the purchasing one and + * [oldProductId] for the purchased one. + * + * If [offerId] is not specified, then the default offer will be applied. To know how we choose + * the default offer, see [QProductStoreDetails.defaultSubscriptionOfferDetails]. + * + * If you want to remove any intro/trial offer from the purchase (use only a bare base plan), + * call the [QPurchaseModel.removeOffer] method. + * + * If the [updatePolicy] is not provided, then default one + * will be selected - [QPurchaseUpdatePolicy.WithTimeProration]. + */ +data class QPurchaseUpdateModel @JvmOverloads constructor( + val productId: String, + var oldProductId: String, + var updatePolicy: QPurchaseUpdatePolicy? = null, + var offerId: String? = null +) { + internal var applyOffer = true + + fun removeOffer(): QPurchaseUpdateModel = apply { + applyOffer = false + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseUpdatePolicy.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseUpdatePolicy.kt new file mode 100644 index 000000000..c4560356d --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/QPurchaseUpdatePolicy.kt @@ -0,0 +1,87 @@ +package com.qonversion.android.sdk.dto + +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode + +/** + * A policy used for purchase updates, which describes + * how to migrate from purchased plan to a new one. + * + * Used in [QPurchaseUpdateModel] class for purchase updates. + */ +enum class QPurchaseUpdatePolicy { + /** + * The new plan takes effect immediately, and the user is charged full price of new plan + * and is given a full billing cycle of subscription, plus remaining prorated time + * from the old plan. + */ + ChargeFullPrice, + + /** + * The new plan takes effect immediately, and the billing cycle remains the same. + */ + ChargeProratedPrice, + + /** + * The new plan takes effect immediately, and the remaining time will be prorated + * and credited to the user. + */ + WithTimeProration, + + /** + * The new purchase takes effect immediately, the new plan will take effect + * when the old item expires. + */ + Deferred, + + /** + * The new plan takes effect immediately, and the new price will be charged + * on next recurrence time. + */ + WithoutProration, + + /** + * Unknown police. + */ + Unknown; + + internal fun toReplacementMode(): Int { + return when (this) { + ChargeFullPrice -> ReplacementMode.CHARGE_FULL_PRICE + ChargeProratedPrice -> ReplacementMode.CHARGE_PRORATED_PRICE + WithTimeProration -> ReplacementMode.WITH_TIME_PRORATION + Deferred -> ReplacementMode.DEFERRED + WithoutProration -> ReplacementMode.WITHOUT_PRORATION + else -> ReplacementMode.UNKNOWN_REPLACEMENT_MODE + } + } + + @Suppress("DEPRECATION") + internal fun toProrationMode(): Int { + return when (this) { + ChargeFullPrice -> BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_FULL_PRICE + ChargeProratedPrice -> BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE + WithTimeProration -> BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION + Deferred -> BillingFlowParams.ProrationMode.DEFERRED + WithoutProration -> BillingFlowParams.ProrationMode.IMMEDIATE_WITHOUT_PRORATION + else -> BillingFlowParams.ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY + } + } + + companion object { + + @Suppress("DEPRECATION") + fun fromProrationMode( + @BillingFlowParams.ProrationMode prorationMode: Int? + ): QPurchaseUpdatePolicy { + return when (prorationMode) { + BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_FULL_PRICE -> ChargeFullPrice + BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE -> ChargeProratedPrice + BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION -> WithTimeProration + BillingFlowParams.ProrationMode.DEFERRED -> Deferred + BillingFlowParams.ProrationMode.IMMEDIATE_WITHOUT_PRORATION -> WithoutProration + else -> Unknown + } + } + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/QonversionError.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/QonversionError.kt index 45d106883..b9639e6e0 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/dto/QonversionError.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/QonversionError.kt @@ -28,15 +28,14 @@ enum class QonversionErrorCode(val specification: String) { ProductNotOwned("Failed to consume purchase since item is not owned"), ProductAlreadyOwned("Failed to purchase since item is already owned"), FeatureNotSupported("The requested feature is not supported"), - ProductUnavailable("Requested product is not available for purchase or its SKU was not found"), + ProductUnavailable("Requested product is not available for purchase or its product id was not found"), NetworkConnectionFailed("There was a network issue. " + "Please make sure that the Internet connection is available on the device"), - ParseResponseFailed("A problem occurred when serializing or deserializing data"), + ParseResponseFailed("A problem occurred while serializing or deserializing data"), BackendError("There was a backend error"), ProductNotFound("Failed to purchase since the Qonversion product was not found"), OfferingsNotFound("No offerings found"), - LaunchError("There was an error on launching Qonversion SDK"), - SkuDetailsError("Failed to retrieve SkuDetails for the in-app product ID"), + LaunchError("There was an error while launching Qonversion SDK"), InvalidCredentials("Access token is invalid or not set"), InvalidClientUid("Client Uid is invalid or not set"), UnknownClientPlatform("The current platform is not supported"), diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/eligibility/QIntroEligibilityStatus.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/eligibility/QIntroEligibilityStatus.kt index 73a4d4ace..24c73d7a4 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/dto/eligibility/QIntroEligibilityStatus.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/eligibility/QIntroEligibilityStatus.kt @@ -1,5 +1,7 @@ package com.qonversion.android.sdk.dto.eligibility +import com.qonversion.android.sdk.dto.products.QProductType + enum class QIntroEligibilityStatus(val type: String) { NonIntroProduct("non_intro_or_trial_product"), Eligible("intro_or_trial_eligible"), @@ -15,5 +17,14 @@ enum class QIntroEligibilityStatus(val type: String) { else -> Unknown } } + + fun fromProductType(productType: QProductType): QIntroEligibilityStatus { + return when (productType) { + QProductType.Intro, QProductType.Trial -> Eligible + QProductType.Subscription -> Ineligible + QProductType.InApp -> NonIntroProduct + QProductType.Unknown -> Unknown + } + } } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProduct.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProduct.kt index 1beeb62e7..6efa3e799 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProduct.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProduct.kt @@ -1,7 +1,10 @@ package com.qonversion.android.sdk.dto.products import com.android.billingclient.api.* -import com.qonversion.android.sdk.internal.converter.GoogleBillingPeriodConverter +import com.qonversion.android.sdk.Qonversion +import com.qonversion.android.sdk.dto.QPurchaseModel +import com.qonversion.android.sdk.dto.QPurchaseUpdateModel +import com.qonversion.android.sdk.dto.QPurchaseUpdatePolicy import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -9,24 +12,137 @@ import com.squareup.moshi.JsonClass data class QProduct( @Json(name = "id") val qonversionID: String, @Json(name = "store_id") val storeID: String?, - @Json(name = "type") val type: QProductType, - @Json(name = "duration") val duration: QProductDuration? + @Json(name = "base_plan_id") val basePlanID: String?, ) { @Transient + @Deprecated("Consider using `storeDetails` instead") @Suppress("DEPRECATION") var skuDetail: SkuDetails? = null - set(value) { - prettyPrice = value?.price - trialDuration = GoogleBillingPeriodConverter.convertTrialPeriod(value?.freeTrialPeriod) - field = value - } + /** + * The store details of this product containing all the information from Google Play including + * the offers for purchasing the base plan of this product (specified by [basePlanID]) + * in case of a subscription. + * Null, if the product was not found. If the [basePlanID] is not specified for a subscription + * product, this field will be presented but the [QProductStoreDetails.subscriptionOfferDetails] + * will be empty. + */ @Transient - var offeringID: String? = null + var storeDetails: QProductStoreDetails? = null + private set @Transient - var prettyPrice: String? = null + var offeringID: String? = null - @Transient - var trialDuration: QTrialDuration = QTrialDuration.Unknown + /** + * The subscription base plan duration for this product. If the [basePlanID] is not specified, + * the duration is calculated using the deprecated [skuDetail]. + * Null, if it's not a subscription product or the product was not found in Google Play. + */ + val subscriptionPeriod: QSubscriptionPeriod? get() = + storeDetails?.defaultSubscriptionOfferDetails?.let { + return it.basePlan?.billingPeriod + } ?: @Suppress("DEPRECATION") skuDetail?.subscriptionPeriod + ?.takeIf { it.isNotBlank() } + ?.let { QSubscriptionPeriod.from(it) } + + /** + * The subscription trial duration of the default offer for this product. + * See [QProductStoreDetails.defaultSubscriptionOfferDetails] for the information on how we + * choose the default offer. + * Null, if it's not a subscription product or the product was not found in Google Play. + */ + val trialPeriod: QSubscriptionPeriod? get() = + storeDetails?.defaultSubscriptionOfferDetails?.let { + return it.trialPhase?.billingPeriod + } ?: @Suppress("DEPRECATION") skuDetail?.freeTrialPeriod + ?.takeIf { it.isNotBlank() } + ?.let { QSubscriptionPeriod.from(it) } + + /** + * The calculated type of this product based on the store information. + * Using deprecated [skuDetail] for the old subscription products + * where [basePlanID] is not specified, and [storeDetails] for all the other products. + */ + val type: QProductType get() { + val productType = storeDetails?.let { + if (it.subscriptionOfferDetails?.isNotEmpty() == true || it.inAppOfferDetails != null) { + it.productType + } else { + null + } + } + + @Suppress("DEPRECATION") + return when { + // We were able to detect the type of the product base on new Google Store details + productType != null && productType != QProductType.Unknown -> productType + // The options below use the deprecated Google Store details and are used only for + // the old subscription products, where basePlanId is not specified. + skuDetail?.type == BillingClient.SkuType.INAPP -> QProductType.InApp + trialPeriod != null -> QProductType.Trial + skuDetail?.introductoryPricePeriod?.isNotBlank() == true -> QProductType.Intro + subscriptionPeriod != null -> QProductType.Subscription + else -> QProductType.Unknown + } + } + + /** + * Formatted price for this product, including the currency sign. + */ + val prettyPrice: String? get() = when { + type == QProductType.InApp -> storeDetails?.inAppOfferDetails?.price?.formattedPrice + storeDetails?.basePlanSubscriptionOfferDetails != null -> + storeDetails?.basePlanSubscriptionOfferDetails?.basePlan?.price?.formattedPrice + else -> @Suppress("DEPRECATION") skuDetail?.price + } + + /** + * Converts this product to purchase model to pass to [Qonversion.purchase]. + * @param offerId concrete offer identifier if necessary. + * If the products' base plan id is specified, but offer id is not provided for + * purchase, then default offer will be used. + * Ignored if base plan id is not specified. + * To know how we choose the default offer, see [QProductStoreDetails.defaultSubscriptionOfferDetails]. + * @return purchase model to pass to the purchase method. + */ + @JvmOverloads + fun toPurchaseModel(offerId: String? = null): QPurchaseModel { + return QPurchaseModel(qonversionID, offerId) + } + + /** + * Converts this product to purchase model to pass to [Qonversion.purchase]. + * @param offer concrete offer which you'd like to purchase. + * @return purchase model to pass to the purchase method. + */ + fun toPurchaseModel(offer: QProductOfferDetails?): QPurchaseModel { + val model = toPurchaseModel(offer?.offerId) + // Remove offer for the case when provided offer details are for bare base plan. + if (offer != null && offer.offerId == null) { + model.removeOffer() + } + + return model + } + + /** + * Converts this product to purchase update (upgrade/downgrade) model + * to pass to [Qonversion.updatePurchase]. + * @param oldProductId Qonversion product identifier from which the upgrade/downgrade + * will be initialized. + * @param updatePolicy purchase update policy. + * @return purchase model to pass to the update purchase method. + */ + @JvmOverloads + fun toPurchaseUpdateModel( + oldProductId: String, + updatePolicy: QPurchaseUpdatePolicy? = null + ): QPurchaseUpdateModel { + return QPurchaseUpdateModel(qonversionID, oldProductId, updatePolicy) + } + + internal fun setStoreProductDetails(productDetails: ProductDetails) { + storeDetails = QProductStoreDetails(productDetails, basePlanID) + } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductDuration.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductDuration.kt deleted file mode 100644 index dd9bc8c8c..000000000 --- a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductDuration.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.qonversion.android.sdk.dto.products - -enum class QProductDuration(val type: Int) { - Weekly(0), - Monthly(1), - ThreeMonthly(2), - SixMonthly(3), - Annual(4); - - companion object { - fun fromType(type: Int): QProductDuration? { - return when (type) { - 0 -> Weekly - 1 -> Monthly - 2 -> ThreeMonthly - 3 -> SixMonthly - 4 -> Annual - else -> null - } - } - } -} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductInAppDetails.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductInAppDetails.kt new file mode 100644 index 000000000..b42bd5976 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductInAppDetails.kt @@ -0,0 +1,20 @@ +package com.qonversion.android.sdk.dto.products + +import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails + +/** + * This class contains all the information about the Google in-app product details. + */ +data class QProductInAppDetails( + /** + * Original [OneTimePurchaseOfferDetails] received from Google Play Billing Library. + */ + val originalOneTimePurchaseOfferDetails: OneTimePurchaseOfferDetails +) { + /** + * The price of the in-app product. + */ + val price: QProductPrice = originalOneTimePurchaseOfferDetails.let { + QProductPrice(it.priceAmountMicros, it.priceCurrencyCode, it.formattedPrice) + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductOfferDetails.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductOfferDetails.kt new file mode 100644 index 000000000..533478820 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductOfferDetails.kt @@ -0,0 +1,74 @@ +package com.qonversion.android.sdk.dto.products + +import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails + +/** + * This class contains all the information about the Google subscription offer details. + * It might be either a plain base plan details or a base plan with the concrete offer details. + */ +data class QProductOfferDetails( + /** + * Original [SubscriptionOfferDetails] received from Google Play Billing Library. + */ + val originalOfferDetails: SubscriptionOfferDetails +) { + /** + * The identifier of the current base plan. + */ + val basePlanId: String = originalOfferDetails.basePlanId + + /** + * The identifier of the concrete offer, to which these details belong. + * Null, if these are plain base plan details. + */ + val offerId: String? = originalOfferDetails.offerId + + /** + * A token to purchase the current offer. + */ + val offerToken: String = originalOfferDetails.offerToken + + /** + * List of tags set for the current offer. + */ + val tags: List = originalOfferDetails.offerTags + + /** + * A time-ordered list of pricing phases for the current offer. + */ + val pricingPhases: List = + originalOfferDetails.pricingPhases.pricingPhaseList.map { QProductPricingPhase(it) } + + /** + * A base plan phase details. + */ + val basePlan: QProductPricingPhase? = pricingPhases.find { it.isBasePlan } + + /** + * A trial phase details, if exists. + */ + val trialPhase: QProductPricingPhase? = pricingPhases.find { it.isTrial } + + /** + * An intro phase details, if exists. + * The intro phase is one of single or recurrent discounted payments. + */ + val introPhase: QProductPricingPhase? = pricingPhases.find { it.isIntro } + + /** + * True, if there is a trial phase in the current offer. False otherwise. + */ + val hasTrial: Boolean = trialPhase != null + + /** + * True, if there is any intro phase in the current offer. False otherwise. + * The intro phase is one of single or recurrent discounted payments. + */ + val hasIntro: Boolean = introPhase != null + + /** + * True, if there is any trial or intro phase in the current offer. False otherwise. + * The intro phase is one of single or recurrent discounted payments. + */ + val hasTrialOrIntro: Boolean = hasTrial || hasIntro +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductPrice.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductPrice.kt new file mode 100644 index 000000000..5cbdbbb95 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductPrice.kt @@ -0,0 +1,43 @@ +package com.qonversion.android.sdk.dto.products + +import java.util.Currency + +/** + * Information about the product's price. + */ +data class QProductPrice( + /** + * Total amount of money in micro-units, + * where 1,000,000 micro-units equal one unit of the currency. + */ + val priceAmountMicros: Long, + + /** + * ISO 4217 currency code for price. + */ + val priceCurrencyCode: String, + + /** + * Formatted price for the payment, including its currency sign. + */ + val formattedPrice: String, +) { + /** + * True, if the price is zero. False otherwise. + */ + val isFree: Boolean = priceAmountMicros == 0L + + /** + * [Currency] object from the [priceCurrencyCode]. Null if failed to parse. + */ + val currency: Currency? = try { + Currency.getInstance(priceCurrencyCode) + } catch (_: IllegalArgumentException) { + null + } + + /** + * Price currency symbol. Null if failed to parse. + */ + val currencySymbol: String? = currency?.symbol +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductPricingPhase.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductPricingPhase.kt new file mode 100644 index 000000000..5cc444ed7 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductPricingPhase.kt @@ -0,0 +1,124 @@ +package com.qonversion.android.sdk.dto.products + +import com.android.billingclient.api.ProductDetails.PricingPhase + +/** + * This class represents a pricing phase, describing how a user pays at a point in time. + */ +data class QProductPricingPhase( + /** + * Original [PricingPhase] received from Google Play Billing Library + */ + val originalPricingPhase: PricingPhase +) { + /** + * Price for the current phase. + */ + val price = QProductPrice( + originalPricingPhase.priceAmountMicros, + originalPricingPhase.priceCurrencyCode, + originalPricingPhase.formattedPrice + ) + + /** + * The billing period for which the given price applies. + */ + val billingPeriod: QSubscriptionPeriod = QSubscriptionPeriod.from(originalPricingPhase.billingPeriod) + + /** + * Number of cycles for which the billing period is applied. + */ + val billingCycleCount: Int = originalPricingPhase.billingCycleCount + + /** + * Recurrence mode for the pricing phase. + */ + val recurrenceMode: RecurrenceMode = RecurrenceMode.from(originalPricingPhase.recurrenceMode) + + /** + * Type of the pricing phase. + */ + val type: Type = when { + recurrenceMode != RecurrenceMode.FiniteRecurring -> Type.Regular + price.isFree -> Type.FreeTrial + billingCycleCount == 1 -> Type.DiscountedSinglePayment + billingCycleCount > 1 -> Type.DiscountedRecurringPayment + else -> Type.Unknown + } + + /** + * True, if the current phase is a trial period. False otherwise. + */ + val isTrial: Boolean = type == Type.FreeTrial + + /** + * True, if the current phase is an intro period. False otherwise. + * The intro phase is one of single or recurrent discounted payments. + */ + val isIntro: Boolean = type == Type.DiscountedSinglePayment || type == Type.DiscountedRecurringPayment + + /** + * True, if the current phase represents the base plan. False otherwise. + */ + val isBasePlan: Boolean = type == Type.Regular + + /** + * Recurrence mode of the pricing phase. + */ + enum class RecurrenceMode(private val code: Int) { + /** + * The billing plan payment recurs for infinite billing periods unless canceled. + */ + InfiniteRecurring(1), + + /** + * The billing plan payment recurs for a fixed number of billing periods + * set in [billingCycleCount]. + */ + FiniteRecurring(2), + + /** + * The billing plan payment is a one-time charge that does not repeat. + */ + NonRecurring(3), + + /** + * Unknown recurrence mode. + */ + Unknown(-1); + + companion object { + fun from(code: Int) = values().find { it.code == code } ?: Unknown + } + } + + /** + * Type of the pricing phase. + */ + enum class Type { + /** + * Regular subscription without any discounts like trial or intro offers. + */ + Regular, + + /** + * A free phase. + */ + FreeTrial, + + /** + * A phase with a discounted payment for a single period. + */ + DiscountedSinglePayment, + + /** + * A phase with a discounted payment for several periods, described in [billingCycleCount]. + */ + DiscountedRecurringPayment, + + /** + * Unknown pricing phase type + */ + Unknown; + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductStoreDetails.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductStoreDetails.kt new file mode 100644 index 000000000..173ca52f5 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductStoreDetails.kt @@ -0,0 +1,140 @@ +package com.qonversion.android.sdk.dto.products + +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.ProductDetails +import com.qonversion.android.sdk.internal.billing.pricePerMaxDuration + +/** + * This class contains all the information about the concrete Google product, + * either subscription or in-app. In case of a subscription also determines concrete base plan. + */ +data class QProductStoreDetails( + /** + * Original [ProductDetails] received from Google Play Billing Library. + */ + val originalProductDetails: ProductDetails, + + /** + * Identifier of the base plan to which these details relate. + * Null for in-app products. + */ + val basePlanId: String?, +) { + /** + * Identifier of the subscription or the in-app product. + */ + val productId: String = originalProductDetails.productId + + /** + * Name of the subscription or the in-app product. + */ + val name: String = originalProductDetails.name + + /** + * Title of the subscription or the in-app product. + * The title includes the name of the app. + */ + val title: String = originalProductDetails.title + + /** + * Description of the subscription or the in-app product. + */ + val description: String = originalProductDetails.description + + /** + * Offer details for the subscription. + * Offer details contain all the available variations of purchase offers, + * including both base plan and eligible base plan + offer combinations + * from Google Play Console for current [basePlanId]. + * Null for in-app products. + */ + val subscriptionOfferDetails: List? = + originalProductDetails.subscriptionOfferDetails + ?.filter { it.basePlanId == basePlanId } + ?.map { QProductOfferDetails(it) } + + /** + * The most profitable subscription offer for the client in our opinion from all the available offers. + * We calculate the cheapest price for the client by comparing all the trial or intro phases + * and the base plan. + */ + val defaultSubscriptionOfferDetails: QProductOfferDetails? = + subscriptionOfferDetails?.minByOrNull { it.pricePerMaxDuration } + + /** + * Subscription offer details containing only the base plan without any offer. + */ + val basePlanSubscriptionOfferDetails: QProductOfferDetails? = + subscriptionOfferDetails?.firstOrNull { it.pricingPhases.size == 1 } + + /** + * Offer details for the in-app product. + * Null for subscriptions. + */ + val inAppOfferDetails: QProductInAppDetails? = + originalProductDetails.oneTimePurchaseOfferDetails?.let { + QProductInAppDetails(it) + } + + /** + * True, if there is any eligible offer with a trial + * for this subscription and base plan combination. + * False otherwise or for an in-app product. + */ + val hasTrialOffer: Boolean = subscriptionOfferDetails?.any { it.hasTrial } ?: false + + /** + * True, if there is any eligible offer with an intro price + * for this subscription and base plan combination. + * False otherwise or for an in-app product. + */ + val hasIntroOffer: Boolean = subscriptionOfferDetails?.any { it.hasIntro } ?: false + + /** + * True, if there is any eligible offer with a trial or an intro price + * for this subscription and base plan combination. + * False otherwise or for an in-app product. + */ + val hasTrialOrIntroOffer: Boolean = subscriptionOfferDetails?.any { it.hasTrialOrIntro } ?: false + + /** + * The calculated type of the current product. + */ + val productType: QProductType = when (originalProductDetails.productType) { + ProductType.SUBS -> defaultSubscriptionOfferDetails?.let { + when { + it.hasTrial -> QProductType.Trial + it.hasIntro -> QProductType.Intro + else -> QProductType.Subscription + } + } ?: QProductType.Unknown + ProductType.INAPP -> QProductType.InApp + else -> QProductType.Unknown + } + + /** + * True, if the product type is InApp. + */ + val isInApp: Boolean = productType == QProductType.InApp + + /** + * True, if the product type is Subscription. + */ + val isSubscription: Boolean = productType == QProductType.Trial || productType == QProductType.Subscription + + /** + * True, if the subscription product is prepaid, which means that users pay in advance - + * they will need to make a new payment to extend their plan. + */ + val isPrepaid: Boolean = isSubscription && + basePlanSubscriptionOfferDetails?.basePlan?.recurrenceMode == QProductPricingPhase.RecurrenceMode.NonRecurring + + /** + * Find an offer with the specified id. + * @param offerId identifier of the searching offer. + * @return found offer or null, if an offer with the specified id doesn't exist. + */ + fun findOffer(offerId: String): QProductOfferDetails? { + return subscriptionOfferDetails?.find { it.offerId == offerId } + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductType.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductType.kt index 669c6d712..9ac0f5acf 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductType.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QProductType.kt @@ -1,9 +1,11 @@ package com.qonversion.android.sdk.dto.products -enum class QProductType(val type: Int) { - Trial(0), - Subscription(1), - InApp(2); +enum class QProductType { + Unknown, + Trial, + Intro, + Subscription, + InApp; companion object { fun fromType(type: Int): QProductType { diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QSubscriptionPeriod.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QSubscriptionPeriod.kt new file mode 100644 index 000000000..943e55ec2 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QSubscriptionPeriod.kt @@ -0,0 +1,55 @@ +package com.qonversion.android.sdk.dto.products + +/** + * A class describing a subscription period + */ +data class QSubscriptionPeriod( + /** + * A count of subsequent intervals. + */ + val unitCount: Int, + + /** + * Interval unit. + */ + val unit: Unit, + + /** + * ISO 8601 representation of the period, e.g. "P7D", meaning 7 days period. + */ + val iso: String +) { + companion object { + fun from(isoPeriod: String): QSubscriptionPeriod { + fun String.toPeriodCount() = takeIf { isNotEmpty() } + ?.substring(0, length - 1) + ?.toIntOrNull() ?: 0 + + val regex = "^P(?!\$)(\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?\$".toRegex() + val parts = regex.matchEntire(isoPeriod) + ?: return QSubscriptionPeriod(0, Unit.Unknown, isoPeriod) + + val (sYear, sMonth, sWeek, sDay) = parts.destructured + val year = sYear.toPeriodCount() + val month = sMonth.toPeriodCount() + val week = sWeek.toPeriodCount() + val day = sDay.toPeriodCount() + + return when { + year > 0 -> QSubscriptionPeriod(year, Unit.Year, isoPeriod) + month > 0 -> QSubscriptionPeriod(month, Unit.Month, isoPeriod) + week > 0 -> QSubscriptionPeriod(week, Unit.Week, isoPeriod) + day > 0 -> QSubscriptionPeriod(day, Unit.Day, isoPeriod) + else -> QSubscriptionPeriod(0, Unit.Unknown, isoPeriod) + } + } + } + + enum class Unit { + Day, + Week, + Month, + Year, + Unknown, + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QTrialDuration.kt b/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QTrialDuration.kt deleted file mode 100644 index 87e46fb64..000000000 --- a/sdk/src/main/java/com/qonversion/android/sdk/dto/products/QTrialDuration.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.qonversion.android.sdk.dto.products - -enum class QTrialDuration(val type: Int) { - NotAvailable(-1), - Unknown(0), - ThreeDays(1), - Week(2), - TwoWeeks(3), - Month(4), - TwoMonths(5), - ThreeMonths(6), - SixMonths(7), - Year(8), - Other(9); - - companion object { - fun fromType(type: Int): QTrialDuration { - return when (type) { - -1 -> NotAvailable - 0 -> Unknown - 1 -> ThreeDays - 2 -> Week - 3 -> TwoWeeks - 4 -> Month - 5 -> TwoMonths - 6 -> ThreeMonths - 7 -> SixMonths - 8 -> Year - 9 -> Other - else -> Other - } - } - } -} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/Consumer.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/Consumer.kt deleted file mode 100644 index 4666c4511..000000000 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/Consumer.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.qonversion.android.sdk.internal - -import com.android.billingclient.api.BillingClient -import com.android.billingclient.api.Purchase -import com.android.billingclient.api.* -import com.qonversion.android.sdk.internal.billing.BillingService -import com.qonversion.android.sdk.internal.billing.sku -import com.qonversion.android.sdk.internal.purchase.PurchaseHistory - -internal class Consumer internal constructor( - private val billingService: BillingService, - private val isAnalyticsMode: Boolean -) { - fun consumePurchases( - purchases: List, - @Suppress("DEPRECATION") skuDetails: Map - ) { - if (isAnalyticsMode) { - return - } - - purchases.forEach { purchase -> - val skuDetail = skuDetails[purchase.sku] - skuDetail?.let { sku -> - if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - consume(sku.type, purchase.purchaseToken, purchase.isAcknowledged) - } - } - } - } - - fun consumeHistoryRecords(records: List) { - if (isAnalyticsMode) { - return - } - - records.forEach { record -> - consume(record.type, record.historyRecord.purchaseToken, false) - } - } - - private fun consume(type: String, purchaseToken: String, isAcknowledged: Boolean) { - @Suppress("DEPRECATION") - if (type == BillingClient.SkuType.INAPP) { - billingService.consume(purchaseToken) - } else if (type == BillingClient.SkuType.SUBS && !isAcknowledged) { - billingService.acknowledge(purchaseToken) - } - } -} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt index 784201c8a..4e9315f62 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt @@ -4,10 +4,7 @@ import android.app.Activity import android.app.Application import android.content.pm.PackageManager import android.os.Build -import android.util.Pair -import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.Purchase -import com.android.billingclient.api.* import com.qonversion.android.sdk.dto.entitlements.QEntitlement import com.qonversion.android.sdk.listeners.QonversionEligibilityCallback import com.qonversion.android.sdk.dto.QonversionError @@ -16,12 +13,7 @@ import com.qonversion.android.sdk.listeners.QonversionLaunchCallback import com.qonversion.android.sdk.listeners.QonversionOfferingsCallback import com.qonversion.android.sdk.listeners.QonversionEntitlementsCallback import com.qonversion.android.sdk.listeners.QonversionProductsCallback -import com.qonversion.android.sdk.internal.LoadStoreProductsState.NotStartedYet -import com.qonversion.android.sdk.internal.LoadStoreProductsState.Loaded -import com.qonversion.android.sdk.internal.LoadStoreProductsState.Failed -import com.qonversion.android.sdk.internal.LoadStoreProductsState.Loading import com.qonversion.android.sdk.internal.billing.QonversionBillingService -import com.qonversion.android.sdk.internal.converter.GoogleBillingPeriodConverter import com.qonversion.android.sdk.internal.converter.GooglePurchaseConverter import com.qonversion.android.sdk.internal.converter.PurchaseConverter import com.qonversion.android.sdk.internal.dto.QLaunchResult @@ -29,15 +21,20 @@ import com.qonversion.android.sdk.internal.dto.QPermission import com.qonversion.android.sdk.dto.entitlements.QEntitlementSource import com.qonversion.android.sdk.dto.QUser import com.qonversion.android.sdk.dto.eligibility.QEligibility +import com.qonversion.android.sdk.dto.eligibility.QIntroEligibilityStatus import com.qonversion.android.sdk.dto.entitlements.QEntitlementGrantType +import com.qonversion.android.sdk.dto.entitlements.QEntitlementsCacheLifetime import com.qonversion.android.sdk.dto.offerings.QOfferings import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.dto.products.QProductType import com.qonversion.android.sdk.internal.dto.QProductRenewState import com.qonversion.android.sdk.internal.billing.BillingError import com.qonversion.android.sdk.internal.billing.BillingService -import com.qonversion.android.sdk.internal.billing.sku +import com.qonversion.android.sdk.internal.billing.productId +import com.qonversion.android.sdk.internal.dto.QStoreProductType +import com.qonversion.android.sdk.internal.dto.purchase.PurchaseModelInternal +import com.qonversion.android.sdk.internal.dto.purchase.PurchaseModelInternalEnriched import com.qonversion.android.sdk.internal.dto.request.data.InitRequestData -import com.qonversion.android.sdk.internal.extractor.SkuDetailsTokenExtractor import com.qonversion.android.sdk.internal.logger.Logger import com.qonversion.android.sdk.internal.provider.AppStateProvider import com.qonversion.android.sdk.internal.provider.UserStateProvider @@ -48,6 +45,7 @@ import com.qonversion.android.sdk.internal.storage.LaunchResultCacheWrapper import com.qonversion.android.sdk.internal.storage.PurchasesCache import com.qonversion.android.sdk.listeners.QEntitlementsUpdateListener import com.qonversion.android.sdk.listeners.QonversionUserCallback +import kotlin.math.min import java.util.Date @SuppressWarnings("LongParameterList") @@ -76,11 +74,6 @@ internal class QProductCenterManager internal constructor( private var isRestoreInProgress = false - private var loadProductsState = NotStartedYet - - @Suppress("DEPRECATION") - private var skuDetails = mapOf() - private var launchError: QonversionError? = null private var productsCallbacks = mutableListOf() @@ -96,20 +89,13 @@ internal class QProductCenterManager internal constructor( private var advertisingID: String? = null private var pendingInitRequestData: InitRequestData? = null - @Suppress("DEPRECATION") - private var converter: PurchaseConverter> = - GooglePurchaseConverter(SkuDetailsTokenExtractor()) + private var converter: PurchaseConverter = GooglePurchaseConverter() @Volatile lateinit var billingService: BillingService @Synchronized set @Synchronized get - @Volatile - lateinit var consumer: Consumer - @Synchronized set - @Synchronized get - init { val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.packageManager.getPackageInfo( @@ -131,9 +117,7 @@ internal class QProductCenterManager internal constructor( processPendingInitIfAvailable() } - fun launch( - callback: QonversionLaunchCallback? = null - ) { + fun launch(callback: QonversionLaunchCallback? = null) { val launchCallback: QonversionLaunchCallback = getLaunchCallback(callback) launchError = null launchResultCache.resetSessionCache() @@ -155,16 +139,15 @@ internal class QProductCenterManager internal constructor( } } - fun loadProducts( - callback: QonversionProductsCallback - ) { + fun loadProducts(callback: QonversionProductsCallback) { productsCallbacks.add(callback) - val isProductsLoaded = loadProductsState in listOf(Loaded, Failed) - if (!isProductsLoaded || !isLaunchingFinished) { + if (!isLaunchingFinished) { return } - retryLaunchForProducts { executeProductsBlocks() } + launchResultCache.sessionLaunchResult?.let { + loadStoreProductsIfPossible() + } ?: launch() } fun offerings( @@ -246,19 +229,17 @@ internal class QProductCenterManager internal constructor( ) { loadProducts(object : QonversionProductsCallback { override fun onSuccess(products: Map) { - val storeIds = products.map { it.value.skuDetail?.sku }.filterNotNull() - repository.eligibilityForProductIds( - storeIds, - installDate, - object : QonversionEligibilityCallback { - override fun onSuccess(eligibilities: Map) { - val resultForRequiredProductIds = - eligibilities.filter { it.key in productIds } - callback.onSuccess(resultForRequiredProductIds) - } + val result = products.filter { it.key in productIds }.mapValues { + val product = it.value + + if (product.storeDetails?.isPrepaid == true) { + QEligibility(QIntroEligibilityStatus.NonIntroProduct) + } else { + QEligibility(QIntroEligibilityStatus.fromProductType(product.type)) + } + } - override fun onError(error: QonversionError) = callback.onError(error) - }) + callback.onSuccess(result) } override fun onError(error: QonversionError) = callback.onError(error) @@ -270,7 +251,7 @@ internal class QProductCenterManager internal constructor( if (offerings != null) { offerings.availableOfferings.forEach { offering -> - addSkuDetailForProducts(offering.products) + billingService.enrichStoreData(offering.products) } callback.onSuccess(offerings) } else { @@ -283,143 +264,65 @@ internal class QProductCenterManager internal constructor( return launchResultCache.getLaunchResult()?.offerings } - private fun addSkuDetailForProducts(products: Collection) { - products.forEach { product -> - product.skuDetail = skuDetails[product.storeID] - } - } - fun purchaseProduct( context: Activity, - product: QProduct, - oldProductId: String?, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int?, + purchaseModel: PurchaseModelInternal, callback: QonversionEntitlementsCallback ) { - purchaseProduct( - context, - product.qonversionID, - oldProductId, - prorationMode, - callback - ) - } + fun tryToPurchase() { + tryToPurchase(context, purchaseModel, callback) + } - fun purchaseProduct( - context: Activity, - productId: String, - oldProductId: String?, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int?, - callback: QonversionEntitlementsCallback - ) { if (launchError != null) { retryLaunch( - onSuccess = { - tryToPurchase( - context, - productId, - oldProductId, - prorationMode, - callback - ) - }, - onError = { - tryToPurchase( - context, - productId, - oldProductId, - prorationMode, - callback - ) - } + onSuccess = { tryToPurchase() }, + onError = { tryToPurchase() } ) } else { - tryToPurchase(context, productId, oldProductId, prorationMode, callback) + tryToPurchase() } } private fun tryToPurchase( context: Activity, - id: String, - oldProductId: String?, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int?, + purchaseModel: PurchaseModelInternal, callback: QonversionEntitlementsCallback ) { - when (loadProductsState) { - Loading, NotStartedYet -> { - productsCallbacks.add(object : QonversionProductsCallback { - override fun onSuccess(products: Map) = - processPurchase( - context, - id, - oldProductId, - prorationMode, - callback - ) - - override fun onError(error: QonversionError) = callback.onError(error) - }) - } - Loaded, Failed -> { - processPurchase(context, id, oldProductId, prorationMode, callback) - } + val launchResult = launchResultCache.getLaunchResult() ?: run { + callback.onError(launchError ?: QonversionError(QonversionErrorCode.LaunchError)) + return } + + val product: QProduct = getProductForPurchase(purchaseModel.productId, launchResult) ?: run { + callback.onError(QonversionError(QonversionErrorCode.ProductNotFound)) + return + } + val oldProduct: QProduct? = getProductForPurchase(purchaseModel.oldProductId, launchResult) + val purchaseModelEnriched = purchaseModel.enrich(product, oldProduct) + processPurchase(context, purchaseModelEnriched, callback) } private fun processPurchase( context: Activity, - productId: String, - oldProductId: String?, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int?, + purchaseModel: PurchaseModelInternalEnriched, callback: QonversionEntitlementsCallback ) { - val launchResult = launchResultCache.getLaunchResult() ?: run { - callback.onError(launchError ?: QonversionError(QonversionErrorCode.LaunchError)) - return - } - - val product: QProduct? = getProductForPurchase(productId, launchResult) - val oldProduct: QProduct? = launchResult.products[oldProductId] - - if (product?.storeID == null) { + if (purchaseModel.product.storeID == null) { callback.onError(QonversionError(QonversionErrorCode.ProductNotFound)) return } - val purchasingCallback = purchasingCallbacks[product.storeID] + val purchasingCallback = purchasingCallbacks[purchaseModel.product.storeID] purchasingCallback?.let { logger.release( - "purchaseProduct() -> Purchase with id = " + - "$productId is already in progress. This one call will be ignored" + "purchaseProduct() -> Purchase of the product " + + "${purchaseModel.product.qonversionID} is already in progress. This call will be ignored" ) return } - val skuDetail = skuDetails[product.storeID] - val oldSkuDetail = skuDetails[oldProduct?.storeID] - if (skuDetail != null) { - purchasingCallbacks[product.storeID] = callback - billingService.purchase(context, skuDetail, oldSkuDetail, prorationMode) - } else { - if (loadProductsState == Loaded) { - val error = QonversionError(QonversionErrorCode.SkuDetailsError) - callback.onError(error) - return - } - - loadStoreProductsIfPossible( - onLoadCompleted = { skuDetailsList -> - val sku = skuDetailsList.find { detail -> - detail.sku == product.storeID - } - if (sku != null) { - purchasingCallbacks[product.storeID] = callback - billingService.purchase(context, sku) - } - }, onLoadFailed = { error -> - callback.onError(error) - }) - } + purchasingCallbacks[purchaseModel.product.storeID] = callback + billingService.purchase(context, purchaseModel) } private fun getProductForPurchase( @@ -448,33 +351,27 @@ internal class QProductCenterManager internal constructor( isRestoreInProgress = true billingService.queryPurchasesHistory( - onQueryHistoryCompleted = { historyRecords -> - consumer.consumeHistoryRecords(historyRecords) - val skuIds = historyRecords.mapNotNull { it.historyRecord.sku } - val loadedSkuDetails = - skuDetails.filter { skuIds.contains(it.value.sku) }.toMutableMap() - val resultSkuIds = (skuIds - loadedSkuDetails.keys).toSet() - - if (resultSkuIds.isNotEmpty()) { - billingService.loadProducts(resultSkuIds, onLoadCompleted = { - it.forEach { singleSkuDetails -> - run { - loadedSkuDetails[singleSkuDetails.sku] = singleSkuDetails - skuDetails = skuDetails + (singleSkuDetails.sku to singleSkuDetails) - } - } + onFailed = { executeRestoreBlocksOnError(it.toQonversionError()) } + ) { historyRecords -> + billingService.consumeHistoryRecords(historyRecords) + repository.restore( + installDate, + historyRecords, + object : QonversionLaunchCallback { + override fun onSuccess(launchResult: QLaunchResult) { + updateLaunchResult(launchResult) + executeRestoreBlocksOnSuccess(launchResult.permissions.toEntitlementsMap()) + } - processRestore(historyRecords, loadedSkuDetails) - }, onLoadFailed = { - processRestore(historyRecords, loadedSkuDetails) - }) - } else { - processRestore(historyRecords, loadedSkuDetails) - } - }, - onQueryHistoryFailed = { - executeRestoreBlocksOnError(it.toQonversionError()) - }) + override fun onError(error: QonversionError, httpCode: Int?) { + if (shouldCalculatePermissionsLocally(error, httpCode)) { + calculateRestorePermissionsLocally(historyRecords, error) + } else { + executeRestoreBlocksOnError(error) + } + } + }) + } } fun syncPurchases() { @@ -485,11 +382,11 @@ internal class QProductCenterManager internal constructor( handlePurchases(purchases) } - override fun onPurchasesFailed(purchases: List, error: BillingError) { + override fun onPurchasesFailed(error: BillingError, purchases: List) { if (purchases.isNotEmpty()) { purchases.forEach { purchase -> - val purchaseCallback = purchasingCallbacks[purchase.sku] - purchasingCallbacks.remove(purchase.sku) + val purchaseCallback = purchasingCallbacks[purchase.productId] + purchasingCallbacks.remove(purchase.productId) purchaseCallback?.onError(error.toQonversionError()) } } else { @@ -502,34 +399,6 @@ internal class QProductCenterManager internal constructor( // Private functions - private fun processRestore( - purchaseHistoryRecords: List, - @Suppress("DEPRECATION") loadedSkuDetails: Map - ) { - purchaseHistoryRecords.forEach { purchaseHistory -> - val skuDetails = loadedSkuDetails[purchaseHistory.historyRecord.sku] - purchaseHistory.skuDetails = skuDetails - } - - repository.restore( - installDate, - purchaseHistoryRecords, - object : QonversionLaunchCallback { - override fun onSuccess(launchResult: QLaunchResult) { - updateLaunchResult(launchResult) - executeRestoreBlocksOnSuccess(launchResult.permissions.toEntitlementsMap()) - } - - override fun onError(error: QonversionError, httpCode: Int?) { - if (shouldCalculatePermissionsLocally(error, httpCode)) { - calculateRestorePermissionsLocally(purchaseHistoryRecords, error) - } else { - executeRestoreBlocksOnError(error) - } - } - }) - } - private fun calculateRestorePermissionsLocally( purchaseHistoryRecords: List, restoreError: QonversionError @@ -542,10 +411,6 @@ internal class QProductCenterManager internal constructor( } launchResultCache.productPermissions?.let { - if (launchResult.products.values.all { product -> product.skuDetail == null }) { - addSkuDetailForProducts(launchResult.products.values) - } - val permissions = grantPermissionsAfterFailedRestore( purchaseHistoryRecords, launchResult.products.values, @@ -570,12 +435,8 @@ internal class QProductCenterManager internal constructor( } launchResultCache.productPermissions?.let { - if (launchResult.products.values.all { product -> product.skuDetail == null }) { - addSkuDetailForProducts(launchResult.products.values) - } - val purchasedProduct = launchResult.products.values.find { product -> - product.skuDetail?.sku == purchase.sku + product.storeID == purchase.productId } ?: run { failLocallyGrantingPurchasePermissionsWithError(purchaseCallback, purchaseError) return @@ -623,17 +484,25 @@ internal class QProductCenterManager internal constructor( productPermissions: Map> ): Map { val newPermissions = historyRecords - .filter { it.skuDetails != null } - .mapNotNull { record -> - val product = products.find { it.skuDetail?.sku === record.skuDetails?.sku } - product?.let { - productPermissions[product.qonversionID]?.map { - createPermission(it, record.historyRecord.purchaseTime, product) + .asSequence() + .flatMap { record -> + products + .filter { it.storeID == record.historyRecord.productId } + .flatMap { + val permissionIds = productPermissions[it.qonversionID] ?: emptyList() + permissionIds.map { permissionId -> + createPermission( + permissionId, + record.historyRecord.purchaseTime, + it + ) + } } - } } - .flatten() .filterNotNull() + .groupBy { it.permissionID } // handling the case when the same permission is granted for several products + .map { it.value.first() } + .toList() return mergeManuallyCreatedPermissions(newPermissions) } @@ -643,9 +512,14 @@ internal class QProductCenterManager internal constructor( purchaseTime: Long, purchasedProduct: QProduct ): QPermission? { - val purchaseDurationInDays = GoogleBillingPeriodConverter.convertPeriodToDays( - purchasedProduct.skuDetail?.subscriptionPeriod - ) + val purchaseDurationInDays = if (purchasedProduct.type === QProductType.InApp) { + null + } else { + min( + internalConfig.cacheConfig.entitlementsCacheLifetime.days, + QEntitlementsCacheLifetime.Year.days + ) + } val expirationDate = purchaseDurationInDays?.let { Date(purchaseTime + it.daysToMs) } return if (expirationDate == null || Date() < expirationDate) { @@ -725,46 +599,36 @@ internal class QProductCenterManager internal constructor( private fun continueLaunchWithPurchasesInfo( callback: QonversionLaunchCallback? ) { + fun processInitDefault() { + val initRequestData = + InitRequestData(installDate, advertisingID, callback = callback) + processInit(initRequestData) + } + billingService.queryPurchases( - onQueryCompleted = { purchases -> - if (purchases.isEmpty()) { - val initRequestData = - InitRequestData(installDate, advertisingID, callback = callback) - processInit(initRequestData) - return@queryPurchases - } + onFailed = { processInitDefault() } + ) { purchases -> + if (purchases.isEmpty()) { + processInitDefault() + return@queryPurchases + } - val completedPurchases = - purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } - billingService.getSkuDetailsFromPurchases( - completedPurchases, - onCompleted = { skuDetails -> - val skuDetailsMap = skuDetails.associateBy { it.sku } - val purchasesInfo = - converter.convertPurchases(skuDetailsMap, completedPurchases) - - val handledPurchasesCallback = - getWrappedPurchasesCallback(completedPurchases, callback) - - val initRequestData = InitRequestData( - installDate, - advertisingID, - purchasesInfo, - handledPurchasesCallback - ) - processInit(initRequestData) - }, - onFailed = { - val initRequestData = - InitRequestData(installDate, advertisingID, callback = callback) - processInit(initRequestData) - }) - }, - onQueryFailed = { - val initRequestData = - InitRequestData(installDate, advertisingID, callback = callback) - processInit(initRequestData) - }) + val completedPurchases = + purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } + + val purchasesInfo = converter.convertPurchases(completedPurchases) + + val handledPurchasesCallback = + getWrappedPurchasesCallback(completedPurchases, callback) + + val initRequestData = InitRequestData( + installDate, + advertisingID, + purchasesInfo, + handledPurchasesCallback + ) + processInit(initRequestData) + } } private fun getWrappedPurchasesCallback( @@ -844,54 +708,18 @@ internal class QProductCenterManager internal constructor( launchResultCache.save(launchResult) } - private fun loadStoreProductsIfPossible( - @Suppress("DEPRECATION") onLoadCompleted: ((products: List) -> Unit)? = null, - onLoadFailed: ((error: QonversionError) -> Unit)? = null - ) { - when (loadProductsState) { - Loading -> return - Loaded -> { - executeProductsBlocks() - onLoadCompleted?.let { skuDetails.values.toList() } - return - } - else -> Unit - } - + private fun loadStoreProductsIfPossible() { val launchResult = launchResultCache.getLaunchResult() ?: run { - loadProductsState = Failed val error = launchError ?: QonversionError(QonversionErrorCode.LaunchError) executeProductsBlocks(error) - onLoadFailed?.let { it(error) } return } - val productStoreIds = launchResult.products.values.mapNotNull { - it.storeID - }.toSet() - - if (productStoreIds.isNotEmpty()) { - loadProductsState = Loading - billingService.loadProducts(productStoreIds, - onLoadCompleted = { details -> - val skuDetailsMap = details.associateBy { it.sku } - skuDetails = skuDetailsMap.toMutableMap() - - loadProductsState = Loaded - - executeProductsBlocks() - - onLoadCompleted?.let { it(details) } - }, - onLoadFailed = { error -> - loadProductsState = Failed - executeProductsBlocks(error.toQonversionError()) - onLoadFailed?.let { it(error.toQonversionError()) } - }) - } else { + billingService.enrichStoreDataAsync( + launchResult.products.values.toList(), + { error -> executeProductsBlocks(error.toQonversionError()) } + ) { executeProductsBlocks() - @Suppress("DEPRECATION") - onLoadCompleted?.let { listOf() } } } @@ -940,19 +768,20 @@ internal class QProductCenterManager internal constructor( productsCallbacks.clear() loadStoreProductsError?.let { - handleFailureProducts(callbacks, it) + fireProductsFailure(callbacks, it) return } val launchResult = launchResultCache.getLaunchResult() ?: run { - handleFailureProducts(callbacks, launchError) + val error = launchError ?: QonversionError(QonversionErrorCode.LaunchError) + fireProductsFailure(callbacks, error) return } - addSkuDetailForProducts(launchResult.products.values) - - callbacks.forEach { - it.onSuccess(launchResult.products) + val products = launchResult.products.values.toList() + billingService.enrichStoreData(products) + callbacks.forEach { callback -> + callback.onSuccess(products.associateBy { it.qonversionID }) } } @@ -1004,18 +833,6 @@ internal class QProductCenterManager internal constructor( handlePendingRequests(error) } - private fun retryLaunchForProducts(onCompleted: () -> Unit) { - launchResultCache.sessionLaunchResult?.let { - handleLoadStateForProducts(onCompleted) - } ?: retryLaunch( - onSuccess = { - handleLoadStateForProducts(onCompleted) - }, - onError = { - handleLoadStateForProducts(onCompleted) - }) - } - private fun retryLaunch( onSuccess: (QLaunchResult) -> Unit, onError: (QonversionError) -> Unit @@ -1026,20 +843,12 @@ internal class QProductCenterManager internal constructor( }) } - private fun handleLoadStateForProducts(onCompleted: () -> Unit) { - when (loadProductsState) { - Loaded -> onCompleted() - Failed -> loadStoreProductsIfPossible() - else -> Unit - } - } - - private fun handleFailureProducts( + private fun fireProductsFailure( callbacks: List, - error: QonversionError? + error: QonversionError ) { callbacks.forEach { - it.onError(error ?: QonversionError(QonversionErrorCode.LaunchError)) + it.onError(error) } } @@ -1069,20 +878,17 @@ internal class QProductCenterManager internal constructor( private fun handlePendingPurchases() { if (!isLaunchingFinished) return - billingService.queryPurchases( - onQueryCompleted = { purchases -> - handlePurchases(purchases) - }, - onQueryFailed = {} - ) + billingService.queryPurchases(onFailed = { /* do nothing */ }) { purchases -> + handlePurchases(purchases) + } } private fun handlePurchases(purchases: List) { - consumer.consumePurchases(purchases, skuDetails) + billingService.consumePurchases(purchases) purchases.forEach { purchase -> - val purchaseCallback = purchasingCallbacks[purchase.sku] - purchasingCallbacks.remove(purchase.sku) + val purchaseCallback = purchasingCallbacks[purchase.productId] + purchasingCallbacks.remove(purchase.productId) when (purchase.purchaseState) { Purchase.PurchaseState.PENDING -> { @@ -1096,54 +902,67 @@ internal class QProductCenterManager internal constructor( } if (!handledPurchasesCache.shouldHandlePurchase(purchase)) return@forEach - val skuDetail = skuDetails[purchase.sku] ?: return@forEach - - val purchaseInfo = Pair.create(skuDetail, purchase) - purchase(purchaseInfo, object : QonversionLaunchCallback { - override fun onSuccess(launchResult: QLaunchResult) { - updateLaunchResult(launchResult) - - val entitlements = launchResult.permissions.toEntitlementsMap() - purchaseCallback?.onSuccess(entitlements) ?: run { - internalConfig.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements) + val product: QProduct? = launchResultCache.getLaunchResult()?.products?.values?.find { + it.storeID == purchase.productId + } + val purchaseInfo = converter.convertPurchase(purchase) + repository.purchase( + installDate, + purchaseInfo, + product?.qonversionID, + object : QonversionLaunchCallback { + override fun onSuccess(launchResult: QLaunchResult) { + updateLaunchResult(launchResult) + + val entitlements = launchResult.permissions.toEntitlementsMap() + + purchaseCallback?.onSuccess(entitlements) ?: run { + internalConfig.entitlementsUpdateListener?.onEntitlementsUpdated( + entitlements + ) + } + handledPurchasesCache.saveHandledPurchase(purchase) } - handledPurchasesCache.saveHandledPurchase(purchase) - } - override fun onError(error: QonversionError, httpCode: Int?) { - if (shouldCalculatePermissionsLocally(error, httpCode)) { - calculatePurchasePermissionsLocally( - purchase, - purchaseCallback, - error - ) - } else { - purchaseCallback?.onError(error) + override fun onError(error: QonversionError, httpCode: Int?) { + storeFailedPurchaseIfNecessary(purchase, purchaseInfo, product) + + if (shouldCalculatePermissionsLocally(error, httpCode)) { + calculatePurchasePermissionsLocally( + purchase, + purchaseCallback, + error + ) + } else { + purchaseCallback?.onError(error) + } } - } - }) + }) } } - private fun purchase( - @Suppress("DEPRECATION") purchaseInfo: Pair, - callback: QonversionLaunchCallback + private fun storeFailedPurchaseIfNecessary( + purchase: Purchase, + purchaseInfo: com.qonversion.android.sdk.internal.purchase.Purchase, + product: QProduct? ) { - val sku = purchaseInfo.first.sku - val product: QProduct? = launchResultCache.getLaunchResult()?.products?.values?.find { it.storeID == sku } - val purchase = converter.convertPurchase(purchaseInfo) ?: run { - callback.onError( - QonversionError( - QonversionErrorCode.ProductUnavailable, - "There is no SKU for the qonversion product ${product?.qonversionID ?: ""}" - ), - null - ) + fun storePurchase() { + purchasesCache.savePurchase(purchaseInfo) + } + + if (product?.storeDetails?.isInApp == true) { + storePurchase() return } - repository.purchase(installDate, purchase, product?.qonversionID, callback) + purchase.productId?.let { + billingService.getStoreProductType( + it, + { storePurchase() }, // saving on error in order not to lose purchase + { type -> if (type === QStoreProductType.InApp) storePurchase() } + ) + } ?: storePurchase() } private fun shouldCalculatePermissionsLocally(error: QonversionError, httpCode: Int?): Boolean { diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionFactory.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionFactory.kt index b189cbf76..5cd7383a3 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionFactory.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionFactory.kt @@ -5,7 +5,9 @@ import android.os.Handler import androidx.annotation.UiThread import com.android.billingclient.api.BillingClient import com.android.billingclient.api.PurchasesUpdatedListener -import com.qonversion.android.sdk.internal.billing.BillingService +import com.qonversion.android.sdk.internal.billing.BillingClientWrapper +import com.qonversion.android.sdk.internal.billing.BillingClientHolder +import com.qonversion.android.sdk.internal.billing.LegacyBillingClientWrapper import com.qonversion.android.sdk.internal.billing.QonversionBillingService import com.qonversion.android.sdk.internal.logger.Logger import com.qonversion.android.sdk.internal.provider.AppStateProvider @@ -43,23 +45,51 @@ internal class QonversionFactory( appStateProvider, remoteConfigManager ) - val billingService = createBillingService(productCenterManager) + val billingService = createBillingService(productCenterManager, config.isAnalyticsMode) productCenterManager.billingService = billingService - productCenterManager.consumer = createConsumer(billingService, config.isAnalyticsMode) return productCenterManager } - private fun createBillingService(listener: QonversionBillingService.PurchasesListener): QonversionBillingService { - val billingService = QonversionBillingService( + private fun createBillingService( + listener: QonversionBillingService.PurchasesListener, + isAnalyticsMode: Boolean + ): QonversionBillingService { + val billingClientHolder = createBillingClientHolder() + return QonversionBillingService( Handler(context.mainLooper), listener, + logger, + isAnalyticsMode, + billingClientHolder, + createBillingClientWrapper(billingClientHolder), + createLegacyBillingClientWrapper(billingClientHolder) + ) + } + + private fun createBillingClientHolder(): BillingClientHolder { + val clientHolder = BillingClientHolder( + Handler(context.mainLooper), logger ) - billingService.billingClient = createBillingClient(billingService) - return billingService + val billingClient = createBillingClient(clientHolder) + clientHolder.setBillingClient(billingClient) + + return clientHolder + } + + private fun createLegacyBillingClientWrapper( + billingClientHolder: BillingClientHolder + ): LegacyBillingClientWrapper { + return LegacyBillingClientWrapper(billingClientHolder, logger) + } + + private fun createBillingClientWrapper( + billingClientHolder: BillingClientHolder + ): BillingClientWrapper { + return BillingClientWrapper(billingClientHolder, logger) } @UiThread @@ -69,8 +99,4 @@ internal class QonversionFactory( builder.setListener(listener) return builder.build() } - - private fun createConsumer(billingService: BillingService, isAnalyticsMode: Boolean): Consumer { - return Consumer(billingService, isAnalyticsMode) - } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt index 814547448..f6574166e 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QonversionInternal.kt @@ -5,10 +5,11 @@ import android.app.Application import android.os.Handler import android.os.Looper import androidx.lifecycle.ProcessLifecycleOwner -import com.android.billingclient.api.BillingFlowParams import com.qonversion.android.sdk.Qonversion import com.qonversion.android.sdk.automations.internal.QAutomationsManager import com.qonversion.android.sdk.dto.QAttributionProvider +import com.qonversion.android.sdk.dto.QPurchaseModel +import com.qonversion.android.sdk.dto.QPurchaseUpdateModel import com.qonversion.android.sdk.dto.entitlements.QEntitlement import com.qonversion.android.sdk.dto.QRemoteConfig import com.qonversion.android.sdk.dto.properties.QUserPropertyKey @@ -18,6 +19,7 @@ import com.qonversion.android.sdk.dto.offerings.QOfferings import com.qonversion.android.sdk.dto.products.QProduct import com.qonversion.android.sdk.internal.di.QDependencyInjector import com.qonversion.android.sdk.internal.dto.QLaunchResult +import com.qonversion.android.sdk.internal.dto.purchase.PurchaseModelInternal import com.qonversion.android.sdk.internal.logger.ConsoleLogger import com.qonversion.android.sdk.internal.logger.ExceptionManager import com.qonversion.android.sdk.internal.provider.AppStateProvider @@ -147,54 +149,26 @@ internal class QonversionInternal( }) } - override fun purchase(context: Activity, id: String, callback: QonversionEntitlementsCallback) { - productCenterManager?.purchaseProduct( - context, - id, - null, - null, - mainEntitlementsCallback(callback) - ) ?: logLaunchErrorForFunctionName(object {}.javaClass.enclosingMethod?.name) - } - - override fun purchase(context: Activity, product: QProduct, callback: QonversionEntitlementsCallback) { - productCenterManager?.purchaseProduct( - context, - product, - null, - null, - mainEntitlementsCallback(callback) - ) ?: logLaunchErrorForFunctionName(object {}.javaClass.enclosingMethod?.name) - } - - override fun updatePurchase( + override fun purchase( context: Activity, - productId: String, - oldProductId: String, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int?, + purchaseModel: QPurchaseModel, callback: QonversionEntitlementsCallback ) { productCenterManager?.purchaseProduct( context, - productId, - oldProductId, - prorationMode, + PurchaseModelInternal(purchaseModel), mainEntitlementsCallback(callback) ) ?: logLaunchErrorForFunctionName(object {}.javaClass.enclosingMethod?.name) } override fun updatePurchase( context: Activity, - product: QProduct, - oldProductId: String, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int?, + purchaseUpdateModel: QPurchaseUpdateModel, callback: QonversionEntitlementsCallback ) { productCenterManager?.purchaseProduct( context, - product, - oldProductId, - prorationMode, + PurchaseModelInternal(purchaseUpdateModel), mainEntitlementsCallback(callback) ) ?: logLaunchErrorForFunctionName(object {}.javaClass.enclosingMethod?.name) } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientHolder.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientHolder.kt new file mode 100644 index 000000000..f79ad4966 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientHolder.kt @@ -0,0 +1,86 @@ +package com.qonversion.android.sdk.internal.billing + +import android.os.Handler +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.qonversion.android.sdk.internal.logger.Logger + +internal class BillingClientHolder( + private val mainHandler: Handler, + private val logger: Logger +) : BillingClientStateListener, PurchasesUpdatedListener { + + private var billingClient: BillingClient? = null + + private var purchasesUpdatedListener: PurchasesUpdatedListener? = null + + private var connectionListener: ConnectionListener? = null + + val isConnected get() = billingClient?.isReady == true + + fun startConnection(listener: ConnectionListener) { + connectionListener = listener + + mainHandler.post { + synchronized(this@BillingClientHolder) { + billingClient?.startConnection(this) + logger.debug("startConnection() -> for $billingClient") + } + } + } + + fun withReadyClient(billingFunction: BillingClient.() -> Unit) { + billingClient.takeIf { isConnected }?.let { + it.billingFunction() + } ?: logger.debug("Connection to the BillingClient was lost") + } + + fun subscribeOnPurchasesUpdates(purchasesUpdatedListener: PurchasesUpdatedListener) { + this.purchasesUpdatedListener = purchasesUpdatedListener + } + + fun setBillingClient(billingClient: BillingClient) { + this.billingClient = billingClient + } + + override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList?) { + purchasesUpdatedListener?.onPurchasesUpdated(billingResult, purchases) + } + + override fun onBillingServiceDisconnected() { + logger.debug("onBillingServiceDisconnected() -> for $billingClient") + } + + override fun onBillingSetupFinished(billingResult: BillingResult) { + when (billingResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + logger.debug("onBillingSetupFinished() -> successfully for $billingClient.") + connectionListener?.onBillingClientConnected() + } + BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED, + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> { + logger.release("onBillingSetupFinished() -> with error: ${billingResult.getDescription()}") + val error = BillingError( + billingResult.responseCode, + "Billing is not available on this device. ${billingResult.getDescription()}" + ) + connectionListener?.onBillingClientUnavailable(error) + } + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> { + // Client is already in the process of connecting to billing service + } + else -> { + logger.release("onBillingSetupFinished with error: ${billingResult.getDescription()}") + } + } + } + + internal interface ConnectionListener { + fun onBillingClientConnected() + + fun onBillingClientUnavailable(error: BillingError) + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientWrapper.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientWrapper.kt new file mode 100644 index 000000000..eac370f26 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientWrapper.kt @@ -0,0 +1,309 @@ +package com.qonversion.android.sdk.internal.billing + +import android.app.Activity +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode +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.PurchaseHistoryRecord +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchaseHistoryParams +import com.android.billingclient.api.QueryPurchasesParams +import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.dto.products.QProductOfferDetails +import com.qonversion.android.sdk.internal.dto.QStoreProductType +import com.qonversion.android.sdk.internal.dto.ProductStoreId +import com.qonversion.android.sdk.internal.logger.Logger + +internal class BillingClientWrapper( + billingClientHolder: BillingClientHolder, + logger: Logger +) : BillingClientWrapperBase(billingClientHolder, logger), + IBillingClientWrapper { + + private var productDetails = mapOf() + + override fun withStoreDataLoaded( + storeIds: List, + onFailed: (error: BillingError) -> Unit, + onReady: () -> Unit + ) { + val productIds = storeIds.map { it.productId } + + val idsToLoad = productIds.filterNot { productDetails.containsKey(it) } + if (idsToLoad.isEmpty()) { + onReady() + return + } + + loadProducts(idsToLoad, onFailed) { details -> + val productDetailsMap = details.associateBy { it.productId } + productDetails = productDetails + productDetailsMap.toMutableMap() + + onReady() + } + } + + override fun getStoreData(storeId: ProductStoreId): ProductDetails? { + return productDetails[storeId.productId] + } + + override fun makePurchase( + activity: Activity, + product: QProduct, + offerId: String?, + applyOffer: Boolean, + updatePurchaseInfo: UpdatePurchaseInfo?, + onFailed: (error: BillingError) -> Unit + ) { + fun fireError(message: String) { + onFailed(BillingError(BillingResponseCode.ITEM_UNAVAILABLE, message)) + } + + val storeDetails = product.storeDetails ?: run { + onFailed( + BillingError( + BillingResponseCode.ITEM_UNAVAILABLE, + "Store details not found for purchase" + ) + ) + return + } + + logger.debug("makePurchase() -> Purchasing the product: ${storeDetails.productId}") + + val offerDetails: QProductOfferDetails? = when { + storeDetails.isInApp -> null + !applyOffer -> { + storeDetails.basePlanSubscriptionOfferDetails ?: run { + fireError("Failed to find base plan offer for Qonversion product ${product.qonversionID}") + return + } + } + offerId?.isNotEmpty() == true -> { + storeDetails.findOffer(offerId) ?: run { + fireError("Failed to find offer $offerId for Qonversion product ${product.qonversionID}") + return + } + } + else -> { + storeDetails.defaultSubscriptionOfferDetails ?: run { + fireError("No offer found for purchasing Qonversion subscription product ${product.qonversionID}") + return + } + } + } + + val productDetailsParamList = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(storeDetails.originalProductDetails) + .applyOffer(offerDetails) + .build() + + val params = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParamList)) + .setSubscriptionUpdateParams(updatePurchaseInfo) + .build() + + launchBillingFlow(activity, params) + } + + override fun queryPurchaseHistoryForProduct( + product: QProduct, + onCompleted: (BillingResult, PurchaseHistoryRecord?) -> Unit + ) { + val storeDetails = product.storeDetails ?: return + val productType = storeDetails.originalProductDetails.productType + + billingClientHolder.withReadyClient { + logger.debug( + "queryPurchaseHistoryForProduct() -> " + + "Querying purchase history for ${storeDetails.productId} with type $productType" + ) + + val params = QueryPurchaseHistoryParams.newBuilder() + .setProductType(productType) + .build() + queryPurchaseHistoryAsync(params) { billingResult, purchasesList -> + onCompleted( + billingResult, + purchasesList?.firstOrNull { storeDetails.productId == it.productId } + ) + } + } + } + + override fun queryPurchaseHistory( + productType: QStoreProductType, + onCompleted: (BillingResult, List?) -> Unit + ) { + billingClientHolder.withReadyClient { + val params = QueryPurchaseHistoryParams.newBuilder() + .setProductType(productType.toProductType()) + .build() + queryPurchaseHistoryAsync(params, onCompleted) + } + } + + override fun queryPurchases( + onFailed: (error: BillingError) -> Unit, + onCompleted: (purchases: List) -> Unit + ) { + billingClientHolder.withReadyClient { + val subscriptionParams = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + queryPurchasesAsync(subscriptionParams) querySubscriptions@{ subsResult, activeSubs -> + if (!subsResult.isOk) { + handlePurchasesQueryError(subsResult, "subscription", onFailed) + return@querySubscriptions + } + + val inAppParams = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.INAPP) + .build() + queryPurchasesAsync(inAppParams) queryInAppPurchases@{ inAppsResult, unconsumedInApp -> + if (!inAppsResult.isOk) { + handlePurchasesQueryError(subsResult, "in-app", onFailed) + return@queryInAppPurchases + } + + val purchasesResult = activeSubs + unconsumedInApp + onCompleted(purchasesResult) + + purchasesResult + .takeUnless { it.isEmpty() } + ?.forEach { + logger.debug("queryPurchases() -> purchases cache is retrieved ${it.getDescription()}") + } + ?: logger.release("queryPurchases() -> purchases cache is empty.") + } + } + } + } + + override fun getStoreProductType( + storeId: String, + onFailed: (error: BillingError) -> Unit, + onSuccess: (type: QStoreProductType) -> Unit + ) { + productDetails[storeId]?.let { + onSuccess(QStoreProductType.fromProductType(it.productType)) + return + } + + loadProducts(listOf(storeId), onFailed) { details -> + details.firstOrNull()?.takeIf { it.productId == storeId }?.let { + onSuccess(QStoreProductType.fromProductType(it.productType)) + } ?: onFailed( + BillingError( + BillingResponseCode.ITEM_UNAVAILABLE, + "Product not found" + ) + ) + } + } + + private fun loadProducts( + productIds: List, + onFailed: (BillingError) -> Unit, + onCompleted: (List) -> Unit + ) { + queryProductDetailsAsync( + BillingClient.ProductType.SUBS, + productIds, + { subscriptionProductDetails -> + val subscriptionProductIds = subscriptionProductDetails.map { it.productId }.toSet() + val inAppProductIds = productIds - subscriptionProductIds + + if (inAppProductIds.isNotEmpty()) { + queryProductDetailsAsync( + BillingClient.ProductType.INAPP, + inAppProductIds, + { inAppProductDetails -> + onCompleted(subscriptionProductDetails + inAppProductDetails) + }, + onFailed + ) + } else { + onCompleted(subscriptionProductDetails) + } + }, + onFailed + ) + } + + private fun queryProductDetailsAsync( + productType: String, + productIds: List, + onQuerySkuCompleted: (List) -> Unit, + onQuerySkuFailed: (BillingError) -> Unit + ) { + val productDetails = productIds.map { productId -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(productId) + .setProductType(productType) + .build() + } + val params = QueryProductDetailsParams + .newBuilder() + .setProductList(productDetails) + .build() + + billingClientHolder.withReadyClient { + queryProductDetailsAsync(params) { billingResult, productDetailsList -> + if (billingResult.isOk) { + logProductDetails(productDetailsList, productIds) + onQuerySkuCompleted(productDetailsList) + } else { + onQuerySkuFailed( + BillingError( + billingResult.responseCode, + "Failed to fetch products. ${billingResult.getDescription()}" + ) + ) + } + } + } + } + + private fun logProductDetails( + productDetailsList: List, + productIds: List + ) { + productDetailsList + .takeUnless { it.isEmpty() } + ?.forEach { + logger.debug("queryProductDetailsAsync() -> $it") + } + ?: logger.release("queryProductDetailsAsync() -> ProductDetails list for $productIds is empty.") + } + + private fun BillingFlowParams.ProductDetailsParams.Builder.applyOffer( + offer: QProductOfferDetails? + ): BillingFlowParams.ProductDetailsParams.Builder { + offer?.let { + setOfferToken(offer.offerToken) + } + return this + } + + private fun BillingFlowParams.Builder.setSubscriptionUpdateParams( + info: UpdatePurchaseInfo? = null + ): BillingFlowParams.Builder { + if (info != null) { + val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder() + updateParamsBuilder.setOldPurchaseToken(info.purchaseToken) + val updateParams = updateParamsBuilder.apply { + info.updatePolicy?.toReplacementMode()?.let { + setSubscriptionReplacementMode(it) + } + }.build() + + setSubscriptionUpdateParams(updateParams) + } + + return this + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientWrapperBase.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientWrapperBase.kt new file mode 100644 index 000000000..582d57a52 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingClientWrapperBase.kt @@ -0,0 +1,73 @@ +package com.qonversion.android.sdk.internal.billing + +import android.app.Activity +import androidx.annotation.UiThread +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ConsumeParams +import com.qonversion.android.sdk.internal.logger.Logger + +internal abstract class BillingClientWrapperBase( + protected val billingClientHolder: BillingClientHolder, + protected val logger: Logger +) { + fun consume(purchaseToken: String) { + val params = ConsumeParams.newBuilder() + .setPurchaseToken(purchaseToken) + .build() + + billingClientHolder.withReadyClient { + consumeAsync( + params + ) { billingResult, purchaseToken -> + if (!billingResult.isOk) { + val errorMessage = + "Failed to consume purchase with token $purchaseToken ${billingResult.getDescription()}" + logger.debug("consume() -> $errorMessage") + } + } + } + } + + fun acknowledge(purchaseToken: String) { + val params = AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchaseToken) + .build() + + billingClientHolder.withReadyClient { + acknowledgePurchase( + params + ) { billingResult -> + if (!billingResult.isOk) { + val errorMessage = + "Failed to acknowledge purchase with token $purchaseToken ${billingResult.getDescription()}" + logger.debug("acknowledge() -> $errorMessage") + } + } + } + } + + @UiThread + protected fun launchBillingFlow( + activity: Activity, + params: BillingFlowParams + ) = billingClientHolder.withReadyClient { + launchBillingFlow(activity, params) + .takeUnless { billingResult -> billingResult.isOk } + ?.let { billingResult -> + logger.release("launchBillingFlow() -> Failed to launch billing flow. ${billingResult.getDescription()}") + } + } + + protected fun handlePurchasesQueryError( + billingResult: BillingResult, + purchaseType: String, + onQueryFailed: (error: BillingError) -> Unit + ) { + val errorMessage = + "Failed to query $purchaseType purchases from cache: ${billingResult.getDescription()}" + onQueryFailed(BillingError(billingResult.responseCode, errorMessage)) + logger.release("queryPurchases() -> $errorMessage") + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingService.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingService.kt index 41b66a01c..687c4b6d2 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingService.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/BillingService.kt @@ -1,49 +1,44 @@ package com.qonversion.android.sdk.internal.billing import android.app.Activity -import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.Purchase -import com.android.billingclient.api.* +import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.internal.dto.QStoreProductType +import com.qonversion.android.sdk.internal.dto.purchase.PurchaseModelInternalEnriched import com.qonversion.android.sdk.internal.purchase.PurchaseHistory internal interface BillingService { - fun queryPurchasesHistory( - onQueryHistoryCompleted: (purchases: List) -> Unit, - onQueryHistoryFailed: (error: BillingError) -> Unit - ) - fun queryPurchases( - onQueryCompleted: (purchases: List) -> Unit, - onQueryFailed: (error: BillingError) -> Unit + fun enrichStoreDataAsync( + products: List, + onFailed: (error: BillingError) -> Unit, + onEnriched: (products: List) -> Unit ) - @Suppress("DEPRECATION") + fun enrichStoreData(products: List) + fun purchase( activity: Activity, - skuDetails: SkuDetails, - oldSkuDetails: SkuDetails? = null, - @BillingFlowParams.ProrationMode prorationMode: Int? = null + purchaseModel: PurchaseModelInternalEnriched, ) - @Suppress("DEPRECATION") - fun loadProducts( - productIDs: Set, - onLoadCompleted: (products: List) -> Unit, - onLoadFailed: (error: BillingError) -> Unit - ) + fun consumePurchases(purchases: List) + + fun consumeHistoryRecords(historyRecords: List) - fun consume( - purchaseToken: String + fun queryPurchasesHistory( + onFailed: (error: BillingError) -> Unit, + onCompleted: (purchases: List) -> Unit ) - fun acknowledge( - purchaseToken: String + fun queryPurchases( + onFailed: (error: BillingError) -> Unit, + onCompleted: (purchases: List) -> Unit ) - @Suppress("DEPRECATION") - fun getSkuDetailsFromPurchases( - purchases: List, - onCompleted: (List) -> Unit, - onFailed: (BillingError) -> Unit + fun getStoreProductType( + storeId: String, + onFailed: (error: BillingError) -> Unit, + onSuccess: (type: QStoreProductType) -> Unit ) } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/IBillingClientWrapper.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/IBillingClientWrapper.kt new file mode 100644 index 000000000..4bae185fd --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/IBillingClientWrapper.kt @@ -0,0 +1,53 @@ +package com.qonversion.android.sdk.internal.billing + +import android.app.Activity +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchaseHistoryRecord +import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.internal.dto.QStoreProductType + +internal interface IBillingClientWrapper { + + fun withStoreDataLoaded( + storeIds: List, + onFailed: (error: BillingError) -> Unit, + onReady: () -> Unit, + ) + + fun getStoreData(storeId: StoreId): StoreData? + + fun makePurchase( + activity: Activity, + product: QProduct, + offerId: String?, + applyOffer: Boolean, + updatePurchaseInfo: UpdatePurchaseInfo?, + onFailed: (error: BillingError) -> Unit + ) + + fun queryPurchaseHistoryForProduct( + product: QProduct, + onCompleted: (BillingResult, PurchaseHistoryRecord?) -> Unit + ) + + fun queryPurchaseHistory( + productType: QStoreProductType, + onCompleted: (BillingResult, List?) -> Unit + ) + + fun queryPurchases( + onFailed: (error: BillingError) -> Unit, + onCompleted: (purchases: List) -> Unit + ) + + fun consume(purchaseToken: String) + + fun acknowledge(purchaseToken: String) + + fun getStoreProductType( + storeId: String, + onFailed: (error: BillingError) -> Unit, + onSuccess: (type: QStoreProductType) -> Unit + ) +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/LegacyBillingClientWrapper.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/LegacyBillingClientWrapper.kt new file mode 100644 index 000000000..802f2be01 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/LegacyBillingClientWrapper.kt @@ -0,0 +1,247 @@ +package com.qonversion.android.sdk.internal.billing + +import android.app.Activity +import com.android.billingclient.api.* +import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.internal.dto.QStoreProductType +import com.qonversion.android.sdk.internal.logger.Logger + +typealias LegacyStoreId = String + +internal class LegacyBillingClientWrapper( + billingClientHolder: BillingClientHolder, + logger: Logger, +) : BillingClientWrapperBase(billingClientHolder, logger), + @Suppress("DEPRECATION") IBillingClientWrapper { + + @Suppress("DEPRECATION") + private var skuDetails = mapOf() + + override fun withStoreDataLoaded( + storeIds: List, + onFailed: (error: BillingError) -> Unit, + onReady: () -> Unit + ) { + val idsToLoad = storeIds.filterNot { skuDetails.containsKey(it) } + if (idsToLoad.isEmpty()) { + onReady() + return + } + + loadProducts(idsToLoad, onFailed) { details -> + val skuDetailsMap = details.associateBy { it.sku } + skuDetails = skuDetails + skuDetailsMap.toMutableMap() + + onReady() + } + } + + @Suppress("DEPRECATION") + override fun getStoreData(storeId: LegacyStoreId): SkuDetails? { + return skuDetails[storeId] + } + + @Suppress("DEPRECATION") + override fun makePurchase( + activity: Activity, + product: QProduct, + offerId: String?, // ignored + applyOffer: Boolean, // ignored + updatePurchaseInfo: UpdatePurchaseInfo?, + onFailed: (error: BillingError) -> Unit + ) { + val skuDetails = product.skuDetail ?: return + + logger.debug("makePurchase() -> Purchasing the sku: ${skuDetails.sku}") + + val params = BillingFlowParams.newBuilder() + .setSkuDetails(skuDetails) + .setSubscriptionUpdateParams(updatePurchaseInfo) + .build() + + launchBillingFlow(activity, params) + } + + @Suppress("DEPRECATION") + override fun queryPurchaseHistoryForProduct( + product: QProduct, + onCompleted: (BillingResult, PurchaseHistoryRecord?) -> Unit + ) { + val skuDetails = product.skuDetail ?: return + + billingClientHolder.withReadyClient { + logger.debug( + "queryPurchaseHistoryForProduct() -> " + + "Querying purchase history for ${skuDetails.sku} with type ${skuDetails.type}" + ) + + queryPurchaseHistoryAsync(skuDetails.type) { billingResult, purchasesList -> + onCompleted( + billingResult, + purchasesList?.firstOrNull { skuDetails.sku == it.skus.firstOrNull() } + ) + } + } + } + + @Suppress("DEPRECATION") + override fun queryPurchaseHistory( + productType: QStoreProductType, + onCompleted: (BillingResult, List?) -> Unit + ) { + billingClientHolder.withReadyClient { + queryPurchaseHistoryAsync(productType.toSkuType(), onCompleted) + } + } + + @Suppress("DEPRECATION") + override fun queryPurchases( + onFailed: (error: BillingError) -> Unit, + onCompleted: (purchases: List) -> Unit + ) { + billingClientHolder.withReadyClient { + queryPurchasesAsync(BillingClient.SkuType.SUBS) querySubscriptions@{ subsResult, activeSubs -> + if (!subsResult.isOk) { + handlePurchasesQueryError(subsResult, "subscription", onFailed) + return@querySubscriptions + } + + queryPurchasesAsync(BillingClient.SkuType.INAPP) queryInAppPurchases@{ inAppsResult, unconsumedInApp -> + if (!inAppsResult.isOk) { + handlePurchasesQueryError(subsResult, "in-app", onFailed) + return@queryInAppPurchases + } + + val purchasesResult = activeSubs + unconsumedInApp + onCompleted(purchasesResult) + + purchasesResult + .takeUnless { it.isEmpty() } + ?.forEach { + logger.debug("queryPurchases() -> purchases cache is retrieved ${it.getDescription()}") + } + ?: logger.release("queryPurchases() -> purchases cache is empty.") + } + } + } + } + + override fun getStoreProductType( + storeId: String, + onFailed: (error: BillingError) -> Unit, + onSuccess: (type: QStoreProductType) -> Unit + ) { + skuDetails[storeId]?.let { + onSuccess(QStoreProductType.fromSkuType(it.type)) + return + } + + loadProducts(listOf(storeId), onFailed) { details -> + details.firstOrNull()?.takeIf { it.sku == storeId }?.let { + onSuccess(QStoreProductType.fromSkuType(it.type)) + } ?: onFailed( + BillingError( + BillingClient.BillingResponseCode.ITEM_UNAVAILABLE, + "Product not found" + ) + ) + } + } + + private fun loadProducts( + productIds: List, + onQuerySkuFailed: (BillingError) -> Unit, + @Suppress("DEPRECATION") onQuerySkuCompleted: (List) -> Unit + ) { + querySkuDetailsAsync( + @Suppress("DEPRECATION") + BillingClient.SkuType.SUBS, + productIds, + { skuDetailsSubs -> + val skuSubs = skuDetailsSubs.map { it.sku }.toSet() + val skuInApp = productIds - skuSubs + + if (skuInApp.isNotEmpty()) { + querySkuDetailsAsync( + @Suppress("DEPRECATION") + BillingClient.SkuType.INAPP, + skuInApp, + { skuDetailsInApp -> + onQuerySkuCompleted(skuDetailsSubs + skuDetailsInApp) + }, + onQuerySkuFailed + ) + } else { + onQuerySkuCompleted(skuDetailsSubs) + } + }, + onQuerySkuFailed + ) + } + + private fun querySkuDetailsAsync( + @Suppress("DEPRECATION") @BillingClient.SkuType productType: String, + skuList: List, + @Suppress("DEPRECATION") onQuerySkuCompleted: (List) -> Unit, + onQuerySkuFailed: (BillingError) -> Unit + ) { + @Suppress("DEPRECATION") + val params = SkuDetailsParams.newBuilder() + .setType(productType) + .setSkusList(skuList) + .build() + + billingClientHolder.withReadyClient { + @Suppress("DEPRECATION") + querySkuDetailsAsync(params) { billingResult, skuDetailsList -> + if (billingResult.isOk && skuDetailsList != null) { + logSkuDetails(skuDetailsList, skuList) + onQuerySkuCompleted(skuDetailsList) + } else { + var errorMessage = "Failed to fetch products. " + if (skuDetailsList == null) { + errorMessage += "SkuDetails list for $skuList is null. " + } + + onQuerySkuFailed( + BillingError( + billingResult.responseCode, + "$errorMessage ${billingResult.getDescription()}" + ) + ) + } + } + } + } + + @Suppress("DEPRECATION") + private fun BillingFlowParams.Builder.setSubscriptionUpdateParams( + info: UpdatePurchaseInfo? = null + ): BillingFlowParams.Builder { + if (info != null) { + val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder() + updateParamsBuilder.setOldSkuPurchaseToken(info.purchaseToken) + val updateParams = updateParamsBuilder.apply { + info.updatePolicy?.toProrationMode()?.let { + setReplaceSkusProrationMode(it) + } + }.build() + + setSubscriptionUpdateParams(updateParams) + } + + return this + } + + private fun logSkuDetails( + @Suppress("DEPRECATION") skuDetailsList: List, + skuList: List + ) { + skuDetailsList + .takeUnless { it.isEmpty() } + ?.forEach { + logger.debug("querySkuDetailsAsync() -> $it") + } + ?: logger.release("querySkuDetailsAsync() -> SkuDetails list for $skuList is empty.") + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/QonversionBillingService.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/QonversionBillingService.kt index e222f1d5c..68975c7cf 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/QonversionBillingService.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/QonversionBillingService.kt @@ -2,9 +2,12 @@ package com.qonversion.android.sdk.internal.billing import android.app.Activity import android.os.Handler -import androidx.annotation.UiThread import com.android.billingclient.api.* -import com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams +import com.qonversion.android.sdk.dto.QPurchaseUpdatePolicy +import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.internal.dto.QStoreProductType +import com.qonversion.android.sdk.internal.dto.ProductStoreId +import com.qonversion.android.sdk.internal.dto.purchase.PurchaseModelInternalEnriched import com.qonversion.android.sdk.internal.purchase.PurchaseHistory import com.qonversion.android.sdk.internal.logger.Logger import java.util.concurrent.ConcurrentLinkedQueue @@ -12,364 +15,342 @@ import java.util.concurrent.ConcurrentLinkedQueue internal class QonversionBillingService internal constructor( private val mainHandler: Handler, private val purchasesListener: PurchasesListener, - private val logger: Logger -) : PurchasesUpdatedListener, BillingClientStateListener, BillingService { - - @Volatile - var billingClient: BillingClient? = null - @Synchronized set(value) { - field = value - startConnection() - } - @Synchronized get + private val logger: Logger, + private val isAnalyticsMode: Boolean, + private val billingClientHolder: BillingClientHolder, + private val billingClientWrapper: BillingClientWrapper, + private val legacyBillingClientWrapper: LegacyBillingClientWrapper +) : PurchasesUpdatedListener, BillingClientHolder.ConnectionListener, BillingService { private val requestsQueue = ConcurrentLinkedQueue<(billingSetupError: BillingError?) -> Unit>() interface PurchasesListener { fun onPurchasesCompleted(purchases: List) fun onPurchasesFailed( - purchases: List, - error: BillingError + error: BillingError, + purchases: List = emptyList() ) } - override fun queryPurchasesHistory( - onQueryHistoryCompleted: (purchases: List) -> Unit, - onQueryHistoryFailed: (error: BillingError) -> Unit - ) { - queryAllPurchasesHistory( - { allPurchases -> - onQueryHistoryCompleted(allPurchases) - }, - { error -> - onQueryHistoryFailed(error) - logger.release("queryPurchasesHistory() -> $error") - } - ) + init { + billingClientHolder.subscribeOnPurchasesUpdates(this) } - override fun loadProducts( - productIDs: Set, - @Suppress("DEPRECATION") onLoadCompleted: (products: List) -> Unit, - onLoadFailed: (error: BillingError) -> Unit + override fun enrichStoreDataAsync( + products: List, + onFailed: (error: BillingError) -> Unit, + onEnriched: (products: List) -> Unit ) { - loadAllProducts( - productIDs.toList(), - { allProducts -> - onLoadCompleted(allProducts) - }, - { error -> - onLoadFailed(error) - logger.release("loadProducts() -> $error") + if (!products.any { it.storeID != null }) { + onEnriched(products) + return + } + + fun fetchProductDetails() { + // Fetching ProductDetails + val actualStoreIds = products.filter { it.storeID != null } + .map { ProductStoreId( + it.storeID!!, + it.basePlanID + ) } + billingClientWrapper.withStoreDataLoaded( + actualStoreIds, + onFailed, + ) { + enrichStoreData(products) + onEnriched(products) } - ) - } + } - override fun consume( - purchaseToken: String - ) { - logger.debug("consume() -> Consuming purchase with token $purchaseToken") executeOnMainThread { billingSetupError -> - if (billingSetupError == null) { - val params = ConsumeParams.newBuilder() - .setPurchaseToken(purchaseToken) - .build() - - withReadyClient { - consumeAsync( - params - ) { billingResult, purchaseToken -> - if (!billingResult.isOk) { - val errorMessage = - "Failed to consume purchase with token $purchaseToken ${billingResult.getDescription()}" - logger.debug("consume() -> $errorMessage") - } - } - } + if (billingSetupError != null) { + logger.release("enrichStoreDataAsync() -> $billingSetupError") + onFailed(billingSetupError) + return@executeOnMainThread + } + + // Fetching legacy SkuDetails + val legacyStoreIds = products.mapNotNull { it.storeID } + legacyBillingClientWrapper.withStoreDataLoaded( + legacyStoreIds, + { fetchProductDetails() }, + ) { + fetchProductDetails() } } } - override fun acknowledge( - purchaseToken: String - ) { - logger.debug("acknowledge() -> Acknowledging purchase with token $purchaseToken") - executeOnMainThread { billingSetupError -> - if (billingSetupError == null) { - val params = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchaseToken) - .build() - - withReadyClient { - acknowledgePurchase( - params - ) { billingResult -> - if (!billingResult.isOk) { - val errorMessage = - "Failed to acknowledge purchase with token $purchaseToken ${billingResult.getDescription()}" - logger.debug("acknowledge() -> $errorMessage") - } - } + override fun enrichStoreData(products: List) { + products.forEach { product -> + product.storeID?.let { storeId -> + @Suppress("DEPRECATION") + product.skuDetail = legacyBillingClientWrapper.getStoreData(storeId) + + val productStoreId = ProductStoreId( + storeId, + product.basePlanID + ) + billingClientWrapper.getStoreData(productStoreId)?.let { storeData -> + product.setStoreProductDetails(storeData) } } } } - override fun queryPurchases( - onQueryCompleted: (purchases: List) -> Unit, - onQueryFailed: (error: BillingError) -> Unit - ) { - logger.debug("queryPurchases() -> Querying purchases from cache for subs and inapp") - executeOnMainThread { billingSetupError -> - if (billingSetupError != null) { - onQueryFailed(billingSetupError) - return@executeOnMainThread + override fun purchase(activity: Activity, purchaseModel: PurchaseModelInternalEnriched) { + fun handlePurchase() { + if (purchaseModel.oldProduct != null && purchaseModel.oldProduct.hasAnyStoreDetails) { + updatePurchase( + activity, + purchaseModel.product, + purchaseModel.offerId, + purchaseModel.applyOffer, + purchaseModel.oldProduct, + purchaseModel.updatePolicy) + } else { + makePurchase( + activity, + purchaseModel.product, + purchaseModel.offerId, + purchaseModel.applyOffer + ) } + } - withReadyClient { - @Suppress("DEPRECATION") - queryPurchasesAsync(BillingClient.SkuType.SUBS) querySubscriptions@{ subsResult, activeSubs -> - if (!subsResult.isOk) { - handlePurchasesQueryError(subsResult, "subscription", onQueryFailed) - return@querySubscriptions - } - - queryPurchasesAsync(BillingClient.SkuType.INAPP) queryInAppPurchases@{ inAppsResult, unconsumedInApp -> - if (!inAppsResult.isOk) { - handlePurchasesQueryError(subsResult, "in-app", onQueryFailed) - return@queryInAppPurchases - } + if (purchaseModel.product.hasAnyStoreDetails) { + handlePurchase() + } else { + enrichStoreDataAsync( + listOfNotNull(purchaseModel.product, purchaseModel.oldProduct), + { error -> purchasesListener.onPurchasesFailed(error) } + ) { + handlePurchase() + } + } + } - val purchasesResult = activeSubs + unconsumedInApp - onQueryCompleted(purchasesResult) + override fun consumePurchases(purchases: List) { + if (isAnalyticsMode) { + return + } - purchasesResult - .takeUnless { it.isEmpty() } - ?.forEach { - logger.debug("queryPurchases() -> purchases cache is retrieved ${it.getDescription()}") + purchases + .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } + .forEach { purchase -> + val productId = purchase.productId ?: return + getStoreProductType( + productId, + { error -> logger.release("Failed to fetch product type for purchase $productId - " + error.message) } + ) { productType -> + when (productType) { + QStoreProductType.InApp -> { + consume(purchase.purchaseToken) + } + QStoreProductType.Subscription -> { + if (!purchase.isAcknowledged) { + acknowledge(purchase.purchaseToken) } - ?: logger.release("queryPurchases() -> purchases cache is empty.") + } } } } + } + + override fun consumeHistoryRecords(historyRecords: List) { + if (isAnalyticsMode) { + return + } + + historyRecords.forEach { record -> + when (record.type) { + QStoreProductType.InApp -> consume(record.historyRecord.purchaseToken) + QStoreProductType.Subscription -> acknowledge(record.historyRecord.purchaseToken) + } } } - override fun getSkuDetailsFromPurchases( - purchases: List, - @Suppress("DEPRECATION") onCompleted: (List) -> Unit, - onFailed: (BillingError) -> Unit + override fun queryPurchasesHistory( + onFailed: (error: BillingError) -> Unit, + onCompleted: (purchases: List) -> Unit ) { - val skuList = purchases.map { it.sku } + fun fireOnFailed(error: BillingError) { + onFailed(error) + logger.release("queryPurchasesHistory() -> $error") + } - loadAllProducts( - skuList, - { skuDetailsList -> - onCompleted(skuDetailsList) + queryPurchaseHistoryAsync( + QStoreProductType.Subscription, + { subsPurchasesList -> + queryPurchaseHistoryAsync( + QStoreProductType.InApp, + { inAppPurchasesList -> + onCompleted( + subsPurchasesList + inAppPurchasesList + ) + }, + { error -> fireOnFailed(error) } + ) }, - { error -> - onFailed(error) - logger.release("loadProducts() -> $error") - } + { error -> fireOnFailed(error) } ) } - override fun purchase( - activity: Activity, - @Suppress("DEPRECATION") skuDetails: SkuDetails, - @Suppress("DEPRECATION") oldSkuDetails: SkuDetails?, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int? + override fun queryPurchases( + onFailed: (error: BillingError) -> Unit, + onCompleted: (purchases: List) -> Unit ) { - if (oldSkuDetails != null) { - replaceOldPurchase(activity, skuDetails, oldSkuDetails, prorationMode) - } else { - makePurchase( - activity, - skuDetails - ) + logger.debug("queryPurchases() -> Querying purchases from cache for subs and inapp") + executeOnMainThread { billingSetupError -> + if (billingSetupError != null) { + onFailed(billingSetupError) + return@executeOnMainThread + } + + billingClientWrapper.queryPurchases(onFailed, onCompleted) } } - private fun handlePurchasesQueryError( - billingResult: BillingResult, - purchaseType: String, - onQueryFailed: (error: BillingError) -> Unit + override fun getStoreProductType( + storeId: String, + onFailed: (error: BillingError) -> Unit, + onSuccess: (type: QStoreProductType) -> Unit ) { - val errorMessage = - "Failed to query $purchaseType purchases from cache: ${billingResult.getDescription()}" - onQueryFailed( - BillingError( - billingResult.responseCode, - errorMessage - ) + billingClientWrapper.getStoreProductType( + storeId, + { actualError -> + legacyBillingClientWrapper.getStoreProductType( + storeId, + { onFailed(actualError) }, + onSuccess + ) + }, + onSuccess ) - logger.release("queryPurchases() -> $errorMessage") } - private fun replaceOldPurchase( + private fun updatePurchase( activity: Activity, - @Suppress("DEPRECATION") skuDetails: SkuDetails, - @Suppress("DEPRECATION") oldSkuDetails: SkuDetails, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode prorationMode: Int? + product: QProduct, + offerId: String?, + applyOffer: Boolean, + oldProduct: QProduct, + updatePolicy: QPurchaseUpdatePolicy? ) { - getPurchaseHistoryFromSkuDetails(oldSkuDetails) { billingResult, oldPurchaseHistory -> - if (billingResult.isOk) { - if (oldPurchaseHistory != null) { - logger.debug( - "replaceOldPurchase() -> Purchase was found successfully for sku: ${oldSkuDetails.sku}" - ) + val billingClientWrapper = chooseBillingClientWrapperForProductPurchase(product) ?: return - makePurchase( - activity, - skuDetails, - UpdatePurchaseInfo(oldPurchaseHistory.purchaseToken, prorationMode) - ) - } else { - val errorMessage = "No existing purchase for sku: ${oldSkuDetails.sku}" - purchasesListener.onPurchasesFailed( - emptyList(), - BillingError(billingResult.responseCode, errorMessage) - ) - logger.release("replaceOldPurchase() -> $errorMessage") - } - } else { - val errorMessage = - "Failed to update purchase: ${billingResult.getDescription()}" + billingClientWrapper.queryPurchaseHistoryForProduct(oldProduct) { billingResult, purchaseHistoryRecord -> + if (!billingResult.isOk) { + val errorMessage = "Failed to update purchase: ${billingResult.getDescription()}" purchasesListener.onPurchasesFailed( - emptyList(), BillingError(billingResult.responseCode, errorMessage) ) - logger.release("replaceOldPurchase() -> $errorMessage") + logger.release("updatePurchase() -> $errorMessage") + return@queryPurchaseHistoryForProduct } - } - } - private fun getPurchaseHistoryFromSkuDetails( - @Suppress("DEPRECATION") skuDetails: SkuDetails, - onQueryHistoryCompleted: (BillingResult, PurchaseHistoryRecord?) -> Unit - ) = withReadyClient { - logger.debug( - "getPurchaseHistoryFromSkuDetails() -> " + - "Querying purchase history for ${skuDetails.sku} with type ${skuDetails.type}" - ) + if (purchaseHistoryRecord != null) { + logger.debug( + "updatePurchase() -> Purchase was found successfully for store product: ${purchaseHistoryRecord.productId}" + ) - @Suppress("DEPRECATION") - queryPurchaseHistoryAsync(skuDetails.type) { billingResult, purchasesList -> - onQueryHistoryCompleted( - billingResult, - purchasesList?.firstOrNull { skuDetails.sku == it.sku } - ) + makePurchase( + activity, + product, + offerId, + applyOffer, + UpdatePurchaseInfo(purchaseHistoryRecord.purchaseToken, updatePolicy) + ) + } else { + val errorMessage = "No existing purchase for Qonversion product: ${oldProduct.qonversionID}" + purchasesListener.onPurchasesFailed( + BillingError(billingResult.responseCode, errorMessage) + ) + logger.release("updatePurchase() -> $errorMessage") + } } } private fun makePurchase( activity: Activity, - @Suppress("DEPRECATION") skuDetails: SkuDetails, + product: QProduct, + offerId: String?, + applyOffer: Boolean, updatePurchaseInfo: UpdatePurchaseInfo? = null ) { - logger.debug("makePurchase() -> Purchasing for sku: ${skuDetails.sku}") - executeOnMainThread { billingSetupError -> - if (billingSetupError == null) { - val builder = BillingFlowParams.newBuilder() - @Suppress("DEPRECATION") - builder.setSkuDetails(skuDetails) - val params = builder - .setSubscriptionUpdateParams(updatePurchaseInfo) - .build() - - this@QonversionBillingService.launchBillingFlow(activity, params) + if (billingSetupError != null) { + return@executeOnMainThread } - } - } - private fun BillingFlowParams.Builder.setSubscriptionUpdateParams( - info: UpdatePurchaseInfo? = null - ): BillingFlowParams.Builder { - if (info != null) { - val updateParamsBuilder = SubscriptionUpdateParams.newBuilder() - @Suppress("DEPRECATION") - updateParamsBuilder.setOldSkuPurchaseToken(info.purchaseToken) - val updateParams = updateParamsBuilder.apply { - info.prorationMode?.let { - @Suppress("DEPRECATION") - setReplaceSkusProrationMode(it) - } + val billingClientWrapper = chooseBillingClientWrapperForProductPurchase(product) ?: run { + purchasesListener.onPurchasesFailed( + BillingError( + BillingClient.BillingResponseCode.ITEM_NOT_OWNED, + "Store details for purchasing Qonversion product " + + "${product.qonversionID} were not found" + ) + ) + return@executeOnMainThread } - .build() - setSubscriptionUpdateParams(updateParams) + billingClientWrapper.makePurchase( + activity, + product, + offerId, + applyOffer, + updatePurchaseInfo, + ) { error -> purchasesListener.onPurchasesFailed(error) } } - - return this } - @UiThread - private fun launchBillingFlow( - activity: Activity, - params: BillingFlowParams - ) = withReadyClient { - launchBillingFlow(activity, params) - .takeUnless { billingResult -> billingResult.isOk } - ?.let { billingResult -> - logger.release("launchBillingFlow() -> Failed to launch billing flow. ${billingResult.getDescription()}") + private fun consume(purchaseToken: String) { + logger.debug("consume() -> Consuming purchase with token $purchaseToken") + executeOnMainThread { billingSetupError -> + if (billingSetupError == null) { + billingClientWrapper.consume(purchaseToken) } + } } - private fun queryAllPurchasesHistory( - onQueryHistoryCompleted: (List) -> Unit, - onQueryHistoryFailed: (BillingError) -> Unit + private fun acknowledge( + purchaseToken: String ) { - queryPurchaseHistoryAsync( - @Suppress("DEPRECATION") - BillingClient.SkuType.SUBS, - { subsPurchasesList -> - queryPurchaseHistoryAsync( - @Suppress("DEPRECATION") - BillingClient.SkuType.INAPP, - { inAppPurchasesList -> - onQueryHistoryCompleted( - subsPurchasesList + inAppPurchasesList - ) - }, - onQueryHistoryFailed - ) - }, - onQueryHistoryFailed - ) + logger.debug("acknowledge() -> Acknowledging purchase with token $purchaseToken") + executeOnMainThread { billingSetupError -> + if (billingSetupError == null) { + billingClientWrapper.acknowledge(purchaseToken) + } + } } private fun queryPurchaseHistoryAsync( - @Suppress("DEPRECATION") @BillingClient.SkuType skuType: String, + productType: QStoreProductType, onQueryHistoryCompleted: (List) -> Unit, onQueryHistoryFailed: (BillingError) -> Unit ) { - logger.debug("queryPurchaseHistoryAsync() -> Querying purchase history for type $skuType") + logger.debug("queryPurchaseHistoryAsync() -> Querying purchase history for type $QStoreProductType") executeOnMainThread { billingSetupError -> if (billingSetupError == null) { - withReadyClient { - @Suppress("DEPRECATION") - queryPurchaseHistoryAsync(skuType) { billingResult, purchaseHistoryRecords -> - if (billingResult.isOk && purchaseHistoryRecords != null) { - val purchaseHistory = getPurchaseHistoryFromHistoryRecords( - skuType, - purchaseHistoryRecords - ) - onQueryHistoryCompleted(purchaseHistory) - } else { - var errorMessage = "Failed to retrieve purchase history. " - if (purchaseHistoryRecords == null) { - errorMessage += "Purchase history for $skuType is null. " - } + billingClientWrapper.queryPurchaseHistory(productType) { billingResult, purchaseHistoryRecords -> + if (billingResult.isOk && purchaseHistoryRecords != null) { + val purchaseHistory = getPurchaseHistoryFromHistoryRecords( + productType, + purchaseHistoryRecords + ) + onQueryHistoryCompleted(purchaseHistory) + } else { + var errorMessage = "Failed to retrieve purchase history. " + if (purchaseHistoryRecords == null) { + errorMessage += "Purchase history for $productType is null. " + } - onQueryHistoryFailed( - BillingError( - billingResult.responseCode, - "$errorMessage ${billingResult.getDescription()}" - ) + onQueryHistoryFailed( + BillingError( + billingResult.responseCode, + "$errorMessage ${billingResult.getDescription()}" ) - } + ) } } } else { @@ -379,130 +360,26 @@ internal class QonversionBillingService internal constructor( } private fun getPurchaseHistoryFromHistoryRecords( - @Suppress("DEPRECATION") @BillingClient.SkuType skuType: String, + productType: QStoreProductType, historyRecords: List ): List { val purchaseHistory = mutableListOf() historyRecords .takeUnless { it.isEmpty() } ?.forEach { record -> - purchaseHistory.add(PurchaseHistory(skuType, record)) - logger.debug("queryPurchaseHistoryAsync() -> purchase history for $skuType is retrieved ${record.getDescription()}") + purchaseHistory.add(PurchaseHistory(productType, record)) + logger.debug("queryPurchaseHistoryAsync() -> purchase history for $productType is retrieved ${record.getDescription()}") } - ?: logger.release("queryPurchaseHistoryAsync() -> purchase history for $skuType is empty.") + ?: logger.release("queryPurchaseHistoryAsync() -> purchase history for $productType is empty.") return purchaseHistory } - private fun loadAllProducts( - productIDs: List, - @Suppress("DEPRECATION") onQuerySkuCompleted: (List) -> Unit, - onQuerySkuFailed: (BillingError) -> Unit - ) { - querySkuDetailsAsync( - @Suppress("DEPRECATION") - BillingClient.SkuType.SUBS, - productIDs, - { skuDetailsSubs -> - val skuSubs = skuDetailsSubs.map { it.sku }.toSet() - val skuInApp = productIDs - skuSubs - - if (skuInApp.isNotEmpty()) { - querySkuDetailsAsync( - @Suppress("DEPRECATION") - BillingClient.SkuType.INAPP, - skuInApp, - { skuDetailsInApp -> - onQuerySkuCompleted(skuDetailsSubs + skuDetailsInApp) - }, - onQuerySkuFailed - ) - } else { - onQuerySkuCompleted(skuDetailsSubs) - } - }, - onQuerySkuFailed - ) - } - - private fun querySkuDetailsAsync( - @Suppress("DEPRECATION") @BillingClient.SkuType productType: String, - skuList: List, - @Suppress("DEPRECATION") onQuerySkuCompleted: (List) -> Unit, - onQuerySkuFailed: (BillingError) -> Unit - ) { - logger.debug("querySkuDetailsAsync() -> Querying skuDetails for type $productType, identifiers: ${skuList.joinToString()}") - - executeOnMainThread { billingSetupError -> - if (billingSetupError == null) { - val params = buildSkuDetailsParams(productType, skuList) - - withReadyClient { - @Suppress("DEPRECATION") - querySkuDetailsAsync(params) { billingResult, skuDetailsList -> - if (billingResult.isOk && skuDetailsList != null) { - logSkuDetails(skuDetailsList, skuList) - onQuerySkuCompleted(skuDetailsList) - } else { - var errorMessage = "Failed to fetch products. " - if (skuDetailsList == null) { - errorMessage += "SkuDetails list for $skuList is null. " - } - - onQuerySkuFailed( - BillingError( - billingResult.responseCode, - "$errorMessage ${billingResult.getDescription()}" - ) - ) - } - } - } - } else { - onQuerySkuFailed(billingSetupError) - } - } - } - - @Suppress("DEPRECATION") - private fun buildSkuDetailsParams( - @BillingClient.SkuType productType: String, - skuList: List - ): SkuDetailsParams { - return SkuDetailsParams.newBuilder() - .setType(productType) - .setSkusList(skuList) - .build() - } - - private fun logSkuDetails( - @Suppress("DEPRECATION") skuDetailsList: List, - skuList: List - ) { - skuDetailsList - .takeUnless { it.isEmpty() } - ?.forEach { - logger.debug("querySkuDetailsAsync() -> $it") - } - ?: logger.release("querySkuDetailsAsync() -> SkuDetails list for $skuList is empty.") - } - - private fun startConnection() { - mainHandler.post { - synchronized(this@QonversionBillingService) { - billingClient?.let { - it.startConnection(this) - logger.debug("startConnection() -> for $it") - } - } - } - } - private fun executeOnMainThread(request: (BillingError?) -> Unit) { synchronized(this@QonversionBillingService) { requestsQueue.add(request) - if (billingClient?.isReady == false) { - startConnection() + if (!billingClientHolder.isConnected) { + billingClientHolder.startConnection(this) } else { executeRequestsFromQueue() } @@ -511,7 +388,7 @@ internal class QonversionBillingService internal constructor( private fun executeRequestsFromQueue() { synchronized(this@QonversionBillingService) { - while (billingClient?.isReady == true && requestsQueue.isNotEmpty()) { + while (billingClientHolder.isConnected && requestsQueue.isNotEmpty()) { requestsQueue.remove() .let { mainHandler.post { @@ -522,15 +399,6 @@ internal class QonversionBillingService internal constructor( } } - private fun withReadyClient(billingFunction: BillingClient.() -> Unit) { - billingClient - ?.takeIf { it.isReady } - ?.let { - it.billingFunction() - } - ?: logger.debug("Connection to the BillingClient was lost") - } - override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List?) { if (billingResult.isOk && purchases != null) { logger.debug("onPurchasesUpdated() -> purchases updated. ${billingResult.getDescription()} ") @@ -538,10 +406,11 @@ internal class QonversionBillingService internal constructor( } else { val errorMessage = billingResult.getDescription() purchasesListener.onPurchasesFailed( - purchases ?: emptyList(), BillingError( + BillingError( billingResult.responseCode, errorMessage - ) + ), + purchases ?: emptyList() ) logger.release("onPurchasesUpdated() -> failed to update purchases $errorMessage") @@ -555,41 +424,32 @@ internal class QonversionBillingService internal constructor( } } - override fun onBillingServiceDisconnected() { - logger.debug("onBillingServiceDisconnected() -> for ${billingClient?.toString()}") + override fun onBillingClientConnected() { + executeRequestsFromQueue() } - override fun onBillingSetupFinished(billingResult: BillingResult) { - when (billingResult.responseCode) { - BillingClient.BillingResponseCode.OK -> { - logger.debug("onBillingSetupFinished() -> successfully for ${billingClient?.toString()}.") - executeRequestsFromQueue() - } - BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED, - BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> { - logger.release("onBillingSetupFinished() -> with error: ${billingResult.getDescription()}") - synchronized(this@QonversionBillingService) { - while (!requestsQueue.isEmpty()) { - requestsQueue.remove() - .let { billingRequest -> - mainHandler.post { - billingRequest( - BillingError( - billingResult.responseCode, - "Billing is not available on this device. ${billingResult.getDescription()}" - ) - ) - } - } - } + override fun onBillingClientUnavailable(error: BillingError) { + synchronized(this@QonversionBillingService) { + while (!requestsQueue.isEmpty()) { + requestsQueue.remove().let { billingRequest -> + mainHandler.post { billingRequest(error) } } } - BillingClient.BillingResponseCode.DEVELOPER_ERROR -> { - // Client is already in the process of connecting to billing service - } - else -> { - logger.release("onBillingSetupFinished with error: ${billingResult.getDescription()}") - } + } + } + + private fun chooseBillingClientWrapperForProductPurchase( + product: QProduct + ): IBillingClientWrapper<*, *>? { + // Use new billing for the products, where + // -- storeDetails are loaded + // -- base plan id is specified + // -- offer for that base plan exists + val storeDetails = product.storeDetails + return when { + storeDetails != null && (product.basePlanID != null || storeDetails.isInApp) -> billingClientWrapper + @Suppress("DEPRECATION") product.skuDetail != null -> legacyBillingClientWrapper + else -> return null } } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/UpdatePurchaseInfo.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/UpdatePurchaseInfo.kt index 177792a67..4bf81cd4b 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/UpdatePurchaseInfo.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/UpdatePurchaseInfo.kt @@ -1,8 +1,8 @@ package com.qonversion.android.sdk.internal.billing -import com.android.billingclient.api.BillingFlowParams +import com.qonversion.android.sdk.dto.QPurchaseUpdatePolicy internal data class UpdatePurchaseInfo( val purchaseToken: String, - @Suppress("DEPRECATION") @BillingFlowParams.ProrationMode val prorationMode: Int? = null + val updatePolicy: QPurchaseUpdatePolicy? = null ) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/utils.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/utils.kt index ab2910b96..1b70c0db0 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/utils.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/billing/utils.kt @@ -4,8 +4,14 @@ import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingResult import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchaseHistoryRecord +import com.qonversion.android.sdk.dto.products.QProduct +import com.qonversion.android.sdk.dto.products.QProductOfferDetails +import com.qonversion.android.sdk.dto.products.QSubscriptionPeriod +import com.qonversion.android.sdk.dto.products.QProductPricingPhase import java.text.SimpleDateFormat -import java.util.* +import java.util.Calendar +import java.util.Date +import java.util.Locale internal val BillingResult.isOk get() = responseCode == BillingClient.BillingResponseCode.OK @@ -13,21 +19,74 @@ internal fun BillingResult.getDescription() = "It is a proxy of the Google BillingClient error: ${responseCode.getDescription()}" internal fun PurchaseHistoryRecord.getDescription() = - "ProductId: ${this.sku}; PurchaseTime: ${this.purchaseTime.convertLongToTime()}; PurchaseToken: ${this.purchaseToken}" + "ProductId: ${this.productId}; PurchaseTime: ${this.purchaseTime.convertLongToTime()}; PurchaseToken: ${this.purchaseToken}" internal fun Purchase.getDescription() = - "ProductId: ${this.sku}; OrderId: ${this.orderId}; PurchaseToken: ${this.purchaseToken}" + "ProductId: ${this.productId}; OrderId: ${this.orderId}; PurchaseToken: ${this.purchaseToken}" -@Suppress("DEPRECATION") -internal val Purchase.sku: String? - get() = skus.firstOrNull() +internal val Purchase.productId: String? + get() = products.firstOrNull() -@Suppress("DEPRECATION") -internal val PurchaseHistoryRecord.sku: String? - get() = skus.firstOrNull() +internal val PurchaseHistoryRecord.productId: String? + get() = products.firstOrNull() internal fun getCurrentTimeInMillis(): Long = Calendar.getInstance().timeInMillis +private const val MAX_BILLING_PHASES_DURATION_YEARS = 55 + +// Calculates total price for a client if he would use this concrete offer. +// 55 years is the maximum length of all the offer phases +// (3 years max trial and 52 years max recurrent discount payments). +internal val QProductOfferDetails.pricePerMaxDuration: Double get() { + var totalDays = QSubscriptionPeriod.Unit.Year.inDays * MAX_BILLING_PHASES_DURATION_YEARS + var totalPrice = .0 + + for (pricingPhase in pricingPhases) { + // Base plan is the last phase, so we just calculate the price of the remaining time + // of base plan usage. + if (pricingPhase.isBasePlan) { + val remainingPeriodCount = if (pricingPhase.billingPeriod.durationDays != 0) { + totalDays.toDouble() / pricingPhase.billingPeriod.durationDays + } else { + Double.MAX_VALUE + } + totalPrice += pricingPhase.price.priceAmountMicros * remainingPeriodCount + break + } + + // For any trial or intro offer we decrease the amount of days left by its duration + totalDays -= pricingPhase.durationDays + + // And also add the price for that offer for its total duration. + if (!pricingPhase.isTrial) { + totalPrice += pricingPhase.price.priceAmountMicros * pricingPhase.billingCycleCount + } + } + + return totalPrice +} + +internal val QProductPricingPhase.durationDays get() = when (type) { + QProductPricingPhase.Type.FreeTrial, + QProductPricingPhase.Type.DiscountedRecurringPayment, + QProductPricingPhase.Type.DiscountedSinglePayment -> + billingPeriod.durationDays * billingCycleCount + else -> 0 +} + +internal val QSubscriptionPeriod.durationDays get() = unit.inDays * unitCount + +internal val QSubscriptionPeriod.Unit.inDays get() = when (this) { + QSubscriptionPeriod.Unit.Day -> 1 + QSubscriptionPeriod.Unit.Week -> 7 + QSubscriptionPeriod.Unit.Month -> 30 + QSubscriptionPeriod.Unit.Year -> 365 + QSubscriptionPeriod.Unit.Unknown -> 0 +} + +@Suppress("DEPRECATION") +internal val QProduct.hasAnyStoreDetails get() = skuDetail != null || storeDetails != null + private fun Long.convertLongToTime(): String { val date = Date(this) val format = SimpleDateFormat("yyyy.MM.dd HH:mm", Locale.getDefault()) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/GoogleBillingPeriodConverter.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/GoogleBillingPeriodConverter.kt deleted file mode 100644 index 196b5f65a..000000000 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/GoogleBillingPeriodConverter.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.qonversion.android.sdk.internal.converter - -import com.qonversion.android.sdk.dto.products.QTrialDuration - -internal object GoogleBillingPeriodConverter { - - private val multipliers = mapOf( - "Y" to 365, - "M" to 30, - "W" to 7, - "D" to 1 - ) - - fun convertTrialPeriod(trialPeriod: String?): QTrialDuration { - if (trialPeriod.isNullOrEmpty()) { - return QTrialDuration.NotAvailable - } - - val period = when (trialPeriod) { - "P3D" -> QTrialDuration.ThreeDays - "P7D", "P1W" -> QTrialDuration.Week - "P14D", "P2W" -> QTrialDuration.TwoWeeks - "P30D", "P1M", "P4W2D" -> QTrialDuration.Month - "P60D", "P2M", "P8W4D" -> QTrialDuration.TwoMonths - "P90D", "P3M", "P12W6D" -> QTrialDuration.ThreeMonths - "P180D", "P6M", "P25W5D" -> QTrialDuration.SixMonths - "P365D", "P12M", "P52W1D", "P1Y" -> QTrialDuration.Year - else -> QTrialDuration.Other - } - - return period - } - - fun convertPeriodToDays(period: String?): Int? { - if (period.isNullOrEmpty()) { - return null - } - - var totalCount = 0 - val regex = Regex("\\d+[a-zA-Z]") - val results = regex.findAll(period, 0) - results.forEach { result -> - val value = result.groupValues.first() - val digits = value.filter { it.isDigit() }.toInt() - val letter = value.filter { it.isLetter() } - - val multiplier = multipliers[letter] - multiplier?.let { - totalCount += it * digits - } - } - - return totalCount - } -} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/GooglePurchaseConverter.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/GooglePurchaseConverter.kt index 3bcd43c62..fb48c63a0 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/GooglePurchaseConverter.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/GooglePurchaseConverter.kt @@ -1,142 +1,30 @@ package com.qonversion.android.sdk.internal.converter -import android.util.Pair -import com.android.billingclient.api.* -import com.qonversion.android.sdk.internal.Constants.PRICE_MICROS_DIVIDER +import com.qonversion.android.sdk.internal.billing.productId import com.qonversion.android.sdk.internal.milliSecondsToSeconds -import com.qonversion.android.sdk.internal.billing.sku import com.qonversion.android.sdk.internal.purchase.Purchase -import com.qonversion.android.sdk.internal.extractor.Extractor -@Suppress("DEPRECATION") -internal class GooglePurchaseConverter( - private val extractor: Extractor -) : PurchaseConverter> { - companion object { - private const val daysPeriodUnit = 0 - } +internal class GooglePurchaseConverter : PurchaseConverter { override fun convertPurchases( - skuDetails: Map, purchases: List ): List { - val pairs = purchases.mapNotNull { - val skuDetail = skuDetails[it.sku] - - if (skuDetail != null) { - Pair.create(skuDetail, it) - } else { - null - } - } - - val result = convertPurchasesFromList(pairs) - - return result + return purchases.map { convertPurchase(it) } } - override fun convertPurchase(purchaseInfo: Pair): Purchase? { - val details = purchaseInfo.first - val purchase = purchaseInfo.second - val sku = purchase.sku ?: return null - + override fun convertPurchase(purchase: com.android.billingclient.api.Purchase): Purchase { return Purchase( - detailsToken = extractor.extract(details.originalJson), - title = details.title, - description = details.description, - productId = sku, - type = details.type, - originalPrice = details.originalPrice, - originalPriceAmountMicros = details.originalPriceAmountMicros, - priceCurrencyCode = details.priceCurrencyCode, - price = formatPrice(details.priceAmountMicros), - priceAmountMicros = details.priceAmountMicros, - periodUnit = getUnitsTypeFromPeriod(details.subscriptionPeriod), - periodUnitsCount = getUnitsCountFromPeriod(details.subscriptionPeriod), - freeTrialPeriod = details.freeTrialPeriod, - introductoryAvailable = details.introductoryPrice.isNotEmpty(), - introductoryPriceAmountMicros = details.introductoryPriceAmountMicros, - introductoryPrice = getIntroductoryPrice(details), - introductoryPriceCycles = getIntroductoryPriceCycles(details), - introductoryPeriodUnit = daysPeriodUnit, - introductoryPeriodUnitsCount = GoogleBillingPeriodConverter.convertPeriodToDays( - details.freeTrialPeriod.takeIf { it.isNotEmpty() } ?: details.introductoryPricePeriod - ), - orderId = purchase.orderId ?: "", - originalOrderId = formatOriginalTransactionId(purchase.orderId ?: ""), - packageName = purchase.packageName, - purchaseTime = purchase.purchaseTime.milliSecondsToSeconds(), - purchaseState = purchase.purchaseState, - purchaseToken = purchase.purchaseToken, - acknowledged = purchase.isAcknowledged, - autoRenewing = purchase.isAutoRenewing, - paymentMode = getPaymentMode(details) - ) - } - - private fun convertPurchasesFromList( - purchaseInfo: List> - ): List { - return purchaseInfo.mapNotNull { - convertPurchase(it) - } - } - - private fun getIntroductoryPriceCycles(details: SkuDetails): Int { - return if (details.freeTrialPeriod.isEmpty()) details.introductoryPriceCycles else 0 - } - - private fun getIntroductoryPrice(details: SkuDetails): String { - if (details.freeTrialPeriod.isEmpty()) { - return formatPrice(details.introductoryPriceAmountMicros) - } - - return "0.0" - } - - private fun getPaymentMode(details: SkuDetails): Int { - return if (details.freeTrialPeriod.isNotEmpty()) 2 else 0 - } - - private fun formatPrice(price: Long): String { - val divideResult = price.toDouble() / PRICE_MICROS_DIVIDER - val result = String.format("%.2f", divideResult) - - return result + storeProductId = purchase.productId, + orderId = purchase.orderId ?: "", + originalOrderId = formatOriginalTransactionId(purchase.orderId ?: ""), + purchaseTime = purchase.purchaseTime.milliSecondsToSeconds(), + purchaseToken = purchase.purchaseToken, + ) } private fun formatOriginalTransactionId(transactionId: String): String { val regex = Regex("\\.{2}.*") - val result = regex.replace(transactionId, "") - - return result - } - - private fun getUnitsTypeFromPeriod(period: String?): Int? { - if (period.isNullOrEmpty()) { - return null - } - - val result = period.last().toString() - - val periodUnit = when (result) { - "Y" -> 3 - "M" -> 2 - "W" -> 1 - "D" -> 0 - else -> null - } - - return periodUnit - } - - private fun getUnitsCountFromPeriod(period: String?): Int? { - if (period.isNullOrEmpty()) { - return null - } - - val unitsCount = period.substring(1..period.length - 2) - return unitsCount.toInt() + return regex.replace(transactionId, "") } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/PurchaseConverter.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/PurchaseConverter.kt index 016280fb3..edf997a7c 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/PurchaseConverter.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/converter/PurchaseConverter.kt @@ -1,12 +1,9 @@ package com.qonversion.android.sdk.internal.converter -import com.android.billingclient.api.* import com.qonversion.android.sdk.internal.purchase.Purchase -internal interface PurchaseConverter { - fun convertPurchase(purchaseInfo: F): Purchase? - fun convertPurchases( - @Suppress("DEPRECATION") skuDetails: Map, - purchases: List - ): List +internal interface PurchaseConverter { + fun convertPurchase(purchase: com.android.billingclient.api.Purchase): Purchase + + fun convertPurchases(purchases: List): List } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/NetworkModule.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/NetworkModule.kt index dff5cb1ba..c181e5fe5 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/NetworkModule.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/NetworkModule.kt @@ -17,9 +17,7 @@ import com.qonversion.android.sdk.internal.dto.QOfferingTagAdapter import com.qonversion.android.sdk.internal.dto.QOfferingsAdapter import com.qonversion.android.sdk.internal.dto.QEntitlementSourceAdapter import com.qonversion.android.sdk.internal.dto.QPermissionsAdapter -import com.qonversion.android.sdk.internal.dto.QProductDurationAdapter import com.qonversion.android.sdk.internal.dto.QProductRenewStateAdapter -import com.qonversion.android.sdk.internal.dto.QProductTypeAdapter import com.qonversion.android.sdk.internal.dto.QProductsAdapter import com.qonversion.android.sdk.internal.dto.QRemoteConfigurationSourceAssignmentTypeAdapter import com.qonversion.android.sdk.internal.dto.QRemoteConfigurationSourceTypeAdapter @@ -55,11 +53,9 @@ internal class NetworkModule { @Provides fun provideMoshi(): Moshi { return Moshi.Builder() - .add(QProductDurationAdapter()) .add(QDateAdapter()) .add(QProductsAdapter()) .add(QPermissionsAdapter()) - .add(QProductTypeAdapter()) .add(QProductRenewStateAdapter()) .add(QEntitlementSourceAdapter()) .add(QOfferingsAdapter()) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/RepositoryModule.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/RepositoryModule.kt index 27c60064c..22b78bc17 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/RepositoryModule.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/di/module/RepositoryModule.kt @@ -15,7 +15,6 @@ import com.qonversion.android.sdk.internal.di.scope.ApplicationScope import com.qonversion.android.sdk.internal.logger.Logger import com.qonversion.android.sdk.internal.repository.QRepository import com.qonversion.android.sdk.internal.repository.RepositoryWithRateLimits -import com.qonversion.android.sdk.internal.storage.PurchasesCache import com.qonversion.android.sdk.internal.storage.TokenStorage import com.qonversion.android.sdk.internal.storage.UserPropertiesStorage import com.qonversion.android.sdk.internal.storage.SharedPreferencesCache @@ -34,7 +33,6 @@ internal class RepositoryModule { environmentProvider: EnvironmentProvider, config: InternalConfig, logger: Logger, - purchasesCache: PurchasesCache, apiErrorMapper: ApiErrorMapper, sharedPreferences: SharedPreferences, delayCalculator: IncrementalDelayCalculator, @@ -46,7 +44,6 @@ internal class RepositoryModule { environmentProvider, config, logger, - purchasesCache, apiErrorMapper, sharedPreferences, delayCalculator @@ -62,7 +59,6 @@ internal class RepositoryModule { environmentProvider: EnvironmentProvider, config: InternalConfig, logger: Logger, - purchasesCache: PurchasesCache, apiErrorMapper: ApiErrorMapper, sharedPreferences: SharedPreferences, delayCalculator: IncrementalDelayCalculator @@ -72,7 +68,6 @@ internal class RepositoryModule { environmentProvider, config, logger, - purchasesCache, apiErrorMapper, sharedPreferences, delayCalculator diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/ProductStoreId.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/ProductStoreId.kt new file mode 100644 index 000000000..69a8fe010 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/ProductStoreId.kt @@ -0,0 +1,7 @@ +package com.qonversion.android.sdk.internal.dto + +internal data class ProductStoreId( + val productId: String, + val basePlanId: String?, // absent for inapp products + val offerId: String? = null +) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/QStoreProductType.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/QStoreProductType.kt new file mode 100644 index 000000000..ebe693621 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/QStoreProductType.kt @@ -0,0 +1,44 @@ +package com.qonversion.android.sdk.internal.dto + +import com.android.billingclient.api.BillingClient + +internal enum class QStoreProductType { + InApp, + Subscription; + + @BillingClient.ProductType + fun toProductType(): String { + return when (this) { + InApp -> BillingClient.ProductType.INAPP + Subscription -> BillingClient.ProductType.SUBS + } + } + + @Suppress("DEPRECATION") + @BillingClient.SkuType + fun toSkuType(): String { + return when (this) { + InApp -> BillingClient.SkuType.INAPP + Subscription -> BillingClient.SkuType.SUBS + } + } + + companion object { + fun fromProductType(@BillingClient.ProductType type: String): QStoreProductType { + return if (type == BillingClient.ProductType.INAPP) { + InApp + } else { + Subscription + } + } + + @Suppress("DEPRECATION") + fun fromSkuType(@BillingClient.SkuType type: String): QStoreProductType { + return if (type == BillingClient.SkuType.INAPP) { + InApp + } else { + Subscription + } + } + } +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/QonversionMappingAdapters.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/QonversionMappingAdapters.kt index 6c72e2ae9..ce5cf469a 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/QonversionMappingAdapters.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/QonversionMappingAdapters.kt @@ -17,36 +17,10 @@ import com.qonversion.android.sdk.dto.offerings.QOffering import com.qonversion.android.sdk.dto.offerings.QOfferingTag import com.qonversion.android.sdk.dto.offerings.QOfferings import com.qonversion.android.sdk.dto.products.QProduct -import com.qonversion.android.sdk.dto.products.QProductDuration -import com.qonversion.android.sdk.dto.products.QProductType import com.squareup.moshi.FromJson import com.squareup.moshi.ToJson import java.util.Date -internal class QProductDurationAdapter { - @ToJson - private fun toJson(enum: QProductDuration): Int { - return enum.type - } - - @FromJson - fun fromJson(type: Int): QProductDuration? { - return QProductDuration.fromType(type) - } -} - -internal class QProductTypeAdapter { - @ToJson - private fun toJson(enum: QProductType): Int { - return enum.type - } - - @FromJson - fun fromJson(type: Int): QProductType { - return QProductType.fromType(type) - } -} - internal class QProductRenewStateAdapter { @ToJson private fun toJson(enum: QProductRenewState): Int { diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/History.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/History.kt index 934542267..39b06f5ff 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/History.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/History.kt @@ -7,7 +7,5 @@ import com.squareup.moshi.JsonClass internal data class History( @Json(name = "product") val product: String, @Json(name = "purchase_token") val purchaseToken: String, - @Json(name = "purchase_time") val purchaseTime: Long, - @Json(name = "currency") val priceCurrencyCode: String?, - @Json(name = "value") val price: String? + @Json(name = "purchase_time") val purchaseTime: Long ) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/Inapp.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/Inapp.kt index 29b551798..c483fa1ba 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/Inapp.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/Inapp.kt @@ -6,29 +6,14 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) internal data class Inapp( @Json(name = "purchase") val purchase: PurchaseDetails, - @Json(name = "introductory_offer") val introductoryOffer: IntroductoryOfferDetails? ) @JsonClass(generateAdapter = true) internal data class PurchaseDetails( - @Json(name = "product") val productId: String, @Json(name = "purchase_token") val purchaseToken: String, @Json(name = "purchase_time") val purchaseTime: Long, - @Json(name = "currency") val priceCurrencyCode: String, - @Json(name = "value") val price: String, @Json(name = "transaction_id") val transactionId: String, @Json(name = "original_transaction_id") val originalTransactionId: String, - @Json(name = "period_unit") val periodUnit: Int?, - @Json(name = "period_number_of_units") val periodUnitsCount: Int?, - @Json(name = "country") val country: String?, + @Json(name = "product") val storeProductId: String, @Json(name = "product_id") val qProductId: String ) - -@JsonClass(generateAdapter = true) -internal data class IntroductoryOfferDetails( - @Json(name = "value") val price: String, - @Json(name = "period_unit") val periodUnit: Int, - @Json(name = "period_number_of_units") val periodUnitsCount: Int, - @Json(name = "number_of_periods") val periodsCount: Int, - @Json(name = "payment_mode") val paymentMode: Int -) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/PurchaseModelInternal.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/PurchaseModelInternal.kt new file mode 100644 index 000000000..f098aa855 --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/PurchaseModelInternal.kt @@ -0,0 +1,34 @@ +package com.qonversion.android.sdk.internal.dto.purchase + +import com.qonversion.android.sdk.dto.QPurchaseModel +import com.qonversion.android.sdk.dto.QPurchaseUpdateModel +import com.qonversion.android.sdk.dto.QPurchaseUpdatePolicy +import com.qonversion.android.sdk.dto.products.QProduct + +internal open class PurchaseModelInternal( + val productId: String, + val offerId: String?, + val applyOffer: Boolean, + val oldProductId: String?, + val updatePolicy: QPurchaseUpdatePolicy?, +) { + constructor(purchaseModel: QPurchaseModel) : this( + purchaseModel.productId, + purchaseModel.offerId, + purchaseModel.applyOffer, + null, + null, + ) + + constructor(purchaseModel: QPurchaseUpdateModel) : this( + purchaseModel.productId, + purchaseModel.offerId, + purchaseModel.applyOffer, + purchaseModel.oldProductId, + purchaseModel.updatePolicy, + ) + + fun enrich(product: QProduct, oldProduct: QProduct?) = PurchaseModelInternalEnriched( + productId, product, offerId, applyOffer, oldProductId, oldProduct, updatePolicy + ) +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/PurchaseModelInternalEnriched.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/PurchaseModelInternalEnriched.kt new file mode 100644 index 000000000..6218e874b --- /dev/null +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/purchase/PurchaseModelInternalEnriched.kt @@ -0,0 +1,29 @@ +package com.qonversion.android.sdk.internal.dto.purchase + +import com.qonversion.android.sdk.dto.QPurchaseUpdatePolicy +import com.qonversion.android.sdk.dto.products.QProduct + +internal class PurchaseModelInternalEnriched( + productId: String, + val product: QProduct, + offerId: String?, + applyOffer: Boolean, + oldProductId: String?, + val oldProduct: QProduct?, + updatePolicy: QPurchaseUpdatePolicy?, +) : PurchaseModelInternal(productId, offerId, applyOffer, oldProductId, updatePolicy) { + + constructor( + purchaseModel: PurchaseModelInternal, + product: QProduct, + oldProduct: QProduct? + ) : this( + purchaseModel.productId, + product, + purchaseModel.offerId, + purchaseModel.applyOffer, + purchaseModel.oldProductId, + oldProduct, + purchaseModel.updatePolicy + ) +} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/request/PurchaseRequest.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/request/PurchaseRequest.kt index b9fd6748b..49714dbc4 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/request/PurchaseRequest.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/dto/request/PurchaseRequest.kt @@ -1,7 +1,6 @@ package com.qonversion.android.sdk.internal.dto.request import com.qonversion.android.sdk.internal.dto.Environment -import com.qonversion.android.sdk.internal.dto.purchase.IntroductoryOfferDetails import com.qonversion.android.sdk.internal.dto.purchase.PurchaseDetails import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -16,5 +15,4 @@ internal data class PurchaseRequest( @Json(name = "receipt") override val receipt: String = "", @Json(name = "debug_mode") override val debugMode: String, @Json(name = "purchase") val purchase: PurchaseDetails, - @Json(name = "introductory_offer") val introductoryOffer: IntroductoryOfferDetails? ) : RequestData() diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/Extractor.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/Extractor.kt deleted file mode 100644 index 631da826e..000000000 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/Extractor.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.qonversion.android.sdk.internal.extractor - -internal interface Extractor { - fun extract(response: T?): String -} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/SkuDetailsTokenExtractor.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/SkuDetailsTokenExtractor.kt deleted file mode 100644 index 19280c3ea..000000000 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/SkuDetailsTokenExtractor.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.qonversion.android.sdk.internal.extractor - -import org.json.JSONObject - -internal class SkuDetailsTokenExtractor : Extractor { - override fun extract(response: String?): String { - if (response.isNullOrEmpty()) { - return "" - } - - val jsonObj = JSONObject(response) - - if (jsonObj.has(SKU_DETAILS_TOKEN_KEY)) { - return jsonObj.getString(SKU_DETAILS_TOKEN_KEY) - } - - return "" - } - - companion object { - const val SKU_DETAILS_TOKEN_KEY = "skuDetailsToken" - } -} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/TokenExtractor.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/TokenExtractor.kt deleted file mode 100644 index e2ee30f2e..000000000 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/extractor/TokenExtractor.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.qonversion.android.sdk.internal.extractor - -import com.qonversion.android.sdk.internal.dto.BaseResponse -import retrofit2.Response - -internal class TokenExtractor : - Extractor>> { - override fun extract(response: Response>?): String { - return response?.body()?.let { - it.data.clientUid ?: "" - } ?: "" - } -} diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/purchase/Purchase.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/purchase/Purchase.kt index 4a0833bef..d5a7c468f 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/purchase/Purchase.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/purchase/Purchase.kt @@ -4,32 +4,9 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) internal data class Purchase( - val detailsToken: String, - val title: String, - val description: String, - val productId: String, - val type: String, - val originalPrice: String, - val originalPriceAmountMicros: Long, - val priceCurrencyCode: String, - val price: String, - val priceAmountMicros: Long, - val periodUnit: Int?, - val periodUnitsCount: Int?, - val freeTrialPeriod: String, - val introductoryAvailable: Boolean, - val introductoryPriceAmountMicros: Long, - val introductoryPrice: String, - val introductoryPriceCycles: Int, - val introductoryPeriodUnit: Int?, - val introductoryPeriodUnitsCount: Int?, + val storeProductId: String?, val orderId: String, val originalOrderId: String, - val packageName: String, val purchaseTime: Long, - val purchaseState: Int, val purchaseToken: String, - val acknowledged: Boolean, - val autoRenewing: Boolean, - val paymentMode: Int ) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/purchase/PurchaseHistory.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/purchase/PurchaseHistory.kt index 6a7317fc2..53f2f4665 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/purchase/PurchaseHistory.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/purchase/PurchaseHistory.kt @@ -1,11 +1,9 @@ package com.qonversion.android.sdk.internal.purchase -import com.android.billingclient.api.BillingClient import com.android.billingclient.api.PurchaseHistoryRecord -import com.android.billingclient.api.* +import com.qonversion.android.sdk.internal.dto.QStoreProductType internal data class PurchaseHistory( - @Suppress("DEPRECATION") @BillingClient.SkuType val type: String, - val historyRecord: PurchaseHistoryRecord, - @Suppress("DEPRECATION") var skuDetails: SkuDetails? = null + val type: QStoreProductType, + val historyRecord: PurchaseHistoryRecord ) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/repository/DefaultRepository.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/repository/DefaultRepository.kt index 14d9846a6..72f513db3 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/repository/DefaultRepository.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/repository/DefaultRepository.kt @@ -8,14 +8,13 @@ import com.qonversion.android.sdk.dto.QonversionError import com.qonversion.android.sdk.dto.QonversionErrorCode import com.qonversion.android.sdk.listeners.QonversionLaunchCallback import com.qonversion.android.sdk.internal.Constants.PENDING_PUSH_TOKEN_KEY -import com.qonversion.android.sdk.internal.Constants.PRICE_MICROS_DIVIDER import com.qonversion.android.sdk.internal.Constants.PUSH_TOKEN_KEY import com.qonversion.android.sdk.internal.EnvironmentProvider import com.qonversion.android.sdk.internal.IncrementalDelayCalculator import com.qonversion.android.sdk.internal.InternalConfig import com.qonversion.android.sdk.internal.api.Api import com.qonversion.android.sdk.internal.api.ApiErrorMapper -import com.qonversion.android.sdk.internal.billing.sku +import com.qonversion.android.sdk.internal.billing.productId import com.qonversion.android.sdk.internal.dto.BaseResponse import com.qonversion.android.sdk.internal.dto.ProviderData import com.qonversion.android.sdk.internal.dto.QLaunchResult @@ -25,7 +24,6 @@ import com.qonversion.android.sdk.internal.dto.automations.Screen import com.qonversion.android.sdk.internal.dto.eligibility.StoreProductInfo import com.qonversion.android.sdk.internal.dto.purchase.History import com.qonversion.android.sdk.internal.dto.purchase.Inapp -import com.qonversion.android.sdk.internal.dto.purchase.IntroductoryOfferDetails import com.qonversion.android.sdk.internal.dto.purchase.PurchaseDetails import com.qonversion.android.sdk.internal.dto.request.AttachUserRequest import com.qonversion.android.sdk.internal.dto.request.SendPushTokenRequest @@ -46,7 +44,6 @@ import com.qonversion.android.sdk.internal.purchase.PurchaseHistory import com.qonversion.android.sdk.internal.logger.Logger import com.qonversion.android.sdk.internal.milliSecondsToSeconds import com.qonversion.android.sdk.internal.secondsToMilliSeconds -import com.qonversion.android.sdk.internal.storage.PurchasesCache import com.qonversion.android.sdk.internal.stringValue import com.qonversion.android.sdk.internal.toQonversionError import com.qonversion.android.sdk.listeners.QonversionExperimentAttachCallback @@ -64,7 +61,6 @@ internal class DefaultRepository internal constructor( private val environmentProvider: EnvironmentProvider, private val config: InternalConfig, private val logger: Logger, - private val purchasesCache: PurchasesCache, private val errorMapper: ApiErrorMapper, private val preferences: SharedPreferences, private val delayCalculator: IncrementalDelayCalculator @@ -464,7 +460,6 @@ internal class DefaultRepository internal constructor( clientUid = uid, debugMode = isDebugMode.stringValue(), purchase = convertPurchaseDetails(purchase, qProductId), - introductoryOffer = convertIntroductoryPurchaseDetail(purchase) ) api.purchase(purchaseRequest).enqueue { @@ -475,7 +470,6 @@ internal class DefaultRepository internal constructor( callback.onSuccess(body.data) } else { handlePurchaseError( - purchase, callback, errorMapper.getErrorFromResponse(it), it.code(), @@ -494,7 +488,6 @@ internal class DefaultRepository internal constructor( onFailure = { logger.release("purchaseRequest - failure - ${it.toQonversionError()}") handlePurchaseError( - purchase, callback, it.toQonversionError(), null, @@ -513,7 +506,6 @@ internal class DefaultRepository internal constructor( } private fun handlePurchaseError( - purchase: Purchase, callback: QonversionLaunchCallback, error: QonversionError, errorCode: Int?, @@ -541,7 +533,6 @@ internal class DefaultRepository internal constructor( } } else { callback.onError(error, errorCode) - purchasesCache.savePurchase(purchase) } } @@ -549,72 +540,38 @@ internal class DefaultRepository internal constructor( val inapps: MutableList = mutableListOf() purchases?.forEach { - val inapp = convertPurchase(it) - inapps.add(inapp) + val inapp = convertPurchaseDetails(it) + inapps.add(Inapp(inapp)) } return inapps.toList() } - private fun convertPurchase(purchase: Purchase): Inapp { - val purchaseDetails = convertPurchaseDetails(purchase) - val introductoryOfferDetails = convertIntroductoryPurchaseDetail(purchase) - - return Inapp(purchaseDetails, introductoryOfferDetails) - } - - private fun convertIntroductoryPurchaseDetail(purchase: Purchase): IntroductoryOfferDetails? { - var introductoryOfferDetails: IntroductoryOfferDetails? = null - - if ((purchase.freeTrialPeriod.isNotEmpty() || purchase.introductoryAvailable) && - purchase.introductoryPeriodUnit != null && - purchase.introductoryPeriodUnitsCount != null - ) { - introductoryOfferDetails = IntroductoryOfferDetails( - purchase.introductoryPrice, - purchase.introductoryPeriodUnit, - purchase.introductoryPeriodUnitsCount, - purchase.introductoryPriceCycles, - purchase.paymentMode - ) - } - - return introductoryOfferDetails - } - private fun convertPurchaseDetails( purchase: Purchase, qProductId: String? = null ): PurchaseDetails { return PurchaseDetails( - purchase.productId, purchase.purchaseToken, purchase.purchaseTime, - purchase.priceCurrencyCode, - purchase.price, purchase.orderId, purchase.originalOrderId, - purchase.periodUnit, - purchase.periodUnitsCount, - null, + purchase.storeProductId ?: "", qProductId ?: "" ) } private fun convertHistory(historyRecords: List): List { return historyRecords.mapNotNull { - val sku = it.historyRecord.sku + val productId = it.historyRecord.productId - if (sku == null) { + if (productId == null) { null } else { History( - sku, + productId, it.historyRecord.purchaseToken, - it.historyRecord.purchaseTime.milliSecondsToSeconds(), - it.skuDetails?.priceCurrencyCode, - it.skuDetails?.priceAmountMicros?.let { micros -> micros / PRICE_MICROS_DIVIDER } - .toString() + it.historyRecord.purchaseTime.milliSecondsToSeconds() ) } } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PurchasesCache.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PurchasesCache.kt index 855117923..e28342b28 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PurchasesCache.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/storage/PurchasesCache.kt @@ -1,7 +1,6 @@ package com.qonversion.android.sdk.internal.storage import android.content.SharedPreferences -import com.android.billingclient.api.BillingClient import com.qonversion.android.sdk.internal.purchase.Purchase import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi @@ -21,23 +20,20 @@ internal class PurchasesCache( moshi.adapter(collectionPurchaseType) fun savePurchase(purchase: Purchase) { - @Suppress("DEPRECATION") - if (purchase.type == BillingClient.SkuType.INAPP) { - val purchases = loadPurchases().toMutableSet() - purchases.add(purchase) - - if (purchases.size >= MAX_PURCHASES_NUMBER) { - val oldPurchases = purchases.toMutableList().take(MAX_OLD_PURCHASES_NUMBER).toSet() - purchases.removeAll(oldPurchases) - } + val purchases = loadPurchases().toMutableSet() + purchases.add(purchase) - savePurchasesAsJson(purchases) + if (purchases.size >= MAX_PURCHASES_NUMBER) { + val oldPurchases = purchases.toMutableList().take(MAX_OLD_PURCHASES_NUMBER).toSet() + purchases.removeAll(oldPurchases) } + + savePurchasesAsJson(purchases) } fun loadPurchases(): Set { val json = preferences.getString(PURCHASE_KEY, "") - if (json == null || json.isEmpty()) { + if (json.isNullOrEmpty()) { return setOf() } return try { diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/ConsumerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/ConsumerTest.kt deleted file mode 100644 index 8a9cdb513..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/ConsumerTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package com.qonversion.android.sdk.internal - -import com.android.billingclient.api.BillingClient -import com.android.billingclient.api.Purchase -import com.android.billingclient.api.PurchaseHistoryRecord -import com.android.billingclient.api.* -import com.qonversion.android.sdk.internal.billing.QonversionBillingService -import com.qonversion.android.sdk.internal.billing.sku -import com.qonversion.android.sdk.internal.purchase.PurchaseHistory -import com.qonversion.android.sdk.mockPrivateField -import io.mockk.* -import org.junit.Before -import org.junit.Test - -internal class ConsumerTest { - private val sku = "sku" - @Suppress("DEPRECATION") - private val skuTypeInApp = BillingClient.SkuType.INAPP - @Suppress("DEPRECATION") - private val skuTypeSubs = BillingClient.SkuType.SUBS - private val purchaseToken = "purchaseToken" - private val fieldIsAnalyticsMode = "isAnalyticsMode" - - private val mockBillingService: QonversionBillingService = mockk() - - private lateinit var consumer: Consumer - - @Before - fun setUp() { - clearAllMocks() - - val isAnalyticsMode = false - consumer = Consumer(mockBillingService, isAnalyticsMode) - } - - @Test - fun `consume purchases when analytics mode is true`() { - val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED) - val purchases = listOf(purchase) - val skuDetails = mockSkuDetailsMap(skuTypeInApp) - consumer.mockPrivateField(fieldIsAnalyticsMode, true) - - consumer.consumePurchases(purchases, skuDetails) - - verify(exactly = 0) { - mockBillingService.acknowledge(any()) - mockBillingService.consume(any()) - } - } - - @Test - fun `consume purchases with pending state`() { - val purchase = mockPurchase(Purchase.PurchaseState.PENDING) - val purchases = listOf(purchase) - val skuDetails = mockSkuDetailsMap(skuTypeInApp) - - consumer.consumePurchases(purchases, skuDetails) - - verify(exactly = 0) { - mockBillingService.consume(any()) - mockBillingService.acknowledge(any()) - } - } - - @Test - fun `consume purchases for inapp`() { - val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED) - val purchases = listOf(purchase) - val skuDetails = mockSkuDetailsMap(skuTypeInApp) - - every { mockBillingService.consume(any()) } just Runs - - consumer.consumePurchases(purchases, skuDetails) - - verify(exactly = 1) { - mockBillingService.consume(purchaseToken) - } - } - - @Test - fun `consume purchases for acknowledged subs`() { - val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, true) - val purchases = listOf(purchase) - val skuDetails = mockSkuDetailsMap(skuTypeSubs) - - consumer.consumePurchases(purchases, skuDetails) - - verify(exactly = 0) { - mockBillingService.acknowledge(any()) - } - } - - @Test - fun `consume purchases for unacknowledged subs`() { - val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED) - val purchases = listOf(purchase) - val skuDetails = mockSkuDetailsMap(skuTypeSubs) - - every { mockBillingService.acknowledge(any()) } just Runs - - consumer.consumePurchases(purchases, skuDetails) - - verify(exactly = 1) { - mockBillingService.acknowledge(purchaseToken) - } - } - - @Test - fun `consume history records when analytics mode is true`(){ - val record = mockPurchaseHistoryRecord() - val purchaseHistory = PurchaseHistory(skuTypeInApp, record) - val purchaseHistoryList = listOf(purchaseHistory) - - consumer.mockPrivateField(fieldIsAnalyticsMode, true) - - consumer.consumeHistoryRecords(purchaseHistoryList) - - verify(exactly = 0) { - mockBillingService.acknowledge(any()) - mockBillingService.consume(any()) - } - } - - @Test - fun `consume history records for inapp`(){ - val record = mockPurchaseHistoryRecord() - val purchaseHistory = PurchaseHistory(skuTypeInApp, record) - val purchaseHistoryList = listOf(purchaseHistory) - - every { mockBillingService.consume(any()) } just Runs - - consumer.consumeHistoryRecords(purchaseHistoryList) - - verify(exactly = 1) { - mockBillingService.consume(purchaseToken) - } - } - - @Test - fun `consume history records for subs`(){ - val record = mockPurchaseHistoryRecord() - val purchaseHistory = PurchaseHistory(skuTypeSubs, record) - val purchaseHistoryList = listOf(purchaseHistory) - - every { mockBillingService.acknowledge(any()) } just Runs - - consumer.consumeHistoryRecords(purchaseHistoryList) - - verify(exactly = 1) { - mockBillingService.acknowledge(purchaseToken) - } - } - - @Suppress("DEPRECATION") - private fun mockSkuDetailsMap(@BillingClient.SkuType skuType: String): Map { - val skuDetails = mockSkuDetails(skuType) - val mapSkuDetails = mutableMapOf() - mapSkuDetails[sku] = skuDetails - - return mapSkuDetails - } - - @Suppress("DEPRECATION") - private fun mockSkuDetails( - @BillingClient.SkuType skuType: String - ): SkuDetails { - - return mockk(relaxed = true).also { - every { it.sku } returns sku - every { it.type } returns skuType - } - } - - private fun mockPurchase( - @Purchase.PurchaseState purchaseState: Int, - isAcknowledged: Boolean = false - ): Purchase { - - val purchase = mockk(relaxed = true) - - every { purchase.sku } returns sku - every { purchase.purchaseToken } returns purchaseToken - every { purchase.purchaseState } returns purchaseState - every { purchase.isAcknowledged } returns isAcknowledged - - return purchase - } - - private fun mockPurchaseHistoryRecord(): PurchaseHistoryRecord { - val purchaseHistoryRecord = mockk(relaxed = true) - - every { purchaseHistoryRecord.sku } returns sku - every { purchaseHistoryRecord.purchaseToken } returns purchaseToken - - return purchaseHistoryRecord - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QAttributionManagerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QAttributionManagerTest.kt index 24d1a9f74..a180a06a0 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/QAttributionManagerTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QAttributionManagerTest.kt @@ -19,8 +19,8 @@ internal class QAttributionManagerTest { private lateinit var attributionManager: QAttributionManager - private val fieldPendingAttrSource = "pendingAttributionSource" - private val fieldPendingInfo = "pendingConversionInfo" + private val fieldPendingAttrProvider = "pendingAttributionProvider" + private val fieldPendingData = "pendingData" private val conversionInfo = mapOf("key" to "value") @BeforeEach @@ -58,9 +58,9 @@ internal class QAttributionManagerTest { // then val pendingSource = - attributionManager.getPrivateField(fieldPendingAttrSource) + attributionManager.getPrivateField(fieldPendingAttrProvider) val pendingInfo = - attributionManager.getPrivateField?>(fieldPendingInfo) + attributionManager.getPrivateField?>(fieldPendingData) assertAll( "Pending attribution info wasn't saved.", { Assert.assertEquals(QAttributionProvider.AppsFlyer, pendingSource) }, @@ -78,17 +78,17 @@ internal class QAttributionManagerTest { @Test fun `should send pending attribution after app switched to foreground`() { // given - attributionManager.mockPrivateField(fieldPendingAttrSource, QAttributionProvider.AppsFlyer) - attributionManager.mockPrivateField(fieldPendingInfo, conversionInfo) + attributionManager.mockPrivateField(fieldPendingAttrProvider, QAttributionProvider.AppsFlyer) + attributionManager.mockPrivateField(fieldPendingData, conversionInfo) // when attributionManager.onAppForeground() // then val pendingSource = - attributionManager.getPrivateField(fieldPendingAttrSource) + attributionManager.getPrivateField(fieldPendingAttrProvider) val pendingInfo = - attributionManager.getPrivateField?>(fieldPendingInfo) + attributionManager.getPrivateField?>(fieldPendingData) assertAll( "Pending attribution info wasn't cleared.", { Assert.assertEquals(null, pendingSource) }, @@ -103,17 +103,17 @@ internal class QAttributionManagerTest { @Test fun `should not send null pending attribution after app switched to foreground`() { // given - attributionManager.mockPrivateField(fieldPendingAttrSource, null) - attributionManager.mockPrivateField(fieldPendingInfo, null) + attributionManager.mockPrivateField(fieldPendingAttrProvider, null) + attributionManager.mockPrivateField(fieldPendingData, null) // when attributionManager.onAppForeground() // then val pendingSource = - attributionManager.getPrivateField(fieldPendingAttrSource) + attributionManager.getPrivateField(fieldPendingAttrProvider) val pendingInfo = - attributionManager.getPrivateField?>(fieldPendingInfo) + attributionManager.getPrivateField?>(fieldPendingData) assertAll( "Pending attribution info wasn't cleared.", { Assert.assertEquals(null, pendingSource) }, diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt index f84961ffd..07d1d0b07 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt @@ -10,12 +10,11 @@ import com.android.billingclient.api.* import com.qonversion.android.sdk.listeners.QonversionLaunchCallback import com.qonversion.android.sdk.internal.billing.BillingError import com.qonversion.android.sdk.internal.billing.QonversionBillingService -import com.qonversion.android.sdk.internal.billing.sku +import com.qonversion.android.sdk.internal.billing.productId import com.qonversion.android.sdk.internal.dto.QLaunchResult import com.qonversion.android.sdk.internal.logger.Logger import com.qonversion.android.sdk.internal.provider.AppStateProvider import com.qonversion.android.sdk.internal.repository.QRepository -import com.qonversion.android.sdk.mockPrivateField import com.qonversion.android.sdk.internal.services.QUserInfoService import com.qonversion.android.sdk.internal.storage.LaunchResultCacheWrapper import com.qonversion.android.sdk.internal.storage.PurchasesCache @@ -41,18 +40,13 @@ internal class QProductCenterManagerTest { private val mockUserInfoService = mockk(relaxed = true) private val mockIdentityManager = mockk(relaxed = true) private val mockBillingService = mockk() - private val mockConsumer = mockk(relaxed = true) private val mockConfig = mockk(relaxed = true) private val mockAppStateProvider = mockk(relaxed = true) private val mockRemoteConfigManager = mockk(relaxed = true) private lateinit var productCenterManager: QProductCenterManager - private val fieldSkuDetails = "skuDetails" - - @Suppress("DEPRECATION") - private val skuTypeInApp = BillingClient.SkuType.INAPP - private val sku = "sku" + private val productId = "productId" private val purchaseToken = "purchaseToken" private val installDate: Long = 1605608753 @@ -77,7 +71,6 @@ internal class QProductCenterManagerTest { mockRemoteConfigManager ) productCenterManager.billingService = mockBillingService - productCenterManager.consumer = mockConsumer mockLaunchResult() } @@ -99,7 +92,7 @@ internal class QProductCenterManagerTest { @Test fun `handle pending purchases when launching is finished and query purchases failed`() { every { - mockBillingService.queryPurchases(any(), captureLambda()) + mockBillingService.queryPurchases(captureLambda(), any()) } answers { lambda<(BillingError) -> Unit>().captured.invoke( BillingError(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE, "") @@ -112,21 +105,16 @@ internal class QProductCenterManagerTest { mockBillingService.queryPurchases(any(), any()) } - verify { - listOf( - mockConsumer, - mockRepository - ) wasNot Called - } + verify { mockRepository wasNot Called } + verify(exactly = 0) { mockBillingService.consumePurchases(any()) } } @Test fun `handle pending purchases when launching is finished and query purchases completed`() { val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false) val purchases = listOf(purchase) - val skuDetails = mockSkuDetailsField(skuTypeInApp) every { - mockBillingService.queryPurchases(captureLambda(), any()) + mockBillingService.queryPurchases(any(), captureLambda()) } answers { lambda<(List) -> Unit>().captured.invoke( purchases @@ -145,45 +133,22 @@ internal class QProductCenterManagerTest { ) } just Runs - every { mockBillingService.consume(any()) } just Runs + every { mockBillingService.consumePurchases(any()) } just Runs productCenterManager.onAppForeground() verify(exactly = 1) { mockBillingService.queryPurchases(any(), any()) - mockConsumer.consumePurchases(purchases, skuDetails) + mockBillingService.consumePurchases(purchases) } assertAll( "Repository purchase() method was called with invalid arguments", - { Assert.assertEquals("Wrong sku value", sku, entityPurchaseSlot.captured.productId) }, { Assert.assertEquals("Wrong purchaseToken value", purchaseToken, entityPurchaseSlot.captured.purchaseToken) }, - { Assert.assertEquals("Wrong type value", skuTypeInApp, entityPurchaseSlot.captured.type) }, { Assert.assertEquals("Wrong installDate value", installDate.milliSecondsToSeconds(), installDateSlot.captured) } ) } - @Suppress("DEPRECATION") - private fun mockSkuDetailsField(@BillingClient.SkuType skuType: String): Map { - val skuDetails = mockSkuDetails(skuType) - val mapSkuDetails = mutableMapOf() - mapSkuDetails[sku] = skuDetails - productCenterManager.mockPrivateField(fieldSkuDetails, mapSkuDetails) - - return mapSkuDetails - } - - @Suppress("DEPRECATION") - private fun mockSkuDetails( - @BillingClient.SkuType skuType: String - ): SkuDetails { - - return mockk(relaxed = true).also { - every { it.sku } returns sku - every { it.type } returns skuType - } - } - private fun mockPurchase( @Purchase.PurchaseState purchaseState: Int, isAcknowledged: Boolean @@ -191,7 +156,7 @@ internal class QProductCenterManagerTest { val purchase = mockk(relaxed = true) - every { purchase.sku } returns sku + every { purchase.productId } returns productId every { purchase.purchaseToken } returns purchaseToken every { purchase.purchaseState } returns purchaseState every { purchase.isAcknowledged } returns isAcknowledged diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/billing/QonversionBillingServiceTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/billing/QonversionBillingServiceTest.kt deleted file mode 100644 index 8fec5ad55..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/billing/QonversionBillingServiceTest.kt +++ /dev/null @@ -1,1038 +0,0 @@ -package com.qonversion.android.sdk.internal.billing - -import android.app.Activity -import android.os.Handler -import com.android.billingclient.api.* -import com.qonversion.android.sdk.internal.purchase.PurchaseHistory -import com.qonversion.android.sdk.internal.logger.Logger -import io.mockk.* -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.fail -import org.junit.Assert.assertEquals -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll - -internal class QonversionBillingServiceTest { - private val skuSubs = "subs" - private val skuInapp = "inapp" - private val purchaseToken = "token" - - private val mockBillingClient: BillingClient = mockk(relaxed = true) - private val mockHandler: Handler = mockk() - private val mockPurchasesListener: QonversionBillingService.PurchasesListener = mockk() - private val mockLogger: Logger = mockk(relaxed = true) - - private lateinit var billingClientStateListener: BillingClientStateListener - private lateinit var billingService: QonversionBillingService - - @BeforeEach - fun setUp() { - clearAllMocks() - - val slot = slot() - every { - mockHandler.post(capture(slot)) - } answers { - slot.captured.run() - true - } - - val billingClientStateSlot = slot() - every { - mockBillingClient.startConnection(capture(billingClientStateSlot)) - } answers { - billingClientStateListener = billingClientStateSlot.captured - } - - every { - mockBillingClient.isReady - } returns true - - billingService = - QonversionBillingService(mockHandler, mockPurchasesListener, mockLogger) - billingService.billingClient = mockBillingClient - } - - @Nested - inner class QueryPurchasesHistory { - @Test - fun `query purchases history completed`() { - mockQueryPurchaseHistoryResponse( - BillingClient.SkuType.SUBS, BillingClient.BillingResponseCode.OK - ) - mockQueryPurchaseHistoryResponse( - BillingClient.SkuType.INAPP, BillingClient.BillingResponseCode.OK - ) - - var purchaseHistory: List? = null - billingService.queryPurchasesHistory( - { - purchaseHistory = it - }, - { - fail("Shouldn't go here") - } - ) - - assertThat(purchaseHistory).isNotNull - assertThat(purchaseHistory!!.size).isEqualTo(2) - assertThat(purchaseHistory!![0].historyRecord.sku).isEqualTo(skuSubs) - assertThat(purchaseHistory!![0].type).isEqualTo(BillingClient.SkuType.SUBS) - assertThat(purchaseHistory!![1].historyRecord.sku).isEqualTo(skuInapp) - assertThat(purchaseHistory!![1].type).isEqualTo(BillingClient.SkuType.INAPP) - } - - @Test - fun `query purchases history failed with billing error`() { - mockQueryPurchaseHistoryResponse( - BillingClient.SkuType.SUBS, - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE - ) - - var billingError: BillingError? = null - billingService.queryPurchasesHistory( - { - fail("Shouldn't go here") - }, - { - billingError = it - } - ) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode) - .isEqualTo(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) - } - - @Test - fun `query purchases history failed with null list`() { - mockQueryPurchaseHistoryResponse( - BillingClient.SkuType.SUBS, - BillingClient.BillingResponseCode.OK, - true - ) - - var billingError: BillingError? = null - billingService.queryPurchasesHistory( - { - fail("Shouldn't go here") - }, - { - billingError = it - } - ) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode) - .isEqualTo(BillingClient.BillingResponseCode.OK) - } - - @Test - fun `query purchases history deferred until billing connected`() { - mockQueryPurchaseHistoryResponse( - BillingClient.SkuType.SUBS, - BillingClient.BillingResponseCode.OK - ) - mockQueryPurchaseHistoryResponse( - BillingClient.SkuType.INAPP, - BillingClient.BillingResponseCode.OK - ) - - every { mockBillingClient.isReady } returns false - - var purchaseHistory: List? = null - billingService.queryPurchasesHistory( - { - purchaseHistory = it - }, - { - fail("Shouldn't go here") - } - ) - assertThat(purchaseHistory).isNull() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.OK)) - - assertThat(purchaseHistory).isNotNull - } - - @Test - fun `query purchases history deferred until billing connected with error`() { - every { mockBillingClient.isReady } returns false - - var billingError: BillingError? = null - billingService.queryPurchasesHistory( - { - fail("Shouldn't go here") - }, - { - billingError = it - } - ) - assertThat(billingError).isNull() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode).isEqualTo(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) - } - - private fun mockQueryPurchaseHistoryResponse( - @BillingClient.SkuType skuType: String, - @BillingClient.BillingResponseCode responseCode: Int, - isHistoryRecordListNull: Boolean = false - ) { - val purchaseHistoryResponse = slot() - every { - mockBillingClient.queryPurchaseHistoryAsync( - skuType, - capture(purchaseHistoryResponse) - ) - } answers { - var historyRecordList: List? = null - - if (responseCode == BillingClient.BillingResponseCode.OK && !isHistoryRecordListNull) { - val sku: String = - if (skuType == BillingClient.SkuType.INAPP) skuInapp else skuSubs - val historyRecord: PurchaseHistoryRecord = mockk(relaxed = true) - every { historyRecord.sku } returns sku - - historyRecordList = listOf(historyRecord) - } - - purchaseHistoryResponse.captured.onPurchaseHistoryResponse( - buildResult(responseCode), - historyRecordList - ) - } - } - } - - @Nested - inner class LoadProducts { - private val sku = "sku" - - @Test - fun `load products completed`() { - mockSkuDetailsResponse( - BillingClient.BillingResponseCode.OK - ) - - var skuDetailsList: List? = null - billingService.loadProducts( - setOf(sku), - { - skuDetailsList = it - }, - { - fail("Shouldn't go here") - }) - - assertThat(skuDetailsList).isNotNull - assertThat(skuDetailsList!!.size).isEqualTo(2) - assertThat(skuDetailsList!![0].sku).isEqualTo(skuSubs) - assertThat(skuDetailsList!![0].type).isEqualTo(BillingClient.SkuType.SUBS) - assertThat(skuDetailsList!![1].sku).isEqualTo(skuInapp) - assertThat(skuDetailsList!![1].type).isEqualTo(BillingClient.SkuType.INAPP) - } - - @Test - fun `load products failed with billing error`() { - mockSkuDetailsResponse(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) - - var billingError: BillingError? = null - billingService.loadProducts( - setOf(sku), - { - fail("Shouldn't go here") - }, - { - billingError = it - } - ) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode) - .isEqualTo(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) - } - - @Test - fun `load products failed null list`() { - mockSkuDetailsResponse(BillingClient.BillingResponseCode.OK, true) - - var billingError: BillingError? = null - billingService.loadProducts( - setOf(sku), - { - fail("Shouldn't go here") - }, - { - billingError = it - } - ) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode) - .isEqualTo(BillingClient.BillingResponseCode.OK) - } - - @Test - fun `load products deferred until billing connected`() { - mockSkuDetailsResponse(BillingClient.BillingResponseCode.OK) - every { mockBillingClient.isReady } returns false - - var skuDetailsList: List? = null - billingService.loadProducts(setOf(sku), - { - skuDetailsList = it - }, - { - fail("Shouldn't go here") - }) - assertThat(skuDetailsList).isNull() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.OK)) - - assertThat(skuDetailsList).isNotNull - } - - @Test - fun `load products deferred until billing connected with error`() { - every { mockBillingClient.isReady } returns false - - var billingError: BillingError? = null - billingService.loadProducts(setOf(sku), - { - fail("Shouldn't go here") - }, - { - billingError = it - }) - assertThat(billingError).isNull() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode).isEqualTo(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) - } - } - - @Nested - inner class Consume { - private val purchaseToken = "token" - - @Test - fun `consume completed`() { - val consumeParams = slot() - mockConsumeResponse(consumeParams) - - billingService.consume(purchaseToken) - assertThat(consumeParams.isCaptured).isTrue() - assertThat(consumeParams.captured.purchaseToken).isEqualTo(purchaseToken) - } - - @Test - fun `consume deferred until billing connected`() { - val consumeParams = slot() - mockConsumeResponse(consumeParams) - - every { mockBillingClient.isReady } returns false - billingService.consume(purchaseToken) - - assertThat(consumeParams.isCaptured).isFalse() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.OK)) - - assertThat(consumeParams.isCaptured).isTrue() - assertThat(consumeParams.captured.purchaseToken).isEqualTo(purchaseToken) - } - - private fun mockConsumeResponse( - consumeParams: CapturingSlot - ) { - val consumeResponse = slot() - every { - mockBillingClient.consumeAsync( - capture(consumeParams), - capture(consumeResponse) - ) - } answers { - consumeResponse.captured.onConsumeResponse( - buildResult(BillingClient.BillingResponseCode.OK), purchaseToken - ) - } - } - } - - @Nested - inner class Acknowledge { - private val purchaseToken = "token" - - @Test - fun `acknowledge completed`() { - val acknowledgeParams = slot() - mockAcknowledgeResponse(acknowledgeParams) - - billingService.acknowledge(purchaseToken) - assertThat(acknowledgeParams.isCaptured).isTrue() - assertThat(acknowledgeParams.captured.purchaseToken).isEqualTo(purchaseToken) - } - - @Test - fun `acknowledge deferred until billing connected`() { - val acknowledgeParams = slot() - mockAcknowledgeResponse(acknowledgeParams) - - every { mockBillingClient.isReady } returns false - - billingService.acknowledge(purchaseToken) - assertThat(acknowledgeParams.isCaptured).isFalse() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.OK)) - - assertThat(acknowledgeParams.isCaptured).isTrue() - assertThat(acknowledgeParams.captured.purchaseToken).isEqualTo(purchaseToken) - } - - private fun mockAcknowledgeResponse( - acknowledgeParams: CapturingSlot - ) { - val acknowledgeResponse = slot() - every { - mockBillingClient.acknowledgePurchase( - capture(acknowledgeParams), - capture(acknowledgeResponse) - ) - } answers { - acknowledgeResponse.captured.onAcknowledgePurchaseResponse( - buildResult(BillingClient.BillingResponseCode.OK) - ) - } - } - } - - @Nested - inner class QueryPurchases { - @Test - fun `query purchases completed`() { - mockQueryPurchasesResponse( - BillingClient.SkuType.SUBS, - BillingClient.BillingResponseCode.OK - ) - mockQueryPurchasesResponse( - BillingClient.SkuType.INAPP, - BillingClient.BillingResponseCode.OK - ) - var purchases: List? = null - billingService.queryPurchases( - { - purchases = it - }, - { - fail("Shouldn't go here") - }) - - assertThat(purchases).isNotNull - assertThat(purchases!!.size).isEqualTo(2) - assertThat(purchases!![0].sku).isEqualTo(skuSubs) - assertThat(purchases!![1].sku).isEqualTo(skuInapp) - } - - @Test - fun `query purchases completed with empty list`() { - mockQueryPurchasesResponse( - BillingClient.SkuType.SUBS, - BillingClient.BillingResponseCode.OK, - true - ) - mockQueryPurchasesResponse( - BillingClient.SkuType.INAPP, - BillingClient.BillingResponseCode.OK, - true - ) - - var purchases: List? = null - billingService.queryPurchases( - { - purchases = it - }, - { - fail("Shouldn't go here") - }) - - assertThat(purchases).isNotNull - assertThat(purchases).isEmpty() - } - - @Test - fun `query purchases failed`() { - mockQueryPurchasesResponse( - BillingClient.SkuType.SUBS, - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE - ) - mockQueryPurchasesResponse( - BillingClient.SkuType.INAPP, - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE - ) - - var billingError: BillingError? = null - billingService.queryPurchases( - { - fail("Shouldn't go here") - }, - { - billingError = it - }) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode) - .isEqualTo(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) - } - - @Test - fun `query purchases deferred until billing connected`() { - mockQueryPurchasesResponse( - BillingClient.SkuType.SUBS, - BillingClient.BillingResponseCode.OK - ) - mockQueryPurchasesResponse( - BillingClient.SkuType.INAPP, - BillingClient.BillingResponseCode.OK - ) - every { mockBillingClient.isReady } returns false - - var purchases: List? = null - billingService.queryPurchases( - { - purchases = it - }, - { - fail("Shouldn't go here") - }) - assertThat(purchases).isNull() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.OK)) - - assertThat(purchases).isNotNull - } - - @Test - fun `query purchases deferred until billing connected with error`() { - every { mockBillingClient.isReady } returns false - - var billingError: BillingError? = null - billingService.queryPurchases( - { - fail("Shouldn't go here") - }, - { - billingError = it - }) - assertThat(billingError).isNull() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode).isEqualTo(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) - } - - private fun mockQueryPurchasesResponse( - @BillingClient.SkuType skuType: String, - @BillingClient.BillingResponseCode responseCode: Int, - isPurchasesListEmpty: Boolean = false - ) { - var purchases: List = emptyList() - - if (responseCode == BillingClient.BillingResponseCode.OK && !isPurchasesListEmpty) { - val purchase = mockk(relaxed = true) - val sku: String = - if (skuType == BillingClient.SkuType.INAPP) skuInapp else skuSubs - every { purchase.sku } returns sku - - purchases = listOf(purchase) - } - - val listener = slot() - every { - mockBillingClient.queryPurchasesAsync(skuType, capture(listener)) - } answers { - listener.captured.onQueryPurchasesResponse( - buildResult(responseCode), - purchases - ) - } - } - } - - @Nested - inner class GetSkuDetailsFromPurchases { - @Test - fun `should return list with SUBS skuDetails`() { - // given - mockSkuDetailsResponse(BillingClient.BillingResponseCode.OK) - - var skuDetailsList: List? = null - val purchase = mockk(relaxed = true) - every { - purchase.sku - } returns skuSubs - - // when - billingService.getSkuDetailsFromPurchases(listOf(purchase), - { - skuDetailsList = it - }, - { - fail("Shouldn't go here") - }) - - // then - assertAll( - "SkuDetails' list is not valid", - { assertThat(skuDetailsList).isNotNull }, - { assertThat(skuDetailsList!!.size).isEqualTo(1) }, - { assertThat(skuDetailsList!![0].sku).isEqualTo(skuSubs) }, - { assertThat(skuDetailsList!![0].type).isEqualTo(BillingClient.SkuType.SUBS) } - ) - } - - @Test - fun `should failed with billing error`() { - // given - mockSkuDetailsResponse(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) - - var billingError: BillingError? = null - val purchase = mockk(relaxed = true) - every { - purchase.sku - } returns skuSubs - - // when - billingService.getSkuDetailsFromPurchases(listOf(purchase), - { - fail("Shouldn't go here") - }, - { - billingError = it - }) - - // then - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode) - .isEqualTo(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) - } - - @Test - fun `should failed with null list`() { - // given - mockSkuDetailsResponse(BillingClient.BillingResponseCode.OK, true) - - var billingError: BillingError? = null - val purchase = mockk(relaxed = true) - every { - purchase.sku - } returns skuSubs - - // when - billingService.getSkuDetailsFromPurchases(listOf(purchase), - { - fail("Shouldn't go here") - }, - { - billingError = it - }) - - // then - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode) - .isEqualTo(BillingClient.BillingResponseCode.OK) - } - - @Test - fun `should wait for billing connected and then return skuDetails list`() { - // given - mockSkuDetailsResponse(BillingClient.BillingResponseCode.OK) - every { mockBillingClient.isReady } returns false - - var skuDetailsList: List? = null - val purchase = mockk(relaxed = true) - every { - purchase.sku - } returns skuSubs - - // when - billingService.getSkuDetailsFromPurchases(listOf(purchase), - { - skuDetailsList = it - }, - { - fail("Shouldn't go here") - }) - - // then - assertThat(skuDetailsList).isNull() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.OK)) - - assertThat(skuDetailsList).isNotNull - } - - @Test - fun `should wait for billing connected and then return error`() { - // given - every { mockBillingClient.isReady } returns false - - val purchase = mockk(relaxed = true) - every { - purchase.sku - } returns skuSubs - var billingError: BillingError? = null - - // when - billingService.getSkuDetailsFromPurchases(listOf(purchase), - { - fail("Shouldn't go here") - }, - { - billingError = it - }) - - // then - assertThat(billingError).isNull() - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE)) - - assertThat(billingError).isNotNull - assertThat(billingError!!.billingResponseCode).isEqualTo(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) - } - } - - @Nested - inner class Purchase { - @Test - fun `purchase billing flow params is correct`() { - // given - val sku = "monthly" - val skuType = BillingClient.SkuType.SUBS - val activity: Activity = mockk() - - mockkStatic(BillingFlowParams::class) - - val mockBuilder = mockk(relaxed = true) - every { - BillingFlowParams.newBuilder() - } returns mockBuilder - - val skuDetailsSlot = slot() - every { - mockBuilder.setSkuDetails(capture(skuDetailsSlot)) - } returns mockBuilder - - val mockParams = mockk(relaxed = true) - every { - mockBuilder.build() - } returns mockParams - - val mockSkuDetails = mockSkuDetails(sku, skuType) - - every { - mockBillingClient.launchBillingFlow(eq(activity), mockParams) - } answers { - buildResult(BillingClient.BillingResponseCode.OK) - } - - // when - billingService.purchase( - activity, - mockSkuDetails - ) - - // then - assertAll( - "SkuDetails contains wrong fields", - { assertEquals("Sku is incorrect", sku, skuDetailsSlot.captured.sku) }, - { assertEquals("SkuType is incorrect", skuType, skuDetailsSlot.captured.type) } - ) - } - - @Test - fun `purchase with oldSkuDetails billing flow params is correct`() { - // given - val oldSku = "weekly" - val sku = "monthly" - val skuType = BillingClient.SkuType.SUBS - val activity: Activity = mockk() - val prorationMode = BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE - - mockkStatic(BillingFlowParams::class) - mockkStatic(BillingFlowParams.SubscriptionUpdateParams::class) - - val mockBuilder = mockk(relaxed = true) - every { - BillingFlowParams.newBuilder() - } returns mockBuilder - - val skuDetailsSlot = slot() - every { - mockBuilder.setSkuDetails(capture(skuDetailsSlot)) - } returns mockBuilder - - val mockParams = mockk(relaxed = true) - every { - mockBuilder.build() - } returns mockParams - - val mockSkuDetails = mockSkuDetails(sku, skuType) - val mockOldSkuDetails = mockSkuDetails(oldSku, skuType) - - every { - mockBillingClient.launchBillingFlow(eq(activity), mockParams) - } answers { - buildResult(BillingClient.BillingResponseCode.OK) - } - - val mockSubscriptionUpdateParamsBuilder = - mockk(relaxed = true) - every { - BillingFlowParams.SubscriptionUpdateParams.newBuilder() - } returns mockSubscriptionUpdateParamsBuilder - - val oldSkuPurchaseTokenSlot = slot() - every { - mockSubscriptionUpdateParamsBuilder.setOldSkuPurchaseToken( - capture( - oldSkuPurchaseTokenSlot - ) - ) - } returns mockSubscriptionUpdateParamsBuilder - - val prorationModeSlot = slot() - every { - mockSubscriptionUpdateParamsBuilder.setReplaceSkusProrationMode( - capture( - prorationModeSlot - ) - ) - } returns mockSubscriptionUpdateParamsBuilder - - mockQueryPurchaseHistoryResponse(BillingClient.SkuType.SUBS, oldSku) - - // when - billingService.purchase( - activity, - mockSkuDetails, - mockOldSkuDetails, - prorationMode - ) - - // then - assertAll( - "SkuDetails contains wrong fields", - { assertEquals("Sku is incorrect", sku, skuDetailsSlot.captured.sku) }, - { assertEquals("SkuType is incorrect", skuType, skuDetailsSlot.captured.type) }, - { - assertEquals( - "ProrationMode is incorrect", - prorationMode, - prorationModeSlot.captured - ) - }, - { - assertEquals( - "PurchaseToken is incorrect", - purchaseToken, - oldSkuPurchaseTokenSlot.captured - ) - } - ) - verify { - mockBillingClient.launchBillingFlow( - activity, - mockParams - ) - } - } - - @Test - fun `launch billing flow completed`() { - val activity: Activity = mockk() - val skuDetails: SkuDetails = mockk(relaxed = true) - - every { - mockBillingClient.launchBillingFlow(any(), any()) - } returns buildResult(BillingClient.BillingResponseCode.OK) - - billingService.purchase(activity, skuDetails) - - verify { - mockBillingClient.launchBillingFlow( - activity, - any() - ) - } - } - - @Test - fun `launch billing flow failed`() { - val activity: Activity = mockk() - val skuDetails: SkuDetails = mockk(relaxed = true) - - every { - mockBillingClient.launchBillingFlow(any(), any()) - } returns buildResult(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) - - billingService.purchase(activity, skuDetails) - - verify { - mockBillingClient.launchBillingFlow( - activity, - any() - ) wasNot Called - } - } - - @Test - fun `launch billing flow deferred until billing connected`() { - val activity: Activity = mockk() - val skuDetails: SkuDetails = mockk(relaxed = true) - - every { - mockBillingClient.launchBillingFlow(any(), any()) - } returns buildResult(BillingClient.BillingResponseCode.OK) - every { mockBillingClient.isReady } returns false - - billingService.purchase(activity, skuDetails) - verify { - mockBillingClient.launchBillingFlow(eq(activity), any()) wasNot Called - } - - every { mockBillingClient.isReady } returns true - billingClientStateListener.onBillingSetupFinished(buildResult(BillingClient.BillingResponseCode.OK)) - - verify(exactly = 1) { - mockBillingClient.launchBillingFlow(eq(activity), any()) - } - } - - @Test - fun `purchases listener completed`() { - every { - mockPurchasesListener.onPurchasesCompleted(any()) - } just Runs - - val purchase = mockk() - billingService.onPurchasesUpdated( - buildResult(BillingClient.BillingResponseCode.OK), - listOf(purchase) - ) - - verify { - mockPurchasesListener.onPurchasesCompleted( - listOf(purchase) - ) - } - } - - @Test - fun `purchases listener failed`() { - every { - mockPurchasesListener.onPurchasesFailed(any(), any()) - } just Runs - - billingService.onPurchasesUpdated( - buildResult(BillingClient.BillingResponseCode.OK), - null - ) - - verify { - mockPurchasesListener.onPurchasesFailed( - emptyList(), - any() - ) - } - } - - private fun mockQueryPurchaseHistoryResponse( - @BillingClient.SkuType skuType: String, - sku: String? = null - ) { - val purchaseHistoryResponse = slot() - every { - mockBillingClient.queryPurchaseHistoryAsync( - skuType, - capture(purchaseHistoryResponse) - ) - } answers { - val historyRecord: PurchaseHistoryRecord = mockk(relaxed = true) - if (sku != null) { - every { historyRecord.sku } returns sku - } - every { historyRecord.purchaseToken } returns purchaseToken - - val historyRecordList = listOf(historyRecord) - - purchaseHistoryResponse.captured.onPurchaseHistoryResponse( - buildResult(BillingClient.BillingResponseCode.OK), - historyRecordList - ) - } - } - } - - @Test - fun startConnection() { - verify(exactly = 1) { - mockHandler.post(any()) - mockBillingClient.startConnection(billingClientStateListener) - } - } - - private fun mockSkuDetailsResponse( - @BillingClient.BillingResponseCode responseCode: Int, - isSkuDetailsListNull: Boolean = false - ) { - val skuDetailsResponseSlot = slot() - val skuDetailsParamsSlot = slot() - - every { - mockBillingClient.querySkuDetailsAsync( - capture(skuDetailsParamsSlot), - capture(skuDetailsResponseSlot) - ) - } answers { - val skuDetailsParams = skuDetailsParamsSlot.captured - val skuType = skuDetailsParams.skuType - var skuDetailsList: List? = null - - if (responseCode == BillingClient.BillingResponseCode.OK && !isSkuDetailsListNull) { - val sku: String = - if (skuType == BillingClient.SkuType.INAPP) skuInapp else skuSubs - val skuDetails: SkuDetails = mockk(relaxed = true) - every { skuDetails.sku } returns sku - every { skuDetails.type } returns skuType - skuDetailsList = listOf(skuDetails) - } - - skuDetailsResponseSlot.captured.onSkuDetailsResponse( - buildResult(responseCode), - skuDetailsList - ) - } - } - - private fun buildResult(@BillingClient.BillingResponseCode code: Int): BillingResult { - return BillingResult.newBuilder().setResponseCode(code).build() - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/billing/mockUtils.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/billing/mockUtils.kt deleted file mode 100644 index a21326333..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/billing/mockUtils.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.qonversion.android.sdk.internal.billing - -import com.android.billingclient.api.BillingClient -import com.android.billingclient.api.* -import io.mockk.every -import io.mockk.mockk - -@Suppress("DEPRECATION") -fun mockSkuDetails( - sku: String, - @BillingClient.SkuType skuType: String -): SkuDetails { - - return mockk(relaxed = true).also { - every { it.sku } returns sku - every { it.type } returns skuType - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/GooglePurchaseConverterTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/GooglePurchaseConverterTest.kt deleted file mode 100644 index f8170075d..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/GooglePurchaseConverterTest.kt +++ /dev/null @@ -1,315 +0,0 @@ -package com.qonversion.android.sdk.internal.converter - -import android.util.Pair -import com.qonversion.android.sdk.* -import com.qonversion.android.sdk.internal.purchase.Purchase -import com.qonversion.android.sdk.internal.extractor.SkuDetailsTokenExtractor -import io.mockk.* -import org.assertj.core.api.Assertions.assertThat -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.assertAll -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -internal class GooglePurchaseConverterTest { - private val mockExtractor: SkuDetailsTokenExtractor = mockk(relaxed = true) - private lateinit var purchaseConverter: GooglePurchaseConverter - - private val mockToken = "XXXXXXX" - private val mockOrderId = "GPA.0000-0000-0000-0000" - private val weeklySku = "subs_weekly" - private val annualSku = "subs_annual" - - @Before - fun setUp() { - clearAllMocks() - - purchaseConverter = GooglePurchaseConverter(mockExtractor) - } - - @Test - fun `should convert purchases when purchase and skuDetails skus match`() { - // given - val spykPurchaseConverter = spyk(purchaseConverter, recordPrivateCalls = true) - - val mockWeeklySkuDetails = mockSubsSkuDetails(weeklySku) - val mockWeeklyPurchase = mockSubsPurchase(weeklySku) - val mockAnnualSkuDetails = mockSubsSkuDetails(annualSku) - val mockAnnualPurchase = mockSubsPurchase(annualSku) - - val purchasesInfoMap = mapOf( - weeklySku to mockWeeklySkuDetails, - annualSku to mockAnnualSkuDetails - ) - val purchases = listOf(mockWeeklyPurchase, mockAnnualPurchase) - - // when - val result = spykPurchaseConverter.convertPurchases(purchasesInfoMap, purchases) - verifyOrder { - spykPurchaseConverter.convertPurchase( - Pair.create( - mockWeeklySkuDetails, - mockWeeklyPurchase - ) - ) - spykPurchaseConverter.convertPurchase( - Pair.create( - mockAnnualSkuDetails, - mockAnnualPurchase - ) - ) - } - - // then - assertThat(result.size).isEqualTo(2) - } - - @Test - fun `should not convert purchases when purchase and skuDetails skus don't match`() { - // given - val spykPurchaseConverter = spyk(purchaseConverter, recordPrivateCalls = true) - - val wrongSku = "wrong_subs_weekly" - - val mockWeeklySkuDetails = mockSubsSkuDetails(weeklySku) - val mockWeeklyPurchase = mockSubsPurchase(wrongSku) - - val map = mapOf(weeklySku to mockWeeklySkuDetails) - val purchases = listOf(mockWeeklyPurchase) - - // when - val result = spykPurchaseConverter.convertPurchases(map, purchases) - - // then - verify(exactly = 0) { - spykPurchaseConverter.convertPurchase( - any() - ) - } - assertThat(result.size).isEqualTo(0) - } - - @Test - fun `shouldn't convert incorrect purchase without sku`() { - // given - val mockWeeklySkuDetails = mockSubsSkuDetails() - val mockWeeklyPurchase = mockIncorrectSubsPurchase() - val pair = Pair.create( - mockWeeklySkuDetails, - mockWeeklyPurchase - ) - - // when - val result = purchaseConverter.convertPurchase(pair) - - // then - assertThat(result).isNull() - } - - @Test - fun `should convert subs purchase correctly`() { - // given - val spykPurchaseConverter = spyk(purchaseConverter, recordPrivateCalls = true) - - val mockWeeklySkuDetails = mockSubsSkuDetails() - val mockWeeklyPurchase = mockSubsPurchase() - - every { - mockExtractor.extract(mockSubsSkuDetailsJson()) - } returns mockToken - - // when - val result = spykPurchaseConverter.convertPurchase( - Pair.create( - mockWeeklySkuDetails, - mockWeeklyPurchase - ) - ) - - // then - Assert.assertNotNull(result) - assertCommonSubsFields(result) - // Assert intro and trial period fields of subscription - assertAll( - "Converted purchase contains incorrect fields:", - { assertEquals(0, result!!.freeTrialPeriod.length, "freeTrialPeriod should be empty")}, - { assertEquals(true, result!!.introductoryAvailable, "introductoryAvailable should be true") }, - { assertEquals(85000000, result!!.introductoryPriceAmountMicros) }, - { assertEquals("85.00", result!!.introductoryPrice) }, - { assertEquals(1, result!!.introductoryPriceCycles, "introductoryPriceCycles is incorrect") }, - { assertEquals(0, result!!.introductoryPeriodUnit, "introductoryPeriodUnit is incorrect") }, - { assertEquals(3, result!!.introductoryPeriodUnitsCount, "introductoryPeriodUnitsCount should be null")}, - { assertEquals(0, result!!.paymentMode,"paymentMode is incorrect") } - ) - } - - @Test - fun `should convert subs purchase with trial correctly`() { - // given - val spykPurchaseConverter = spyk(purchaseConverter, recordPrivateCalls = true) - - val weeklySku = "subs_weekly" - - val mockWeeklySkuDetails = mockSubsSkuDetails(weeklySku, freeTrialPeriod = "P9W2D") - val mockWeeklyPurchase = mockSubsPurchase(weeklySku) - every { - mockExtractor.extract(mockSubsSkuDetailsJson(weeklySku, freeTrialPeriod = "P9W2D")) - } returns mockToken - - // when - val result = spykPurchaseConverter.convertPurchase( - Pair.create( - mockWeeklySkuDetails, - mockWeeklyPurchase - ) - ) - - // then - Assert.assertNotNull(result) - assertCommonSubsFields(result) - // Assert intro and trial period fields of subscription - assertAll( - "Converted purchase contains incorrect fields", - { assertEquals("P9W2D", result!!.freeTrialPeriod, "freeTrialPeriod is incorrect") }, - { assertEquals(true, result!!.introductoryAvailable, "introductoryAvailable should be true") }, - { assertEquals(85000000, result!!.introductoryPriceAmountMicros) }, - { assertEquals("0.0", result!!.introductoryPrice) }, - { assertEquals(0, result!!.introductoryPriceCycles, "introductoryPriceCycles is incorrect") }, - { assertEquals(0, result!!.introductoryPeriodUnit, "introductoryPriceCycles is incorrect") }, - { assertEquals(65, result!!.introductoryPeriodUnitsCount) }, - { assertEquals(2, result!!.paymentMode, "paymentMode is incorrect") } - ) - } - - private fun assertCommonSubsFields(purchase: Purchase?) { - assertAll( - "Converted purchase contains incorrect fields", - { assertEquals(mockToken, purchase!!.detailsToken, "detailsToken token is incorrect") }, - { assertEquals("Qonversion Subs", purchase!!.title) }, - { assertEquals("Weekly", purchase!!.description) }, - { assertEquals(weeklySku, purchase!!.productId) }, - { assertEquals("subs", purchase!!.type) }, - { assertEquals("RUB 439.00", purchase!!.originalPrice) }, - { assertEquals(439000000, purchase!!.originalPriceAmountMicros) }, - { assertEquals("RUB", purchase!!.priceCurrencyCode) }, - { assertEquals("439.00", purchase!!.price) }, - { assertEquals(439000000, purchase!!.priceAmountMicros) }, - { assertEquals(1, purchase!!.periodUnit, "periodUnit is incorrect") }, - { assertEquals(1, purchase!!.periodUnitsCount, "periodUnitsCount is incorrect") }, - { assertEquals(mockOrderId, purchase!!.orderId, "orderId is incorrect") }, - { assertEquals(mockOrderId, purchase!!.originalOrderId, "originalOrderId is incorrect") }, - { assertEquals("com.qonversion.sample", purchase!!.packageName) }, - { assertEquals(1631867965, purchase!!.purchaseTime) }, - { assertEquals( 1, purchase!!.purchaseState, "purchaseState is incorrect") }, - { assertEquals(mockToken, purchase!!.purchaseToken, "purchaseToken is incorrect") }, - { assertEquals(true, purchase!!.acknowledged, "acknowledged should be true") }, - { assertEquals(true, purchase!!.autoRenewing, "autoRenewing should be true") } - ) - } - - @Test - fun `should get correct units type from period`() { - // given - val dailyPeriod = "P1D" - val weeklyPeriod = "P1W" - val monthlyPeriod = "P1M" - val annualPeriod = "P1Y" - val incorrectPeriod = "P1S" - - val mockWeeklySkuDetails = mockSubsSkuDetails(subsPeriod = weeklyPeriod) - val mockAnnualSkuDetails = mockSubsSkuDetails(subsPeriod = annualPeriod) - val mockMonthlySkuDetails = mockSubsSkuDetails(subsPeriod = monthlyPeriod) - val mockDailySkuDetails = mockSubsSkuDetails(subsPeriod = dailyPeriod) - val mockIncorrectSkuDetails = mockSubsSkuDetails(subsPeriod = incorrectPeriod) - val mockSubsPurchase = mockSubsPurchase() - - val skuDetailsMap = mapOf( - weeklyPeriod to mockWeeklySkuDetails, - annualPeriod to mockAnnualSkuDetails, - monthlyPeriod to mockMonthlySkuDetails, - dailyPeriod to mockDailySkuDetails, - incorrectPeriod to mockIncorrectSkuDetails - ) - - skuDetailsMap.forEach { entry -> - val skuDetails = entry.value - val periodUnit = when (entry.key) { - annualPeriod -> 3 - monthlyPeriod -> 2 - weeklyPeriod -> 1 - dailyPeriod -> 0 - else -> null - } - - // when - val result = purchaseConverter.convertPurchase( - Pair.create( - skuDetails, - mockSubsPurchase - ) - ) - - // then - assertEquals(periodUnit, result!!.periodUnit) - } - } - - @Test - fun `should convert inapp purchase correctly`() { - // given - val spykPurchaseConverter = spyk(purchaseConverter, recordPrivateCalls = true) - - val mockSkuDetails = mockInAppSkuDetails() - val mockPurchase = mockInAppPurchase() - - every { - mockExtractor.extract(mockInAppSkuDetailsJson()) - } returns mockToken - - // when - val result = spykPurchaseConverter.convertPurchase( - Pair.create( - mockSkuDetails, - mockPurchase - ) - ) - - // then - Assert.assertNotNull(result) - assertAll( - "Converted purchase contains incorrect fields", - { assertEquals(mockToken, result!!.detailsToken, "detailsToken is incorrect") }, - { assertEquals("Qonversion In-app", result!!.title) }, - { assertEquals("Consumable", result!!.description) }, - { assertEquals("qonversion_inapp_consumable", result!!.productId) }, - { assertEquals("inapp", result!!.type) }, - { assertEquals("RUB 75.00", result!!.originalPrice) }, - { assertEquals(75000000, result!!.originalPriceAmountMicros) }, - { assertEquals("RUB", result!!.priceCurrencyCode) }, - { assertEquals("75.00", result!!.price) }, - { assertEquals(75000000, result!!.priceAmountMicros) }, - { assertEquals(null, result!!.periodUnit, "periodUnit is incorrect") }, - { assertEquals(null, result!!.periodUnitsCount, "periodUnitsCount is incorrect") }, - { assertEquals(0, result!!.freeTrialPeriod.length, "freeTrialPeriod should be empty")}, - { assertEquals(false, result!!.introductoryAvailable, "introductoryAvailable should be false") }, - { assertEquals(0, result!!.introductoryPriceAmountMicros, "introductoryPriceAmountMicros is incorrect") }, - { assertEquals("0.00", result!!.introductoryPrice) }, - { assertEquals(0, result!!.introductoryPriceCycles, "introductoryPriceCycles is incorrect") }, - { assertEquals(0, result!!.introductoryPeriodUnit, "introductoryPeriodUnit is incorrect") }, - { assertEquals(null, result!!.introductoryPeriodUnitsCount, "introductoryPeriodUnitsCount should be null")}, - { assertEquals(mockOrderId, result!!.orderId, "orderId is incorrect") }, - { assertEquals(mockOrderId, result!!.originalOrderId, "originalOrderId is incorrect") }, - { assertEquals("com.qonversion.sample", result!!.packageName) }, - { assertEquals(1632238801, result!!.purchaseTime) }, - { assertEquals( 1, result!!.purchaseState, "purchaseState is incorrect") }, - { assertEquals(mockToken, result!!.purchaseToken, "purchaseToken is incorrect") }, - { assertEquals(true, result!!.acknowledged, "acknowledged should be true") }, - { assertEquals(false, result!!.autoRenewing, "autoRenewing should be false") }, - { assertEquals(0, result!!.paymentMode, "paymentMode is incorrect") } - ) - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/SkuDetailsTokenExtractorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/SkuDetailsTokenExtractorTest.kt deleted file mode 100644 index b413feaad..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/SkuDetailsTokenExtractorTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.qonversion.android.sdk.internal.converter - -import com.qonversion.android.sdk.internal.converter.Util.Companion.CORRECT_SKU_DETAILS_SUB_JSON -import com.qonversion.android.sdk.internal.extractor.SkuDetailsTokenExtractor -import org.junit.Assert -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -internal class SkuDetailsTokenExtractorTest { - - @Test - fun extractSkuDetailsTokenFromCorrectJson() { - val extractor = SkuDetailsTokenExtractor() - val skuDetailsToken = extractor.extract(CORRECT_SKU_DETAILS_SUB_JSON) - Assert.assertEquals(skuDetailsToken, "XXXXXXX") - } - - @Test - fun extractSkuDetailsTokenFromNullJson() { - val extractor = SkuDetailsTokenExtractor() - val skuDetailsToken = extractor.extract(null) - Assert.assertEquals(skuDetailsToken, "") - } - - @Test - fun extractSkuDetailsTokenFromEmptyJson() { - val extractor = SkuDetailsTokenExtractor() - val skuDetailsToken = extractor.extract("{}") - Assert.assertEquals(skuDetailsToken, "") - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/util.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/util.kt deleted file mode 100644 index 957aed657..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/converter/util.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.qonversion.android.sdk.internal.converter - -internal class Util { - - companion object { - const val CORRECT_SKU_DETAILS_SUB_JSON = "{\n" + - " \"skuDetailsToken\": \"XXXXXXX\",\n" + - " \"productId\": \"conversion_test_subscribe\",\n" + - " \"type\": \"subs\",\n" + - " \"price\": \"RUB 200.00\",\n" + - " \"price_amount_micros\": 200000000,\n" + - " \"price_currency_code\": \"RUB\",\n" + - " \"subscriptionPeriod\": \"P1M\",\n" + - " \"freeTrialPeriod\": \"P3D\",\n" + - " \"introductoryPriceAmountMicros\": 100000000,\n" + - " \"introductoryPricePeriod\": \"P1M\",\n" + - " \"introductoryPrice\": \"RUB 100.00\",\n" + - " \"introductoryPriceCycles\": 1,\n" + - " \"title\": \"conversion-test-subscribe (Qonversion)\",\n" + - " \"description\": \"conversion-test-subscribe\"\n" + - "}\n" - - const val CORRECT_PURCHASE_SUB_JSON = "{\n" + - " \"orderId\": \"GPA.0000-0000-0000-00000\",\n" + - " \"packageName\": \"com.qonversion.android.sdk\",\n" + - " \"productId\": \"conversion_test_subscribe\",\n" + - " \"purchaseTime\": 1575404669520,\n" + - " \"purchaseState\": 0,\n" + - " \"purchaseToken\": \"XXXXXXX\",\n" + - " \"autoRenewing\": true,\n" + - " \"acknowledged\": false\n" + - "}" - - const val CORRECT_SKU_DETAILS_INAPP_JSON = "{\n" + - " \"skuDetailsToken\": \"XXXXXXX\",\n" + - " \"productId\": \"conversion_test_purchase\",\n" + - " \"type\": \"inapp\",\n" + - " \"price\": \"RUB 500.00\",\n" + - " \"price_amount_micros\": 500000000,\n" + - " \"price_currency_code\": \"RUB\",\n" + - " \"title\": \"conversion-test-purchase (Qonversion)\",\n" + - " \"description\": \"conversion-test-purchase\"\n" + - "}" - - const val CORRECT_PURCHASE_INAPP_JSON = "{\n" + - " \"orderId\": \"GPA.0000-0000-0000-00000\",\n" + - " \"packageName\": \"com.qonversion.android.sdk\",\n" + - " \"productId\": \"conversion_test_purchase\",\n" + - " \"purchaseTime\": 1575404326564,\n" + - " \"purchaseState\": 0,\n" + - " \"purchaseToken\": \"XXXXXXX\",\n" + - " \"acknowledged\": false\n" + - "}\n" - } - -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/AttributionRequestTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/AttributionRequestTest.kt deleted file mode 100644 index 00e0cfb12..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/AttributionRequestTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.qonversion.android.sdk.internal.requests - -import com.qonversion.android.sdk.internal.dto.request.AttributionRequest -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Moshi -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -internal class AttributionRequestTest { - - private lateinit var adapter: JsonAdapter - - @Before - fun setup() { - val moshi = Moshi.Builder().build() - adapter = moshi.adapter(AttributionRequest::class.java) - } - - @Test - fun appRequestWithCorrectData() { - // TODO: Update test for new AttributionRequest format - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/EnvironmentRequestTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/EnvironmentRequestTest.kt deleted file mode 100644 index eb3abbc5c..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/EnvironmentRequestTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.qonversion.android.sdk.internal.requests - -import com.qonversion.android.sdk.internal.dto.Environment -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Moshi -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -internal class EnvironmentRequestTest { - - private lateinit var adapter: JsonAdapter - - @Before - fun setup() { - val moshi = Moshi.Builder().build() - adapter = moshi.adapter(Environment::class.java) - } - - @Test - fun appRequestWithCorrectData() { - // TODO: Update test for new Environment format - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/InitRequestTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/InitRequestTest.kt deleted file mode 100644 index a163fa5ca..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/InitRequestTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.qonversion.android.sdk.internal.requests - -import com.qonversion.android.sdk.internal.dto.request.InitRequest -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Moshi -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -internal class InitRequestTest { - - private lateinit var adapter: JsonAdapter - - @Before - fun setup() { - val moshi = Moshi.Builder().build() - adapter = moshi.adapter(InitRequest::class.java) - } - - @Test - fun appRequestWithCorrectData() { - // TODO: Update test for new InitRequest format - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/queue/RequestQueueTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/queue/RequestQueueTest.kt deleted file mode 100644 index fa67c6992..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/queue/RequestQueueTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.qonversion.android.sdk.internal.requests.queue - -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -internal class RequestQueueTest { - - @Test - fun addOneRequestTest() { - // TODO: Update test for new AttributionRequest format - } - - @Test - fun addAndPollOneRequestTest() { - // TODO: Update test for new AttributionRequest format - } - - @Test - fun addAndPollManyRequestTest() { - // TODO: Update test for new AttributionRequest format - } - - @Test - fun addAndPollMixManyRequestTest() { - // TODO: Update test for new AttributionRequest format - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/queue/util.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/queue/util.kt deleted file mode 100644 index 7c5af62f7..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/requests/queue/util.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.qonversion.android.sdk.internal.requests.queue - -internal class Util { - - companion object { - // TODO: Update test for new AttributionRequest format - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/services/QUserInfoServiceTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/services/QUserInfoServiceTest.kt index 6095d3ac0..54bb2136a 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/services/QUserInfoServiceTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/services/QUserInfoServiceTest.kt @@ -286,10 +286,11 @@ internal class QUserInfoServiceTest { verifySequence { mockSharedPreferencesCache.getString(prefsOriginalUserIdKey, null) mockSharedPreferencesCache.getString(prefsUserIdKey, null) + mockSharedPreferencesCache.putString(prefsPartnerIdentityUserIdKey, null) } verify(exactly = 0) { - mockSharedPreferencesCache.putString(any(), any()) + mockSharedPreferencesCache.putString(prefsUserIdKey, any()) } assertEquals("must be false", false, isLogoutNeeded) @@ -316,8 +317,8 @@ internal class QUserInfoServiceTest { verifySequence { mockSharedPreferencesCache.getString(prefsOriginalUserIdKey, null) mockSharedPreferencesCache.getString(prefsUserIdKey, null) - mockSharedPreferencesCache.putString(prefsUserIdKey, originalUserID) mockSharedPreferencesCache.putString(prefsPartnerIdentityUserIdKey, null) + mockSharedPreferencesCache.putString(prefsUserIdKey, originalUserID) } assertEquals("must be true", true, isLogoutNeeded) diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PurchasesCacheTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PurchasesCacheTest.kt index b47046100..ef1502d0a 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PurchasesCacheTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/PurchasesCacheTest.kt @@ -32,18 +32,8 @@ internal class PurchasesCacheTest { @Nested inner class SavePurchase { @Test - fun `should not save purchase when sku type is subs`() { - val purchase = mockPurchase(BillingClient.SkuType.SUBS) - - purchasesCache.savePurchase(purchase) - verify(exactly = 0) { - mockEditor.putString(purchaseKey, any()).apply() - } - } - - @Test - fun `should save purchase when sku type is inapp`() { - val purchase = mockPurchase(BillingClient.SkuType.INAPP) + fun `should save purchase`() { + val purchase = mockPurchase() purchasesCache.savePurchase(purchase) @@ -58,7 +48,7 @@ internal class PurchasesCacheTest { every { mockPrefs.getString(purchaseKey, "") } returns onePurchaseStr - val purchase = mockPurchase(BillingClient.SkuType.INAPP) + val purchase = mockPurchase() purchasesCache.savePurchase(purchase) @@ -76,7 +66,7 @@ internal class PurchasesCacheTest { mockPrefs.getString(purchaseKey, "") } returns fourPurchasesStr - val fifthPurchase = mockPurchase(BillingClient.SkuType.INAPP, "5") + val fifthPurchase = mockPurchase("5") purchasesCache.savePurchase(fifthPurchase) val fourNewestPurchasesStr = "[${generatePurchaseJson("2")},${generatePurchaseJson("3")},${generatePurchaseJson("4")},${generatePurchaseJson("5")}]" @@ -109,7 +99,7 @@ internal class PurchasesCacheTest { mockPrefs.getString(purchaseKey, "") } returns onePurchaseStr - val purchase = mockPurchase(BillingClient.SkuType.INAPP) + val purchase = mockPurchase() val purchases = purchasesCache.loadPurchases() verify(exactly = 1) { @@ -139,7 +129,7 @@ internal class PurchasesCacheTest { @Test fun `should delete purchase from set when it is existed`() { val emptyList = "[]" - val purchase = mockPurchase(BillingClient.SkuType.INAPP) + val purchase = mockPurchase() every { mockPrefs.getString(purchaseKey, any()) @@ -156,7 +146,7 @@ internal class PurchasesCacheTest { @Test fun `should not delete purchase from set when it is not existed`() { val emptyList = "[]" - val purchase = mockPurchase(BillingClient.SkuType.INAPP) + val purchase = mockPurchase() purchasesCache.clearPurchase(purchase) @@ -167,39 +157,13 @@ internal class PurchasesCacheTest { } } - private fun mockPurchase( - @BillingClient.SkuType skuType: String, - originalOrderId: String = "" - ): Purchase { + private fun mockPurchase(originalOrderId: String = ""): Purchase { return Purchase( - detailsToken = "AEuhp4IOz4jzn7ZFK222oIkBaHcEBKYQYmJ6QqguRvyulBm0yv0ntS6hJQx97euC1dBW", - title = "Qonversion In-app Consumable (Qonversion Sample)", - description = "Qonversion In-app Consumable", - productId = "qonversion_inapp_consumable", - type = skuType, - originalPrice = "RUB 75.00", - originalPriceAmountMicros = 75000000, - priceCurrencyCode = "RUB", - price = "75.00", - priceAmountMicros = 75000000, - periodUnit = 0, - periodUnitsCount = 0, - freeTrialPeriod = "", - introductoryAvailable = false, - introductoryPriceAmountMicros = 0, - introductoryPrice = "0.00", - introductoryPriceCycles = 0, - introductoryPeriodUnit = 0, - introductoryPeriodUnitsCount = 0, + storeProductId = "article-test-trial", orderId = "GPA.3375-4436-3573-53474", originalOrderId = "GPA.3375-4436-3573-53474$originalOrderId", - packageName = "com.qonversion.sample", purchaseTime = 1611323804, - purchaseState = 1, purchaseToken = "gfegjilekkmecbonpfjiaakm.AO-J1OxQCaAn0NPlHTh5CoOiXK0p19X7qEymW9SHtssrggp7S9YafjA1oPBPlWO4Ur3W5rtyNJBzIrVoLOb5In0Jxofv4xV_7t1HaUYYd_f8xOBk7nRIY7g", - acknowledged = false, - autoRenewing = false, - paymentMode = 0 ) } @@ -218,33 +182,9 @@ internal class PurchasesCacheTest { } private fun generatePurchaseJson(originalOrderId: String = ""): String { - return "{\"detailsToken\":\"AEuhp4IOz4jzn7ZFK222oIkBaHcEBKYQYmJ6QqguRvyulBm0yv0ntS6hJQx97euC1dBW\"," + - "\"title\":\"Qonversion In-app Consumable (Qonversion Sample)\"," + - "\"description\":\"Qonversion In-app Consumable\"," + - "\"productId\":\"qonversion_inapp_consumable\"," + - "\"type\":\"inapp\"," + - "\"originalPrice\":\"RUB 75.00\"," + - "\"originalPriceAmountMicros\":75000000," + - "\"priceCurrencyCode\":\"RUB\"," + - "\"price\":\"75.00\"," + - "\"priceAmountMicros\":75000000," + - "\"periodUnit\":0," + - "\"periodUnitsCount\":0," + - "\"freeTrialPeriod\":\"\"," + - "\"introductoryAvailable\":false," + - "\"introductoryPriceAmountMicros\":0," + - "\"introductoryPrice\":\"0.00\"," + - "\"introductoryPriceCycles\":0," + - "\"introductoryPeriodUnit\":0," + - "\"introductoryPeriodUnitsCount\":0," + - "\"orderId\":\"GPA.3375-4436-3573-53474\"," + + return "{\"orderId\":\"GPA.3375-4436-3573-53474\"," + "\"originalOrderId\":\"GPA.3375-4436-3573-53474$originalOrderId\"," + - "\"packageName\":\"com.qonversion.sample\"," + "\"purchaseTime\":1611323804," + - "\"purchaseState\":1," + - "\"purchaseToken\":\"gfegjilekkmecbonpfjiaakm.AO-J1OxQCaAn0NPlHTh5CoOiXK0p19X7qEymW9SHtssrggp7S9YafjA1oPBPlWO4Ur3W5rtyNJBzIrVoLOb5In0Jxofv4xV_7t1HaUYYd_f8xOBk7nRIY7g\"," + - "\"acknowledged\":false," + - "\"autoRenewing\":false," + - "\"paymentMode\":0}" + "\"purchaseToken\":\"gfegjilekkmecbonpfjiaakm.AO-J1OxQCaAn0NPlHTh5CoOiXK0p19X7qEymW9SHtssrggp7S9YafjA1oPBPlWO4Ur3W5rtyNJBzIrVoLOb5In0Jxofv4xV_7t1HaUYYd_f8xOBk7nRIY7g\"}" } } \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheTest.kt index f2d3126cf..ceb2c870f 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/SharedPreferencesCacheTest.kt @@ -189,10 +189,7 @@ internal class SharedPreferencesCacheTest { prefsCache.putObject(key, value, mockAdapter) - verifyOrder { - mockEditor.putString(key, valueJsonStr) - mockEditor.apply() - } + verify { mockEditor.putString(key, valueJsonStr) } } @Test diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/TokenExtractorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/TokenExtractorTest.kt deleted file mode 100644 index 72d4db240..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/TokenExtractorTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.qonversion.android.sdk.internal.storage - -import com.qonversion.android.sdk.internal.dto.BaseResponse -import com.qonversion.android.sdk.internal.extractor.Extractor -import com.qonversion.android.sdk.internal.extractor.TokenExtractor -import com.qonversion.android.sdk.internal.storage.Util.Companion.CLIENT_ID -import com.qonversion.android.sdk.internal.storage.Util.Companion.CLIENT_TARGET_ID -import com.qonversion.android.sdk.internal.storage.Util.Companion.CLIENT_UID -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import retrofit2.Response - -@RunWith(RobolectricTestRunner::class) -internal class TokenExtractorTest { - - private lateinit var tokenExtractor: Extractor>> - - @Before - fun setup() { - tokenExtractor = TokenExtractor() - } - - @Test - fun extractFromEmptyResponse() { - val response = Response.success(BaseResponse( - true, - com.qonversion.android.sdk.internal.dto.Response( - "", - "", - "" - ) - )) - val token = tokenExtractor.extract(response) - Assert.assertEquals(token, "") - } - - @Test - fun extractFromNullResponseBody() { - val token = tokenExtractor.extract(Response.success(null)) - Assert.assertEquals(token, "") - } - - @Test - fun extractFromNullResponse() { - val token = tokenExtractor.extract(null) - Assert.assertEquals(token, "") - } - - @Test - fun extractFromCorrectResponse() { - val response = Response.success(BaseResponse( - true, - com.qonversion.android.sdk.internal.dto.Response( - CLIENT_ID, - CLIENT_UID, - CLIENT_TARGET_ID - ) - )) - val token = tokenExtractor.extract(response) - Assert.assertEquals(token, CLIENT_UID) - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/TokenStorageTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/TokenStorageTest.kt deleted file mode 100644 index 83b5188de..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/TokenStorageTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.qonversion.android.sdk.internal.storage - -import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder -import com.qonversion.android.sdk.internal.dto.BaseResponse -import com.qonversion.android.sdk.internal.dto.Response -import com.qonversion.android.sdk.internal.extractor.Extractor -import com.qonversion.android.sdk.internal.extractor.TokenExtractor -import com.qonversion.android.sdk.internal.storage.Util.Companion.CLIENT_ID -import com.qonversion.android.sdk.internal.storage.Util.Companion.CLIENT_TARGET_ID -import com.qonversion.android.sdk.internal.storage.Util.Companion.CLIENT_UID -import com.qonversion.android.sdk.internal.validator.TokenValidator -import com.qonversion.android.sdk.internal.validator.Validator -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -internal class TokenStorageTest { - - private lateinit var tokenStorage: TokenStorage - private lateinit var tokenExtractor: Extractor>> - - @Before - fun setup() { - tokenStorage = TokenStorage(SPMockBuilder().createSharedPreferences(), - TokenValidator() as Validator - ) - tokenExtractor = TokenExtractor() - } - - @Test - fun saveNotEmptyTokenWhenExistTokenEmpty() { - val emptyResponse = retrofit2.Response.success(BaseResponse( - true, - Response( - "", - "", - "" - ) - )) - val emptyToken = tokenExtractor.extract(emptyResponse) - tokenStorage.save(emptyToken) - Assert.assertEquals("", tokenStorage.load()) - Assert.assertFalse(tokenStorage.exist()) - - val response = retrofit2.Response.success(BaseResponse( - true, - Response( - CLIENT_ID, - CLIENT_UID, - CLIENT_TARGET_ID - ) - )) - val notEmptyToken = tokenExtractor.extract(response) - - tokenStorage.save(notEmptyToken) - Assert.assertEquals(CLIENT_UID, tokenStorage.load()) - Assert.assertTrue(tokenStorage.exist()) - } - - - @Test - fun saveEmptyTokenWhenExistTokenEmpty() { - val firstEmptyResponse = retrofit2.Response.success(BaseResponse( - true, - Response( - "", - "", - "" - ) - )) - val firstEmptyToken = tokenExtractor.extract(firstEmptyResponse) - - tokenStorage.save(firstEmptyToken) - Assert.assertEquals("", tokenStorage.load()) - Assert.assertFalse(tokenStorage.exist()) - - val secondEmptyResponse = retrofit2.Response.success(BaseResponse( - true, - Response( - "", - "", - "" - ) - )) - val secondEmptyToken = tokenExtractor.extract(secondEmptyResponse) - tokenStorage.save(secondEmptyToken) - Assert.assertEquals("", tokenStorage.load()) - Assert.assertFalse(tokenStorage.exist()) - } - - @Test - fun saveEmptyTokenWhenNotEmptyTokenExist() { - val response = retrofit2.Response.success(BaseResponse( - true, - Response( - CLIENT_ID, - CLIENT_UID, - CLIENT_TARGET_ID - ) - )) - val notEmptyToken = tokenExtractor.extract(response) - - tokenStorage.save(notEmptyToken) - Assert.assertEquals(CLIENT_UID, tokenStorage.load()) - Assert.assertTrue(tokenStorage.exist()) - - val emptyResponse = retrofit2.Response.success(BaseResponse( - true, - Response( - "", - "", - "" - ) - )) - val emptyToken = tokenExtractor.extract(emptyResponse) - tokenStorage.save(emptyToken) - Assert.assertEquals(CLIENT_UID, tokenStorage.load()) - Assert.assertTrue(tokenStorage.exist()) - } - - - @Test - fun saveNotEmptyToken() { - val response = retrofit2.Response.success(BaseResponse( - true, - Response( - CLIENT_ID, - CLIENT_UID, - CLIENT_TARGET_ID - ) - )) - val notEmptyToken = tokenExtractor.extract(response) - tokenStorage.save(notEmptyToken) - Assert.assertEquals(CLIENT_UID, tokenStorage.load()) - Assert.assertTrue(tokenStorage.exist()) - } -} \ No newline at end of file diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/util.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/util.kt index 5c8a79842..69080e4b2 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/util.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/storage/util.kt @@ -6,9 +6,7 @@ import com.qonversion.android.sdk.dto.offerings.QOffering import com.qonversion.android.sdk.dto.offerings.QOfferingTag import com.qonversion.android.sdk.dto.offerings.QOfferings import com.qonversion.android.sdk.dto.products.QProduct -import com.qonversion.android.sdk.dto.products.QProductDuration import com.qonversion.android.sdk.internal.dto.QProductRenewState -import com.qonversion.android.sdk.dto.products.QProductType import com.qonversion.android.sdk.internal.dto.QDateAdapter import com.qonversion.android.sdk.internal.dto.QEligibilityAdapter import com.qonversion.android.sdk.internal.dto.QEligibilityStatusAdapter @@ -20,9 +18,7 @@ import com.qonversion.android.sdk.internal.dto.QPermission import com.qonversion.android.sdk.internal.dto.QEntitlementSourceAdapter import com.qonversion.android.sdk.internal.dto.QLaunchResult import com.qonversion.android.sdk.internal.dto.QPermissionsAdapter -import com.qonversion.android.sdk.internal.dto.QProductDurationAdapter import com.qonversion.android.sdk.internal.dto.QProductRenewStateAdapter -import com.qonversion.android.sdk.internal.dto.QProductTypeAdapter import com.qonversion.android.sdk.internal.dto.QProductsAdapter import com.squareup.moshi.Moshi import java.util.* @@ -41,20 +37,17 @@ internal class Util { "main" to QProduct( qonversionID = "main", storeID = "qonversion_subs_weekly", - type = QProductType.Trial, - duration = QProductDuration.Weekly + basePlanID = null, ), "in_app" to QProduct( qonversionID = "in_app", storeID = "qonversion_inapp_consumable", - type = QProductType.InApp, - duration = null + basePlanID = null, ), "annual" to QProduct( qonversionID = "annual", storeID = "qonversion_subs_annual", - type = QProductType.Trial, - duration = QProductDuration.Annual + basePlanID = null, ) ), permissions = mapOf( @@ -95,8 +88,7 @@ internal class Util { "in_app" to QProduct( qonversionID = "in_app", storeID = "qonversion_inapp_consumable", - type = QProductType.InApp, - duration = null + basePlanID = null, ) ), offerings = QOfferings( @@ -107,14 +99,12 @@ internal class Util { QProduct( qonversionID = "in_app", storeID = "qonversion_inapp_consumable", - type = QProductType.InApp, - duration = null + basePlanID = null, ), QProduct( qonversionID = "main", storeID = "qonversion_subs_weekly", - type = QProductType.Trial, - duration = QProductDuration.Weekly + basePlanID = null, ) ) ), @@ -126,14 +116,12 @@ internal class Util { QProduct( qonversionID = "in_app", storeID = "qonversion_inapp_consumable", - type = QProductType.InApp, - duration = null + basePlanID = null, ), QProduct( qonversionID = "main", storeID = "qonversion_subs_weekly", - type = QProductType.Trial, - duration = QProductDuration.Weekly + basePlanID = null, ) ) ) @@ -146,17 +134,15 @@ internal class Util { "\"products\":[{\"id\":\"main\",\"store_id\":\"qonversion_subs_weekly\",\"type\":0,\"duration\":0}," + "{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2}," + "{\"id\":\"annual\",\"store_id\":\"qonversion_subs_annual\",\"type\":0,\"duration\":4}]," + - "\"permissions\":[{\"id\":\"standart\",\"associated_product\":\"in_app\",\"renew_state\":-1,\"started_timestamp\":1612880300,\"source\":\"playstore\",\"active\":1},{\"id\":\"Test Permission\",\"associated_product\":\"in_app\",\"renew_state\":-1,\"started_timestamp\":1612880300,\"source\":\"appstore\",\"active\":1}],\"user_products\":[{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2}],\"experiments\":[]," + - "\"offerings\":[{\"id\":\"main\",\"tag\":1,\"products\":[{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2},{\"id\":\"main\",\"store_id\":\"qonversion_subs_weekly\",\"type\":0,\"duration\":0}],\"experiment\":{\"uid\":\"secondary\",\"attached\":false}" + + "\"permissions\":[{\"id\":\"standart\",\"associated_product\":\"in_app\",\"renew_state\":-1,\"started_timestamp\":1612880300,\"source\":\"playstore\",\"active\":1},{\"id\":\"Test Permission\",\"associated_product\":\"in_app\",\"renew_state\":-1,\"started_timestamp\":1612880300,\"source\":\"appstore\",\"active\":1}],\"user_products\":[{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2}]," + + "\"offerings\":[{\"id\":\"main\",\"tag\":1,\"products\":[{\"id\":\"in_app\",\"store_id\":\"qonversion_inapp_consumable\",\"type\":2},{\"id\":\"main\",\"store_id\":\"qonversion_subs_weekly\",\"type\":0,\"duration\":0}]" + "}]}" fun buildMoshi(): Moshi = Moshi.Builder() - .add(QProductDurationAdapter()) .add(QDateAdapter()) .add(QProductsAdapter()) .add(QPermissionsAdapter()) - .add(QProductTypeAdapter()) .add(QProductRenewStateAdapter()) .add(QEntitlementSourceAdapter()) .add(QOfferingsAdapter()) diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/validator/RequestValidatorTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/validator/RequestValidatorTest.kt deleted file mode 100644 index e76b9ae68..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/validator/RequestValidatorTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.qonversion.android.sdk.internal.validator - - -import org.junit.Test - -internal class RequestValidatorTest { - - @Test - fun validateWithNotEmptyClientUid() { - // TODO: Update test for new AttributionRequest format - } - - @Test - fun validateWithEmptyClientUid() { - // TODO: Update test for new AttributionRequest format - } - -} diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/validator/util.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/validator/util.kt deleted file mode 100644 index 0dfa068e9..000000000 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/validator/util.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.qonversion.android.sdk.internal.validator - -internal class Util { - - companion object { - // TODO: Update test for new AttributionRequest format - } -} \ No newline at end of file