From 7457834ccd3bd32180e5558d7fff3d91e9f87e2d Mon Sep 17 00:00:00 2001 From: Luca Garbolino <387293+lgarbo@users.noreply.github.com> Date: Mon, 30 Oct 2023 17:09:20 +0100 Subject: [PATCH] Release v1.6.0 --- README.md | 4 +- .../io/glassfy/androidsdk/Configuration.kt | 4 +- .../java/io/glassfy/paywall/Configuration.kt | 4 +- glassfy/build.gradle.kts | 11 +- .../java/io/glassfy/androidsdk/Glassfy.kt | 11 - .../io/glassfy/androidsdk/GlassfyError.kt | 2 +- .../glassfy/androidsdk/internal/GManager.kt | 163 +++--- .../internal/billing/IBillingService.kt | 15 +- .../internal/billing/SkuDetailsQuery.kt | 31 ++ .../play/IPlayBillingPurchaseDelegate.kt | 7 + .../{google => play}/PlayBillingResource.kt | 5 +- .../play/PlayBillingServiceProvider.kt | 29 ++ .../play/billing/PlayBillingClientWrapper.kt | 464 ++++++++++++++++++ .../play/billing/PlayBillingService.kt | 250 ++++++++++ .../play/billing/SubscriptionOffersUseCase.kt | 10 + .../billing/mapper/AccountIdentifierMapper.kt | 11 + .../mapper/BillingPeriodConversions.kt | 17 + .../play/billing/mapper/GlassfyErrorMapper.kt | 67 +++ .../billing/mapper/HistoryPurchaseMapper.kt | 22 + .../play/billing/mapper/PurchaseMapper.kt | 25 + .../play/billing/mapper/SkuDetailsMapper.kt | 160 ++++++ .../legacy}/IPlayBillingPurchaseDelegate.kt | 0 .../play/legacy/LegacyProrationMode.kt | 13 + .../legacy/PlayBilling4ClientWrapper.kt} | 19 +- .../legacy/PlayBilling4Service.kt} | 124 +++-- .../internal/network/IApiService.kt | 8 +- .../network/model/AccountableSkuDto.kt | 6 + .../internal/network/model/OfferingDto.kt | 7 +- .../network/model/SkuDetailsParamsDto.kt | 31 ++ .../internal/network/model/SkuDto.kt | 33 +- .../model/request/InitializeRequest.kt | 39 +- .../model/request/SkuDetailsRequest.kt | 40 ++ .../network/model/request/TokenRequest.kt | 45 +- .../model/response/OfferingsResponse.kt | 3 +- .../network/model/utils/DTOException.kt | 2 +- .../network/model/utils/ProductTypeAdapter.kt | 24 + .../internal/repository/IRepository.kt | 3 +- .../internal/repository/Repository.kt | 39 +- .../androidsdk/model/AccountableSku.kt | 2 + .../glassfy/androidsdk/model/ProductType.kt | 24 + .../androidsdk/model/PurchaseHistory.kt | 2 +- .../java/io/glassfy/androidsdk/model/Sku.kt | 22 +- .../io/glassfy/androidsdk/model/SkuDetails.kt | 32 +- .../androidsdk/model/SkuDetailsParams.kt | 8 + .../androidsdk/model/SubscriptionUpdate.kt | 34 +- .../androidsdk/paywall/DurationFormatter.kt | 2 +- .../androidsdk/paywall/PaywallResponse.kt | 3 +- .../java/io/glassfy/androidsdk/GlassfyTest.kt | 2 +- 48 files changed, 1606 insertions(+), 273 deletions(-) create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/SkuDetailsQuery.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/IPlayBillingPurchaseDelegate.kt rename glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/{google => play}/PlayBillingResource.kt (71%) create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/PlayBillingServiceProvider.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/PlayBillingClientWrapper.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/PlayBillingService.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/SubscriptionOffersUseCase.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/AccountIdentifierMapper.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/BillingPeriodConversions.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/GlassfyErrorMapper.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/HistoryPurchaseMapper.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/PurchaseMapper.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/SkuDetailsMapper.kt rename glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/{google => play/legacy}/IPlayBillingPurchaseDelegate.kt (100%) create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/LegacyProrationMode.kt rename glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/{google/PlayBillingClientWrapper.kt => play/legacy/PlayBilling4ClientWrapper.kt} (94%) rename glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/{google/PlayBillingService.kt => play/legacy/PlayBilling4Service.kt} (67%) create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/SkuDetailsParamsDto.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/SkuDetailsRequest.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/utils/ProductTypeAdapter.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/model/ProductType.kt create mode 100644 glassfy/src/main/java/io/glassfy/androidsdk/model/SkuDetailsParams.kt diff --git a/README.md b/README.md index fd75c1b..1a48982 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Add the dependency to your module-level `build.gradle`: ``` dependencies { [...] - implementation 'io.glassfy:androidsdk:1.5.2' + implementation 'io.glassfy:androidsdk:1.6.0' } ``` @@ -33,7 +33,7 @@ Add the dependency to your module-level `build.gradle.kts`: ``` dependencies { [...] - implementation("io.glassfy:androidsdk:1.5.2") + implementation("io.glassfy:androidsdk:1.6.0") } ``` diff --git a/buildSrc/src/main/java/io/glassfy/androidsdk/Configuration.kt b/buildSrc/src/main/java/io/glassfy/androidsdk/Configuration.kt index a53855a..5b1c842 100644 --- a/buildSrc/src/main/java/io/glassfy/androidsdk/Configuration.kt +++ b/buildSrc/src/main/java/io/glassfy/androidsdk/Configuration.kt @@ -5,8 +5,8 @@ object Configuration { const val targetSdk = 33 const val minSdk = 21 private const val majorVersion = 1 - private const val minorVersion = 5 - private const val patchVersion = 2 + private const val minorVersion = 6 + private const val patchVersion = 0 const val versionName = "$majorVersion.$minorVersion.$patchVersion" const val snapshotVersionName = "${versionName}-SNAPSHOT" const val artifactGroup = "io.glassfy" diff --git a/buildSrc/src/main/java/io/glassfy/paywall/Configuration.kt b/buildSrc/src/main/java/io/glassfy/paywall/Configuration.kt index 48c6b62..511e0fd 100644 --- a/buildSrc/src/main/java/io/glassfy/paywall/Configuration.kt +++ b/buildSrc/src/main/java/io/glassfy/paywall/Configuration.kt @@ -5,8 +5,8 @@ object Configuration { const val targetSdk = 33 const val minSdk = 24 private const val majorVersion = 1 - private const val minorVersion = 5 - private const val patchVersion = 2 + private const val minorVersion = 6 + private const val patchVersion = 0 const val versionName = "$majorVersion.$minorVersion.$patchVersion" const val snapshotVersionName = "${versionName}-SNAPSHOT" const val artifactGroup = "io.glassfy" diff --git a/glassfy/build.gradle.kts b/glassfy/build.gradle.kts index 9a5b72d..056a509 100644 --- a/glassfy/build.gradle.kts +++ b/glassfy/build.gradle.kts @@ -60,17 +60,16 @@ android { dependencies { // Lifecycle - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.1") - implementation("androidx.lifecycle:lifecycle-process:2.4.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") + implementation("androidx.lifecycle:lifecycle-process:2.6.1") // Android Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") // BillingClient - implementation("com.android.billingclient:billing-ktx:5.2.1") + implementation("com.android.billingclient:billing-ktx:6.0.1") // Retrofit + OKHttp + Moshi -// implementation("com.squareup.okhttp3:logging-interceptor:4.11.0") implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("com.squareup.moshi:moshi:1.15.0") @@ -81,8 +80,8 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") - androidTestImplementation("androidx.test.ext:junit:1.1.3") - androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") } // Sources diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/Glassfy.kt b/glassfy/src/main/java/io/glassfy/androidsdk/Glassfy.kt index ee19ae0..62c2d9a 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/Glassfy.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/Glassfy.kt @@ -165,17 +165,6 @@ object Glassfy { customScope.runAndPostResult(callback) { manager.skubase(identifier, store) } } - /** - * Fetch Sku - * - * @param identifier Store product identifier - * @param callback Completion callback with results - */ - @JvmStatic - fun skuWithProductId(identifier: String, callback: SkuCallback) { - customScope.runAndPostResult(callback) { manager.skuWithProductId(identifier) } - } - /** * Chek permissions status of the user * diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/GlassfyError.kt b/glassfy/src/main/java/io/glassfy/androidsdk/GlassfyError.kt index 63f7981..a2e3393 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/GlassfyError.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/GlassfyError.kt @@ -6,7 +6,7 @@ enum class GlassfyErrorCode(internal val internalCode: Int? = null) { SDKNotInitialized, MissingPurchase, PendingPurchase, - Purchasing, + Purchasing(-199), StoreError, UserCancelPurchase, diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/GManager.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/GManager.kt index 6154b44..edddea2 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/GManager.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/GManager.kt @@ -8,13 +8,16 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import com.android.billingclient.api.BillingClient import com.android.billingclient.api.Purchase.PurchaseState.PURCHASED import com.squareup.moshi.Moshi -import io.glassfy.androidsdk.* +import io.glassfy.androidsdk.Glassfy +import io.glassfy.androidsdk.GlassfyErrorCode +import io.glassfy.androidsdk.LogLevel +import io.glassfy.androidsdk.PurchaseDelegate import io.glassfy.androidsdk.internal.billing.IBillingPurchaseDelegate import io.glassfy.androidsdk.internal.billing.IBillingService -import io.glassfy.androidsdk.internal.billing.google.PlayBillingService +import io.glassfy.androidsdk.internal.billing.SkuDetailsQuery +import io.glassfy.androidsdk.internal.billing.play.PlayBillingServiceProvider import io.glassfy.androidsdk.internal.cache.CacheManager import io.glassfy.androidsdk.internal.cache.ICacheManager import io.glassfy.androidsdk.internal.device.DeviceManager @@ -113,7 +116,7 @@ internal class GManager : LifecycleEventObserver, IBillingPurchaseDelegate { val deviceManager: IDeviceManager = DeviceManager(appContext.applicationContext) val apiService: IApiService = makeApiService(cacheManager, deviceManager, apiKey) repository = Repository(apiService) - billingService = PlayBillingService(this, appContext, watcherMode) + billingService = PlayBillingServiceProvider.billingService(this, appContext, watcherMode) val res = _initialize(crossPlatformSdkFramework, crossPlatformSdkVersion) if (res.err != null) { @@ -151,14 +154,11 @@ internal class GManager : LifecycleEventObserver, IBillingPurchaseDelegate { internal suspend fun restore(): Resource = withSdkInitializedOrError { _restore() } internal suspend fun sku(identifier: String): Resource = - withSdkInitializedOrError { _playstoresku(identifier) } + withSdkInitializedOrError { _playStoreSku(identifier) } internal suspend fun skubase(identifier: String, store: Store): Resource = withSdkInitializedOrError { _skubase(identifier, store) } - internal suspend fun skuWithProductId(identifier: String): Resource = - withSdkInitializedOrError { _skuWithProductId(identifier) } - internal suspend fun offerings(): Resource = withSdkInitializedOrError { _offerings() } @@ -281,51 +281,93 @@ internal class GManager : LifecycleEventObserver, IBillingPurchaseDelegate { } private suspend fun _offerings(): Resource { - val offRes = repository.offerings() + val offRes = repository.offerings(billingService.version) if (offRes.err != null) return offRes if (offRes.data == null) return Resource.Error(GlassfyErrorCode.NotFoundOnGlassfy.toError()) - val detailRes = offRes.data.all.flatMap { it.skus.map { s -> s.productId } }.toSet() - .let { billingService.skuDetails(it) } - if (detailRes.err != null) return Resource.Error(detailRes.err) - if (detailRes.data == null) return Resource.Error(GlassfyErrorCode.NotFoundOnStore.toError()) + val queries = + offRes.data.all.flatMap { off -> SkuDetailsQuery.fromSkus(off.skus) }.distinct() + val detailRes = billingService.skusDetails(queries).data ?: emptyList() for (o in offRes.data.all) { - o.skus_ = o.skus.filter { sku -> detailRes.data.map { it.sku }.contains(sku.productId) } - .map { s -> - detailRes.data.find { detail -> detail.sku == s.productId } - ?.let { detail -> s.product = detail } - return@map s + o.skus_ = o.skus.mapNotNull { s -> + matchSkuWithStoreDetails(s, detailRes)?.let { + s.apply { + product = it + } } + } + } + return Resource.Success(offRes.data) + } + + private fun matchSkuWithStoreDetails(s: Sku, storeDetails: List) = + matchSkuWithStoreDetailsAndFallback(s, storeDetails) + + private fun matchSkuWithStoreDetailsAndFallback( + s: Sku, storeDetails: List + ): SkuDetails? { + return storeDetails.find { + it.sku == s.skuParams.productId && it.basePlanId == s.skuParams.basePlanId.orEmpty() && it.offerId == s.skuParams.offerId.orEmpty() + }?.also { + Logger.logDebug( + "Sku Found ${s.skuId}: " + "\t${s.skuParams.productId} - ${s.skuParams.basePlanId} - ${s.skuParams.offerId}" + ) + } ?: storeDetails.find { + it.sku == s.fallbackSkuParams?.productId && it.basePlanId == s.fallbackSkuParams.basePlanId.orEmpty() && it.offerId == s.fallbackSkuParams.offerId.orEmpty() + }.also { + if (it == null) { + Logger.logDebug( + "Sku NOT Found ${s.skuId}: " + "\t${s.skuParams.productId} - ${s.skuParams.basePlanId} - ${s.skuParams.offerId}" + ) + } else { + Logger.logDebug( + "Sku Fallback ${s.skuId}: " + "\n\t${s.skuParams.productId} - ${s.skuParams.basePlanId} - ${s.skuParams.offerId}" + "\n\t" + + "${it.sku} - ${it.basePlanId.ifEmpty { null }} - ${it.offerId.ifEmpty { null }}" + ) + } } - return offRes } private suspend fun _purchase( activity: Activity, sku: Sku, upgradeSku: SubscriptionUpdate?, accountId: String? ): Resource { if (upgradeSku != null) { + val purchases = billingService.allPurchases() + if (purchases.err != null) return Resource.Error(purchases.err) + val res = repository.skuByIdentifier(upgradeSku.originalSku) if (res.err != null) return Resource.Error(res.err) - val purchases = billingService.allPurchases() - if (purchases.err != null) return Resource.Error(purchases.err) - purchases.data?.firstOrNull { it.skus.contains(res.data?.productId ?: "") } - ?.let { upgradeSku.purchaseToken = it.purchaseToken } + listOfNotNull( + res.data?.skuParams?.productId, res.data?.fallbackSkuParams?.productId + ).firstNotNullOfOrNull { purchasedProduct -> + purchases.data?.firstOrNull { purchase -> + purchase.skus.contains(purchasedProduct) + } + }?.also { purchase -> + upgradeSku.purchaseToken = purchase.purchaseToken + } if (upgradeSku.purchaseToken.isEmpty()) { - return Resource.Error(GlassfyErrorCode.MissingPurchase.toError("purchaseToken not found for ${upgradeSku.originalSku}")) + return Resource.Error( + GlassfyErrorCode.MissingPurchase.toError( + "purchaseToken not found for ${upgradeSku.originalSku}" + ) + ) } } - val result = billingService.purchase(activity, sku.product, upgradeSku, accountId) + val result = billingService.purchase( + activity, sku.product, upgradeSku, accountId + ) if (result.err != null) return Resource.Error(result.err) if (result.data == null) return Resource.Error(GlassfyErrorCode.MissingPurchase.toError()) if (result.data.purchaseState != PURCHASED) return Resource.Error(GlassfyErrorCode.PendingPurchase.toError()) return result.data.let { p -> - val tokenReq = TokenRequest.from(p, sku.product.type == BillingClient.SkuType.SUBS) - tokenReq.offeringId = sku.offeringId + val tokenReq = TokenRequest.from(p, sku.isSubscription(), sku.offeringId, sku.product) + repository.token(tokenReq).apply { data?.permissions?.installationId_ = cacheManager.installationId } @@ -346,7 +388,7 @@ internal class GManager : LifecycleEventObserver, IBillingPurchaseDelegate { private suspend fun _skubase(identifier: String, store: Store): Resource { if (store == Store.PlayStore) { - return _playstoresku(identifier) + return _playStoreSku(identifier) } val skuRes = repository.skuByIdentifierAndStore(identifier, store) @@ -355,40 +397,19 @@ internal class GManager : LifecycleEventObserver, IBillingPurchaseDelegate { return Resource.Success(skuRes.data) } - private suspend fun _playstoresku(identifier: String): Resource { + private suspend fun _playStoreSku(identifier: String): Resource { val skuRes = repository.skuByIdentifier(identifier) if (skuRes.err != null) return skuRes if (skuRes.data == null) return Resource.Error(GlassfyErrorCode.NotFoundOnGlassfy.toError()) - return skuRes.data.let { - val detailRes = billingService.skuDetails(setOf(it.productId)) - if (detailRes.err != null) return Resource.Error(detailRes.err) - if (detailRes.data.isNullOrEmpty()) return Resource.Error( - GlassfyErrorCode.NotFoundOnStore.toError() - ) - - Resource.Success(it.apply { - product = detailRes.data.first() - }) - } - } - - private suspend fun _skuWithProductId(identifier: String): Resource { - val skuRes = repository.skuByProductId(identifier) - if (skuRes.err != null) return skuRes - if (skuRes.data == null) return Resource.Error(GlassfyErrorCode.NotFoundOnGlassfy.toError()) - - return skuRes.data.let { - val detailRes = billingService.skuDetails(setOf(it.productId)) - if (detailRes.err != null) return Resource.Error(detailRes.err) - if (detailRes.data.isNullOrEmpty()) return Resource.Error( - GlassfyErrorCode.NotFoundOnStore.toError() - ) + val detailRes = billingService.skusDetails(SkuDetailsQuery.fromSku(skuRes.data)) + if (detailRes.err != null) return Resource.Error(detailRes.err) + if (detailRes.data == null) return Resource.Error(GlassfyErrorCode.NotFoundOnGlassfy.toError()) - Resource.Success(it.apply { - product = detailRes.data.first() - }) - } + return matchSkuWithStoreDetails(skuRes.data, detailRes.data)?.let { + skuRes.data.product = it + Resource.Success(skuRes.data) + } ?: Resource.Error(GlassfyErrorCode.NotFoundOnGlassfy.toError()) } private suspend fun _connectCustomSubscriber(customId: String?): Resource { @@ -418,22 +439,21 @@ internal class GManager : LifecycleEventObserver, IBillingPurchaseDelegate { if (pRes.err != null) return Resource.Error(pRes.err) if (pRes.data == null || pRes.data.skus.isEmpty()) return Resource.Error(GlassfyErrorCode.NotFoundOnGlassfy.toError()) - val dRes = - pRes.data.skus.map { s -> s.productId }.toSet().let { billingService.skuDetails(it) } + val dRes = billingService.skusDetails(SkuDetailsQuery.fromSkus(pRes.data.skus)) if (dRes.err != null) return Resource.Error(dRes.err) if (dRes.data == null) return Resource.Error(GlassfyErrorCode.NotFoundOnStore.toError()) - pRes.data.skus = - pRes.data.skus.filter { sku -> dRes.data.map { it.sku }.contains(sku.productId) } - pRes.data.skus.forEach { s -> - dRes.data.find { detail -> detail.sku == s.productId } - ?.let { detail -> s.product = detail } + pRes.data.skus = pRes.data.skus.mapNotNull { s -> + matchSkuWithStoreDetails(s, dRes.data)?.let { + s.apply { + product = it + } + } } - return Resource.Success(pRes.data) } - /// LifecycleEventObserver +/// LifecycleEventObserver override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { @@ -450,7 +470,7 @@ internal class GManager : LifecycleEventObserver, IBillingPurchaseDelegate { private suspend fun onStartProcessState() = withSdkInitialized()?.also { repository.lastSeen() } - /// Utils +/// Utils private fun makeApiService( cacheManager: ICacheManager, deviceManager: IDeviceManager, apiKey: String @@ -466,11 +486,14 @@ internal class GManager : LifecycleEventObserver, IBillingPurchaseDelegate { val r = c.request().newBuilder().header("Authorization", "Bearer $apiKey").url(url) .build() c.proceed(r) - }.build() + } +// .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + .build() val moshi = - Moshi.Builder().add(EntitlementAdapter()).add(StoreAdapter()).add(StoreInfoAdapter()) - .add(EventTypeAdapter()).add(UserPropertiesAdapter()).add(PaywallTypeAdapter()) + Moshi.Builder().add(EntitlementAdapter()).add(ProductTypeAdapter()).add(StoreAdapter()) + .add(StoreInfoAdapter()).add(EventTypeAdapter()).add(UserPropertiesAdapter()) + .add(PaywallTypeAdapter()) // .addLast(KotlinJsonAdapterFactory()) if not using Codegen, use Reflection (2.5 MiB .jar file) .build() diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/IBillingService.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/IBillingService.kt index 3e5531a..0a198fc 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/IBillingService.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/IBillingService.kt @@ -8,24 +8,27 @@ import io.glassfy.androidsdk.model.SkuDetails import io.glassfy.androidsdk.model.SubscriptionUpdate internal interface IBillingService { + val version: Int val delegate: IBillingPurchaseDelegate - suspend fun allPurchaseHistory(): Resource> + suspend fun inAppPurchaseHistory(): Resource> suspend fun subsPurchaseHistory(): Resource> + suspend fun allPurchaseHistory(): Resource> - suspend fun allPurchases(): Resource> suspend fun inAppPurchases(): Resource> suspend fun subsPurchases(): Resource> - - suspend fun skuDetails(skuList: Set): Resource> + suspend fun allPurchases(): Resource> + + suspend fun skuDetails(query: SkuDetailsQuery): Resource + suspend fun skusDetails(queries: List): Resource> suspend fun purchase( activity: Activity, - sku: SkuDetails, + product: SkuDetails, update: SubscriptionUpdate? = null, accountId: String? = null ): Resource suspend fun consume(purchaseToken: String): Resource suspend fun acknowledge(purchaseToken: String): Resource -} \ No newline at end of file +} diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/SkuDetailsQuery.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/SkuDetailsQuery.kt new file mode 100644 index 0000000..64b76c8 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/SkuDetailsQuery.kt @@ -0,0 +1,31 @@ +package io.glassfy.androidsdk.internal.billing + +import io.glassfy.androidsdk.model.ProductType +import io.glassfy.androidsdk.model.Sku + +internal data class SkuDetailsQuery( + val productId: String, + val basePlanId: String?, + val offerId: String?, + val productType: ProductType +) { + companion object { + fun fromSkus(skus: List): List = skus.flatMap { fromSku(it) }.distinct() + + fun fromSku(sku: Sku): List = listOfNotNull(sku.skuParams.run { + SkuDetailsQuery( + productId = productId, + basePlanId = basePlanId, + offerId = offerId, + productType = productType + ) + }, sku.fallbackSkuParams?.run { + SkuDetailsQuery( + productId = productId, + basePlanId = basePlanId, + offerId = offerId, + productType = productType + ) + }).distinct() + } +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/IPlayBillingPurchaseDelegate.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/IPlayBillingPurchaseDelegate.kt new file mode 100644 index 0000000..b9e73c0 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/IPlayBillingPurchaseDelegate.kt @@ -0,0 +1,7 @@ +package io.glassfy.androidsdk.internal.billing.play + +import com.android.billingclient.api.Purchase + +internal interface IPlayBillingPurchaseDelegate { + suspend fun onPlayBillingPurchasePurchase(p: Purchase, productType: String) +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingResource.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/PlayBillingResource.kt similarity index 71% rename from glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingResource.kt rename to glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/PlayBillingResource.kt index 144f1f6..ec59c52 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingResource.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/PlayBillingResource.kt @@ -1,9 +1,10 @@ -package io.glassfy.androidsdk.internal.billing.google +package io.glassfy.androidsdk.internal.billing.play import com.android.billingclient.api.BillingResult internal sealed class PlayBillingResource( - val data: T? = null, val err: BillingResult? = null + val data: T? = null, + val err: BillingResult? = null ) { internal class Success(data: T) : PlayBillingResource(data) internal class Error(err: BillingResult, data: T? = null) : PlayBillingResource(data, err) diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/PlayBillingServiceProvider.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/PlayBillingServiceProvider.kt new file mode 100644 index 0000000..2dce264 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/PlayBillingServiceProvider.kt @@ -0,0 +1,29 @@ +package io.glassfy.androidsdk.internal.billing.play + +import android.content.Context +import io.glassfy.androidsdk.internal.billing.IBillingPurchaseDelegate +import io.glassfy.androidsdk.internal.billing.IBillingService +import io.glassfy.androidsdk.internal.billing.play.billing.PlayBillingService +import io.glassfy.androidsdk.internal.billing.play.legacy.PlayBilling4Service +import io.glassfy.androidsdk.internal.network.model.utils.Resource + +internal class PlayBillingServiceProvider { + companion object { + suspend fun billingService( + delegate: IBillingPurchaseDelegate, context: Context, watcherMode: Boolean + ): IBillingService { + val defaultService = PlayBillingService(delegate, context, watcherMode) + return when (val res = defaultService.isAvailable()) { + is Resource.Success -> { + if (res.data!!) { + defaultService + } else { + PlayBilling4Service(delegate, context, watcherMode) + } + } + + is Resource.Error -> defaultService + } + } + } +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/PlayBillingClientWrapper.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/PlayBillingClientWrapper.kt new file mode 100644 index 0000000..e90add9 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/PlayBillingClientWrapper.kt @@ -0,0 +1,464 @@ +package io.glassfy.androidsdk.internal.billing.play.billing + +import android.app.Activity +import android.content.Context +import com.android.billingclient.api.AcknowledgePurchaseParams +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.ConsumeParams +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.android.billingclient.api.SkuDetails +import com.android.billingclient.api.SkuDetailsParams +import com.android.billingclient.api.acknowledgePurchase +import com.android.billingclient.api.consumePurchase +import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchaseHistory +import com.android.billingclient.api.queryPurchasesAsync +import com.android.billingclient.api.querySkuDetails +import io.glassfy.androidsdk.Glassfy +import io.glassfy.androidsdk.GlassfyErrorCode +import io.glassfy.androidsdk.internal.billing.play.IPlayBillingPurchaseDelegate +import io.glassfy.androidsdk.internal.billing.play.PlayBillingResource +import io.glassfy.androidsdk.internal.billing.play.legacy.prorationMode +import io.glassfy.androidsdk.internal.logger.Logger +import io.glassfy.androidsdk.model.SubscriptionUpdate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal class PlayBillingClientWrapper( + ctx: Context, + private val delegate: IPlayBillingPurchaseDelegate, + private val watcherMode: Boolean +) { + companion object { + private const val BACKOFF_RECONNECTION_MS = 2000L + private const val MAX_RECONNECTION_RETRIES = 5 + } + + private val billingClient by lazy { + BillingClient.newBuilder(ctx).enablePendingPurchases().setListener { r, p -> + Glassfy.customScope.launch { handlePurchasesUpdate(r, p) } + }.build() + } + + private val purchasingProductTypeById = mutableMapOf() + + private val billingConnectionMutex = Mutex() + + // purchase listener + private val purchasingCallbacks = + mutableMapOf>>() + + private suspend fun handlePurchasesUpdate( + billingResult: BillingResult, purchases: List? + ) { + if (billingResult.isOk()) { + purchases?.forEach { purchase -> + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + findProductsType(purchase.products)?.also { productType -> + // 1 - process purchase + if (!watcherMode) { + processPurchases(purchase, productType) + } + // 2 - call delegate + delegate.onPlayBillingPurchasePurchase(purchase, productType) + } + } + + // 3 - call continuation + purchase.products.intersect(purchasingCallbacks.keys).onEach { + purchasingCallbacks.remove(it)?.takeIf { c -> c.context.isActive } + ?.also { c -> c.resume(PlayBillingResource.Success(purchase)) } + } + } + } else { + Logger.logDebug("handlePurchasesUpdate result error - code:${billingResult.responseCode} ; msg:${billingResult.debugMessage} - ${Thread.currentThread().name}") + + val callbacks: MutableIterator>>> = + purchasingCallbacks.entries.iterator() + while (callbacks.hasNext()) { + val c = callbacks.next().value + callbacks.remove() + if (c.context.isActive) { + c.resume(PlayBillingResource.Error(billingResult)) + } + } + } + } + + private suspend fun processPurchases(p: Purchase, type: String): PlayBillingResource? { + Logger.logDebug("processPurchase - state:${p.purchaseState} ; ack:${p.isAcknowledged} - ${Thread.currentThread().name}") + + return when (type) { + BillingClient.ProductType.INAPP -> consumeToken(p.purchaseToken) + BillingClient.ProductType.SUBS -> { + if (!p.isAcknowledged) { + acknowledgeToken(p.purchaseToken) + } else { + null + } + } + + else -> { + Logger.logError("UNKNOWN PRODUCT TYPE - ${Thread.currentThread().name}"); null + } + } + } + + private suspend fun findProductsType(products: List): String? = + purchasingProductTypeById.firstNotNullOfOrNull { it.takeIf { products.contains(it.key) } }?.value + ?: queryProductsDetails( + products.toSet(), BillingClient.ProductType.SUBS + ).data?.firstOrNull()?.productType ?: queryProductsDetails( + products.toSet(), BillingClient.ProductType.INAPP + ).data?.firstOrNull()?.productType + + +////// INTERNALS + + internal suspend fun isFeatureSupported(feature: String): PlayBillingResource = + withClientReady { + PlayBillingResource.Success(billingClient.isFeatureSupported(feature).isOk()) + } + + + // PurchaseHistory + + internal suspend fun allPurchaseHistory(): PlayBillingResource> = + queryPurchaseHistory( + listOf( + BillingClient.ProductType.INAPP, BillingClient.ProductType.SUBS + ) + ) + + internal suspend fun inappPurchaseHistory(): PlayBillingResource> = + queryPurchaseHistory(listOf(BillingClient.ProductType.INAPP)) + + internal suspend fun subsPurchaseHistory(): PlayBillingResource> = + queryPurchaseHistory(listOf(BillingClient.ProductType.SUBS)) + + internal suspend fun queryPurchaseHistory(types: List): PlayBillingResource> = + withClientReady { + types.flatMap { + val params = QueryPurchaseHistoryParams.newBuilder().setProductType(it).build() + val result = billingClient.queryPurchaseHistory(params) + if (!result.billingResult.isOk()) { + return@withClientReady PlayBillingResource.Error(result.billingResult) + } + result.purchaseHistoryRecordList.orEmpty() + }.let { PlayBillingResource.Success(it) } + } + + +// Purchases - Active subscriptions and non-consumed one-time purchases + + internal suspend fun allPurchase(): PlayBillingResource> = + queryPurchase(listOf(BillingClient.ProductType.INAPP, BillingClient.ProductType.SUBS)) + + internal suspend fun inappPurchase(): PlayBillingResource> = + queryPurchase(listOf(BillingClient.ProductType.INAPP)) + + internal suspend fun subsPurchase(): PlayBillingResource> = + queryPurchase(listOf(BillingClient.ProductType.SUBS)) + + internal suspend fun queryPurchase(types: List): PlayBillingResource> = + withClientReady { + types.flatMap { + val params = QueryPurchasesParams.newBuilder().setProductType(it).build() + val result = billingClient.queryPurchasesAsync(params) + if (!result.billingResult.isOk()) { + return@withClientReady PlayBillingResource.Error(result.billingResult) + } + result.purchasesList + }.let { PlayBillingResource.Success(it) } + } + + // Acknowledges subscription and consume in-app purchase + internal suspend fun acknowledgeToken(purchaseToken: String): PlayBillingResource = + withClientReady { + Logger.logDebug("acknowledgeToken of SUBS product - ${Thread.currentThread().name}") + val param = + AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build() + val res = billingClient.acknowledgePurchase(param) + if (res.isOk()) { + return@withClientReady PlayBillingResource.Success(purchaseToken) + } else { + return@withClientReady PlayBillingResource.Error(res) + } + } + + internal suspend fun consumeToken(purchaseToken: String): PlayBillingResource = + withClientReady { + Logger.logDebug("consumeToken of INAPP product - ${Thread.currentThread().name}") + val param = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build() + val res = billingClient.consumePurchase(param) + if (res.billingResult.isOk()) { + return@withClientReady PlayBillingResource.Success(res.purchaseToken) + } else { + return@withClientReady PlayBillingResource.Error(res.billingResult) + } + } + + + // Product details + internal suspend fun queryProductsDetails( + productIds: Set, type: String + ): PlayBillingResource> = withClientReady { + productIds.ifEmpty { + return@withClientReady PlayBillingResource.Success(emptyList()) + }.map { + QueryProductDetailsParams.Product + .newBuilder() + .setProductId(it) + .setProductType(type) + .build() + }.let { + billingClient.queryProductDetails( + QueryProductDetailsParams + .newBuilder() + .setProductList(it) + .build() + ) + }.let { + if (it.billingResult.isOk()) { + PlayBillingResource.Success(it.productDetailsList.orEmpty()) + } else { + PlayBillingResource.Error(it.billingResult) + } + } + } + + +// Purchase + + internal suspend fun purchaseProductDetails( + activity: Activity, + product: ProductDetails, + offerToken: String, + update: SubscriptionUpdate? = null, + accountId: String? = null + ): PlayBillingResource { + val paramsBuilder = BillingFlowParams.ProductDetailsParams.newBuilder().run { + setProductDetails(product) + if (product.productType == BillingClient.ProductType.SUBS) { + setOfferToken(offerToken) + } + build() + }.let { + BillingFlowParams.newBuilder().setProductDetailsParamsList(listOf(it)) + } + + update?.run { + if (purchaseToken.isEmpty()) { + return PlayBillingResource.Error( + BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.ITEM_NOT_OWNED).build() + ) + } + + if (product.productType == BillingClient.ProductType.SUBS) { + paramsBuilder.setSubscriptionUpdateParams( + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(purchaseToken) + .setSubscriptionReplacementMode(replacement.mode) + .build() + ) + } + } + + accountId?.let { + paramsBuilder.setObfuscatedAccountId(it) + } + + return purchaseProductDetails(activity, product, paramsBuilder.build()) + } + + private suspend fun purchaseProductDetails( + activity: Activity, product: ProductDetails, params: BillingFlowParams + ): PlayBillingResource { + Logger.logDebug("purchaseProduct - 0 - ${Thread.currentThread().name}") + if (purchasingProductTypeById.contains(product.productId)) { + val err = BillingResult.newBuilder() + .setResponseCode(GlassfyErrorCode.Purchasing.internalCode!!) + .setDebugMessage("Already Purchasing...").build() + return PlayBillingResource.Error(err) + } + purchasingProductTypeById[product.productId] = product.productType + Logger.logDebug("purchaseProduct - 1 - ${Thread.currentThread().name}") + + withContext(Dispatchers.Main) { + Logger.logDebug("purchaseProduct - 2 - ${Thread.currentThread().name}") + billingClient.launchBillingFlow(activity, params) + }.takeIf { !it.isOk() }?.let { + Logger.logDebug("purchaseProduct - 3 fail - ${Thread.currentThread().name}") + purchasingProductTypeById.remove(product.productId) + return@purchaseProductDetails PlayBillingResource.Error(it) + } + + suspendCoroutine> { + Logger.logDebug("purchaseProduct - 3 - ${Thread.currentThread().name}") + purchasingCallbacks[product.productId] = it + }.also { + Logger.logDebug("purchaseProduct - 4 - ${Thread.currentThread().name}") + purchasingProductTypeById.remove(product.productId) + + return@purchaseProductDetails it + } + } + + +////// LEGACY - SkuDetails + + internal suspend fun querySkuDetails(skuList: Set): PlayBillingResource> = + withClientReady { + if (skuList.isEmpty()) { + return@withClientReady PlayBillingResource.Success(emptyList()) + } + + val inApp = SkuDetailsParams.newBuilder().setSkusList(skuList.toList()) + .setType(BillingClient.SkuType.INAPP).build().let { + billingClient.querySkuDetails(it) + } + + if (!inApp.billingResult.isOk()) { + return@withClientReady PlayBillingResource.Error(inApp.billingResult) + } + + val subs = SkuDetailsParams.newBuilder().setSkusList(skuList.toList()) + .setType(BillingClient.SkuType.SUBS).build().let { + billingClient.querySkuDetails(it) + } + + if (!subs.billingResult.isOk()) { + return@withClientReady PlayBillingResource.Error(inApp.billingResult) + } + + return@withClientReady PlayBillingResource.Success(inApp.skuDetailsList.orEmpty() + subs.skuDetailsList.orEmpty()) + } + + internal suspend fun purchaseSkuDetails( + activity: Activity, + sku: SkuDetails, + update: SubscriptionUpdate? = null, + accountId: String? = null + ): PlayBillingResource { + val paramsBuilder = BillingFlowParams.newBuilder().setSkuDetails(sku) + update?.run { + if (purchaseToken.isEmpty()) { + return PlayBillingResource.Error( + BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.ITEM_NOT_OWNED).build() + ) + } + + paramsBuilder.setSkuDetails(sku).setSubscriptionUpdateParams( + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldSkuPurchaseToken(purchaseToken) + .setReplaceSkusProrationMode(replacement.prorationMode()).build() + ) + } + accountId?.let { + paramsBuilder.setObfuscatedAccountId(it) + } + + return purchaseSkuDetails(activity, sku, paramsBuilder.build()) + } + + private suspend fun purchaseSkuDetails( + activity: Activity, sku: SkuDetails, params: BillingFlowParams + ): PlayBillingResource { + Logger.logDebug("purchaseSku - 0 - ${Thread.currentThread().name}") + if (purchasingProductTypeById.contains(sku.sku)) { + val err = BillingResult.newBuilder() + .setResponseCode(GlassfyErrorCode.Purchasing.internalCode!!) + .setDebugMessage("Already Purchasing...").build() + return PlayBillingResource.Error(err) + } + purchasingProductTypeById[sku.sku] = sku.type + Logger.logDebug("purchaseSku - 1 - ${Thread.currentThread().name}") + + withContext(Dispatchers.Main) { + Logger.logDebug("purchaseSku - 2 - ${Thread.currentThread().name}") + billingClient.launchBillingFlow(activity, params) + }.takeIf { !it.isOk() }?.let { + Logger.logDebug("purchaseSku - 3 fail - ${Thread.currentThread().name}") + purchasingProductTypeById.remove(sku.sku) + return@purchaseSkuDetails PlayBillingResource.Error(it) + } + + suspendCoroutine> { + Logger.logDebug("purchaseSku - 3 - ${Thread.currentThread().name}") + purchasingCallbacks[sku.sku] = it + }.also { + Logger.logDebug("purchaseSku - 4 - ${Thread.currentThread().name}") + purchasingProductTypeById.remove(sku.sku) + + return@purchaseSkuDetails it + } + } + + +////// UTILS + + private suspend fun withClientReady( + block: suspend () -> PlayBillingResource + ): PlayBillingResource = billingClient.isReady.let { + if (it) return block() + + var retryCount = 0 + var connectionResult: BillingResult + do { + delay(BACKOFF_RECONNECTION_MS * retryCount) + connectionResult = billingConnectionMutex.withLock { + startConnectionSync(billingClient) + } + retryCount += 1 + } while (!connectionResult.isOk() && retryCount < MAX_RECONNECTION_RETRIES) + + return if (connectionResult.isOk()) block() else PlayBillingResource.Error( + connectionResult + ) + } + + private suspend fun startConnectionSync(bc: BillingClient): BillingResult = + suspendCancellableCoroutine { c -> + bc.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + Logger.logDebug("onBillingSetupFinished code:${billingResult.responseCode} ; msg:${billingResult.debugMessage} - ${Thread.currentThread().name}") + if (c.isActive && c.context.isActive) { + c.resume(billingResult) + } + } + + override fun onBillingServiceDisconnected() { + Logger.logDebug("onBillingServiceDisconnected - ${Thread.currentThread().name}") + if (c.isActive && c.context.isActive) { + val disconnectedResult = BillingResult.newBuilder() + .setResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED) + .build() + c.resume(disconnectedResult) + } + } + }) + } + + private fun BillingResult.isOk(): Boolean { + return this.responseCode == BillingClient.BillingResponseCode.OK + } +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/PlayBillingService.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/PlayBillingService.kt new file mode 100644 index 0000000..554dc8b --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/PlayBillingService.kt @@ -0,0 +1,250 @@ +package io.glassfy.androidsdk.internal.billing.play.billing + +import android.app.Activity +import android.content.Context +import com.android.billingclient.api.BillingClient +import io.glassfy.androidsdk.GlassfyErrorCode +import io.glassfy.androidsdk.PurchaseDelegate +import io.glassfy.androidsdk.internal.billing.IBillingPurchaseDelegate +import io.glassfy.androidsdk.internal.billing.IBillingService +import io.glassfy.androidsdk.internal.billing.SkuDetailsQuery +import io.glassfy.androidsdk.internal.billing.play.IPlayBillingPurchaseDelegate +import io.glassfy.androidsdk.internal.billing.play.PlayBillingResource +import io.glassfy.androidsdk.internal.billing.play.billing.mapper.convertError +import io.glassfy.androidsdk.internal.billing.play.billing.mapper.convertHistoryPurchases +import io.glassfy.androidsdk.internal.billing.play.billing.mapper.convertLegacySkusDetails +import io.glassfy.androidsdk.internal.billing.play.billing.mapper.convertPurchase +import io.glassfy.androidsdk.internal.billing.play.billing.mapper.convertPurchases +import io.glassfy.androidsdk.internal.billing.play.billing.mapper.convertSkuDetails +import io.glassfy.androidsdk.internal.logger.Logger +import io.glassfy.androidsdk.internal.network.model.utils.Resource +import io.glassfy.androidsdk.model.HistoryPurchase +import io.glassfy.androidsdk.model.ProductType +import io.glassfy.androidsdk.model.Purchase +import io.glassfy.androidsdk.model.SkuDetails +import io.glassfy.androidsdk.model.SubscriptionUpdate + +internal class PlayBillingService( + override val delegate: IBillingPurchaseDelegate, ctx: Context, watcherMode: Boolean +) : IBillingService, IPlayBillingPurchaseDelegate { + + override val version: Int + get() = 6 + + private var _delegate: PurchaseDelegate? = null + + private val billingClientWrapper = PlayBillingClientWrapper(ctx, this, watcherMode) + + suspend fun isAvailable(): Resource { + billingClientWrapper.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS).let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(it.data!!) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + } + + override suspend fun inAppPurchaseHistory(): Resource> { + billingClientWrapper.inappPurchaseHistory().let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(convertHistoryPurchases(it.data!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + } + + override suspend fun subsPurchaseHistory(): Resource> { + billingClientWrapper.subsPurchaseHistory().let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(convertHistoryPurchases(it.data!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + } + + override suspend fun allPurchaseHistory(): Resource> { + billingClientWrapper.allPurchaseHistory().let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(convertHistoryPurchases(it.data!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + } + + override suspend fun inAppPurchases(): Resource> { + billingClientWrapper.inappPurchase().let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(convertPurchases(it.data!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + } + + override suspend fun subsPurchases(): Resource> { + billingClientWrapper.subsPurchase().let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(convertPurchases(it.data!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + } + + override suspend fun allPurchases(): Resource> { + billingClientWrapper.allPurchase().let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(convertPurchases(it.data!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + } + + override suspend fun skuDetails(query: SkuDetailsQuery): Resource = + skusDetails(listOf(query)).let { res -> + when (res) { + is Resource.Success -> res.data?.firstOrNull()?.let { + Resource.Success(it) + } ?: Resource.Error(GlassfyErrorCode.NotFoundOnStore.toError()) + + is Resource.Error -> Resource.Error(res.err!!) + } + } + + override suspend fun skusDetails(queries: List): Resource> { + val (legacyQueries, productQueries) = queries.partition { + it.basePlanId == null && it.productType != ProductType.INAPP + } + + val legacyRes = legacySkuDetails(legacyQueries) + if (legacyRes is Resource.Error) { + return legacyRes + } + + val productRes = productSkuDetails(productQueries) + if (productRes is Resource.Error) { + return productRes + } + return Resource.Success(legacyRes.data.orEmpty() + productRes.data.orEmpty()) + } + + override suspend fun purchase( + activity: Activity, product: SkuDetails, update: SubscriptionUpdate?, accountId: String? + ): Resource { + if (product.originalJson.isNotEmpty()) { + return legacyPurchaseSkuDetails(activity, product, update, accountId) + } + + val billingProduct = when (product.type) { + ProductType.SUBS -> BillingClient.ProductType.SUBS + ProductType.INAPP -> BillingClient.ProductType.INAPP + else -> null + }?.let { + billingClientWrapper.queryProductsDetails(setOf(product.sku), it) + }?.let { + it.data?.firstOrNull() + } ?: return Resource.Error(GlassfyErrorCode.NotFoundOnStore.toError()) + + billingClientWrapper.purchaseProductDetails( + activity, billingProduct, product.offerToken, update, accountId + ).let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(convertPurchase(it.data!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + + } + + + override suspend fun consume(purchaseToken: String): Resource = + billingClientWrapper.consumeToken(purchaseToken).let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(purchaseToken) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + + override suspend fun acknowledge(purchaseToken: String): Resource = + billingClientWrapper.acknowledgeToken(purchaseToken).let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(purchaseToken) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + + + /// IPlayBillingPurchaseDelegate + + override suspend fun onPlayBillingPurchasePurchase( + p: com.android.billingclient.api.Purchase, productType: String + ) { + Logger.logDebug("onPlayBillingPurchasePurchase ${p.products} - ${Thread.currentThread().name}") + + delegate.onProductPurchase( + convertPurchase(p), productType == BillingClient.ProductType.SUBS + ) + } + + + //// Utils + + private suspend fun productSkuDetails(queries: List): Resource> { + val resInapp = + queries.filter { it.productType == ProductType.INAPP || it.productType == ProductType.UNKNOWN } + .map { it.productId }.toSet().let { + billingClientWrapper.queryProductsDetails( + it, BillingClient.ProductType.INAPP + ) + } + if (resInapp is PlayBillingResource.Error) { + return Resource.Error(convertError(resInapp.err!!)) + } + + val resSubs = + queries.filter { it.productType == ProductType.SUBS || it.productType == ProductType.UNKNOWN } + .map { it.productId }.toSet().let { + billingClientWrapper.queryProductsDetails( + it, BillingClient.ProductType.SUBS + ) + } + if (resSubs is PlayBillingResource.Error) { + return Resource.Error(convertError(resSubs.err!!)) + } + + return convertSkuDetails(resInapp.data.orEmpty() + resSubs.data.orEmpty(), queries).let { + Resource.Success(it) + } + } + + private suspend fun legacySkuDetails(queries: List): Resource> { + val skuList = queries.map { it.productId }.toSet() + return billingClientWrapper.querySkuDetails(skuList).let { billingRes -> + when (billingRes) { + is PlayBillingResource.Success -> { + val skuDetails = convertLegacySkusDetails(billingRes.data!!) + val invalidProductIdentifiers = skuList.minus(skuDetails.map { it.sku }.toSet()) + if (invalidProductIdentifiers.isNotEmpty()) { + val message = "PlayStore did not return details for the following products:" + val docs = + "Check the guide at \uD83D\uDD17 https://docs.glassfy.io/26293898" + Logger.logDebug("$message\n\t${invalidProductIdentifiers.joinToString("\n\t")}\n$docs") + } + return Resource.Success(skuDetails) + } + + is PlayBillingResource.Error -> Resource.Error(convertError(billingRes.err!!)) + } + } + } + + private suspend fun legacyPurchaseSkuDetails( + activity: Activity, product: SkuDetails, update: SubscriptionUpdate?, accountId: String? + ): Resource { + val s = product.run { com.android.billingclient.api.SkuDetails(originalJson) } + billingClientWrapper.purchaseSkuDetails(activity, s, update, accountId).let { + return when (it) { + is PlayBillingResource.Success -> Resource.Success(convertPurchase(it.data!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + } +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/SubscriptionOffersUseCase.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/SubscriptionOffersUseCase.kt new file mode 100644 index 0000000..a7ddb47 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/SubscriptionOffersUseCase.kt @@ -0,0 +1,10 @@ +package io.glassfy.androidsdk.internal.billing.play.billing + +import com.android.billingclient.api.ProductDetails.PricingPhase +import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails + +internal fun findBasePlan(offers: List): SubscriptionOfferDetails? = + offers.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } + +internal fun findOffer(offerId: String, offers: List): SubscriptionOfferDetails? = + offers.firstOrNull { o -> o.offerId == offerId } diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/AccountIdentifierMapper.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/AccountIdentifierMapper.kt new file mode 100644 index 0000000..1eccc61 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/AccountIdentifierMapper.kt @@ -0,0 +1,11 @@ +package io.glassfy.androidsdk.internal.billing.play.billing.mapper + +import io.glassfy.androidsdk.model.AccountIdentifiers + +internal fun convertAccountIdentifier(a: com.android.billingclient.api.AccountIdentifiers?) = + a?.run { + AccountIdentifiers( + obfuscatedAccountId, + obfuscatedProfileId + ) + } \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/BillingPeriodConversions.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/BillingPeriodConversions.kt new file mode 100644 index 0000000..8015e84 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/BillingPeriodConversions.kt @@ -0,0 +1,17 @@ +package io.glassfy.androidsdk.internal.billing.play.billing.mapper + +class BillingPeriodConversions { + companion object { + fun days(period: String): Int? { + if (period.length < 3) return null + val num = period.substring(1, period.length - 1).toIntOrNull() ?: return null + return when (period[period.length - 1]) { + 'D' -> num + 'W' -> num * 7 + 'M' -> num * 30 + 'Y' -> num * 365 + else -> null + } + } + } +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/GlassfyErrorMapper.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/GlassfyErrorMapper.kt new file mode 100644 index 0000000..3bf5d86 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/GlassfyErrorMapper.kt @@ -0,0 +1,67 @@ +package io.glassfy.androidsdk.internal.billing.play.billing.mapper + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import io.glassfy.androidsdk.GlassfyError +import io.glassfy.androidsdk.GlassfyErrorCode + +fun convertError(b: BillingResult): GlassfyError = b.run { + return when (responseCode) { + GlassfyErrorCode.Purchasing.internalCode!! -> GlassfyErrorCode.Purchasing.toError( + "Purchase already in progress..." + ) + + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> GlassfyErrorCode.ProductAlreadyOwned.toError( + "The purchase failed because the item is already owned. (ITEM_ALREADY_OWNED)" + ) + + BillingClient.BillingResponseCode.USER_CANCELED -> GlassfyErrorCode.UserCancelPurchase.toError( + "Transaction was canceled by the user. (USER_CANCELED)" + ) + + BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> GlassfyErrorCode.StoreError.toError( + "Action on the item failed since it is not owned by the user. (ITEM_NOT_OWNED)" + ) + + BillingClient.BillingResponseCode.SERVICE_TIMEOUT -> GlassfyErrorCode.StoreError.toError( + "The request has reached the maximum timeout before Google Play responds. (SERVICE_TIMEOUT)" + ) + + BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> GlassfyErrorCode.StoreError.toError( + "Play Store service is not connected now. (SERVICE_DISCONNECTED)" + ) + + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> GlassfyErrorCode.StoreError.toError( + "The service is currently unavailable. (SERVICE_UNAVAILABLE)" + ) + + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> GlassfyErrorCode.StoreError.toError( + "A user billing error occurred during processing. Examples where this error may occur:\n" + + "- The Play Store app on the user's device is out of date.\n" + + "- The user is in an unsupported country.\n" + + "- The user is an enterprise user and their enterprise admin has disabled users from making purchases.\n" + + "- Google Play is unable to charge the user’s payment method.\n" + + "(BILLING_UNAVAILABLE)" + ) + + BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> GlassfyErrorCode.StoreError.toError( + "Requested product is not available for purchase. (ITEM_UNAVAILABLE)" + ) + + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> GlassfyErrorCode.StoreError.toError( + "Google Play does not recognize the configuration. " + "If you are just getting started, make sure you have configured the application correctly in the Google Play Console. " + "The SKU product ID must match and the APK you are using must be signed with release keys. (DEVELOPER_ERROR)" + ) + + BillingClient.BillingResponseCode.ERROR -> GlassfyErrorCode.StoreError.toError( + "Internal Google Play error. Obsolete Play Store version? (ERROR)" + ) + + BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> GlassfyErrorCode.StoreError.toError( + "The requested feature is not supported on the current device. (FEATURE_NOT_SUPPORTED)" + ) + + BillingClient.BillingResponseCode.NETWORK_ERROR -> GlassfyErrorCode.StoreError.toError("A network error occurred during the operation. (NETWORK_ERROR)") + + else -> GlassfyErrorCode.StoreError.toError("Unknown error") + } +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/HistoryPurchaseMapper.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/HistoryPurchaseMapper.kt new file mode 100644 index 0000000..edf3ccd --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/HistoryPurchaseMapper.kt @@ -0,0 +1,22 @@ +package io.glassfy.androidsdk.internal.billing.play.billing.mapper + + +import com.android.billingclient.api.PurchaseHistoryRecord +import io.glassfy.androidsdk.model.HistoryPurchase + +internal fun convertHistoryPurchases(ps: List) = + ps.map { convertHistoryPurchase(it) } + +private fun convertHistoryPurchase(p: PurchaseHistoryRecord): HistoryPurchase = + p.run { + HistoryPurchase( + developerPayload, + purchaseTime, + purchaseToken, + quantity, + signature, + products, + hashCode(), + originalJson + ) + } diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/PurchaseMapper.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/PurchaseMapper.kt new file mode 100644 index 0000000..67f6088 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/PurchaseMapper.kt @@ -0,0 +1,25 @@ +package io.glassfy.androidsdk.internal.billing.play.billing.mapper + +import io.glassfy.androidsdk.model.Purchase + +internal fun convertPurchases(ps: List) = + ps.map { convertPurchase(it) } + +internal fun convertPurchase(p: com.android.billingclient.api.Purchase) = p.run { + Purchase( + convertAccountIdentifier(accountIdentifiers), + developerPayload, + orderId.orEmpty(), // orderId is null with purchaseState != PURCHASED + packageName, + purchaseState, + purchaseTime, + purchaseToken, + quantity, + signature, + products, + hashCode(), + isAcknowledged, + isAutoRenewing, + originalJson + ) +} diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/SkuDetailsMapper.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/SkuDetailsMapper.kt new file mode 100644 index 0000000..f4340b5 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/billing/mapper/SkuDetailsMapper.kt @@ -0,0 +1,160 @@ +package io.glassfy.androidsdk.internal.billing.play.billing.mapper + +import com.android.billingclient.api.BillingClient +import io.glassfy.androidsdk.internal.billing.SkuDetailsQuery +import io.glassfy.androidsdk.model.ProductType +import io.glassfy.androidsdk.model.SkuDetails + + +internal fun convertSkuDetails( + products: List, queries: List +): List { + return queries.mapNotNull { convertSkuDetails(products, it) } +} + +private fun convertSkuDetails( + products: List, query: SkuDetailsQuery +): SkuDetails? { + return products.find { + it.productId == query.productId + }?.let { + if (it.productType == BillingClient.ProductType.INAPP) { + return convertInAppPurchaseSkuDetails(it) + } else { + return convertSubscriptionSkuDetails(it, query) + } + } +} + +private fun convertSubscriptionSkuDetails( + product: com.android.billingclient.api.ProductDetails, query: SkuDetailsQuery +): SkuDetails? = query.basePlanId?.let { basePlanId -> + // offers + product.subscriptionOfferDetails?.filter { + it.basePlanId == basePlanId + } +}?.let { offers -> + // base plan + val basePlan = findBasePlan(offers) ?: return null + + // offer + val offer = query.offerId?.let { findOffer(it, offers) } + if (offer == null && query.offerId != null) { + return null + } + + // SkuDetails + convertSubscriptionSkuDetails(product, basePlan, offer) +} + +private fun convertSubscriptionSkuDetails( + product: com.android.billingclient.api.ProductDetails, + basePlan: com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails, + offer: com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails? +): SkuDetails? { + val basePricing = basePlan.pricingPhases.pricingPhaseList.firstOrNull() ?: return null + // filter base plan phase from offer's pricingPhase + val offerPhases = offer?.pricingPhases?.pricingPhaseList?.filter { + !(basePricing.billingCycleCount == it.billingCycleCount && basePricing.billingPeriod == it.billingPeriod && basePricing.formattedPrice == it.formattedPrice && basePricing.priceAmountMicros == it.priceAmountMicros && basePricing.priceCurrencyCode == it.priceCurrencyCode && basePricing.recurrenceMode == it.recurrenceMode) + } + + val freeTrial = offerPhases?.firstOrNull { it.priceAmountMicros == 0L } + val introPrice = offerPhases?.firstOrNull { it.priceAmountMicros != 0L } + + return SkuDetails( + description = product.description, + freeTrialPeriod = freeTrial?.billingPeriod.orEmpty(), + iconUrl = "", + sku = product.productId, + subscriptionPeriod = basePricing.billingPeriod, + title = product.title, + type = ProductType.SUBS, + basePlanId = basePlan.basePlanId, + offerId = offer?.offerId.orEmpty(), + offerToken = offer?.offerToken ?: basePlan.offerToken, + hashCode = product.hashCode(), + introductoryPrice = introPrice?.formattedPrice.orEmpty(), + introductoryPriceAmountMicro = introPrice?.priceAmountMicros ?: 0, + introductoryPriceAmountCycles = introPrice?.billingCycleCount ?: 0, + introductoryPriceAmountPeriod = introPrice?.billingPeriod.orEmpty(), + originalPrice = basePricing.formattedPrice, + originalPriceAmountMicro = basePricing.priceAmountMicros, + price = basePricing.formattedPrice, + priceAmountMicro = basePricing.priceAmountMicros, + priceCurrencyCode = basePricing.priceCurrencyCode + ) +} + +private fun findBasePlan(offers: List): com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails? = + offers.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } + +private fun findOffer( + offerId: String, + offers: List +): com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails? = + offers.firstOrNull { o -> o.offerId == offerId } + +internal fun convertInAppPurchaseSkuDetails(product: com.android.billingclient.api.ProductDetails): SkuDetails? { + val offer = product.oneTimePurchaseOfferDetails ?: return null + + return SkuDetails( + description = product.description, + freeTrialPeriod = "", + iconUrl = "", + sku = product.productId, + subscriptionPeriod = "", + title = product.title, + type = ProductType.INAPP, + basePlanId = "", + offerId = "", + offerToken = "", + hashCode = product.hashCode(), + introductoryPrice = "", + introductoryPriceAmountMicro = 0, + introductoryPriceAmountCycles = 0, + introductoryPriceAmountPeriod = "", + originalPrice = offer.formattedPrice, + originalPriceAmountMicro = offer.priceAmountMicros, + price = offer.formattedPrice, + priceAmountMicro = offer.priceAmountMicros, + priceCurrencyCode = offer.priceCurrencyCode + ) +} + + +// LEGACY +internal fun convertLegacySkusDetails(ps: List): List = + ps.map { convertLegacySkusDetails(it) } + +private fun convertLegacySkusDetails(s: com.android.billingclient.api.SkuDetails): SkuDetails = + s.run { + SkuDetails( + description = description, + freeTrialPeriod = freeTrialPeriod, + iconUrl = iconUrl, + sku = sku, + subscriptionPeriod = subscriptionPeriod, + title = title, + type = convertLegacySkuType(type), + basePlanId = "", + offerId = "", + offerToken = "", + hashCode = hashCode(), + introductoryPrice = introductoryPrice, + introductoryPriceAmountMicro = introductoryPriceAmountMicros, + introductoryPriceAmountCycles = introductoryPriceCycles, + introductoryPriceAmountPeriod = introductoryPricePeriod, + originalPrice = originalPrice, + originalPriceAmountMicro = originalPriceAmountMicros, + price = price, + priceAmountMicro = priceAmountMicros, + priceCurrencyCode = priceCurrencyCode, + originalJson = originalJson + ) + } + +private fun convertLegacySkuType(type: String) = when (type) { + BillingClient.SkuType.INAPP -> ProductType.INAPP + BillingClient.SkuType.SUBS -> ProductType.SUBS + else -> ProductType.UNKNOWN +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/IPlayBillingPurchaseDelegate.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/IPlayBillingPurchaseDelegate.kt similarity index 100% rename from glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/IPlayBillingPurchaseDelegate.kt rename to glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/IPlayBillingPurchaseDelegate.kt diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/LegacyProrationMode.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/LegacyProrationMode.kt new file mode 100644 index 0000000..b49fa02 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/LegacyProrationMode.kt @@ -0,0 +1,13 @@ +package io.glassfy.androidsdk.internal.billing.play.legacy + +import com.android.billingclient.api.BillingFlowParams +import io.glassfy.androidsdk.model.ReplacementMode + +internal fun ReplacementMode.prorationMode() = when(this) { + ReplacementMode.CHARGE_FULL_PRICE -> BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_FULL_PRICE + ReplacementMode.CHARGE_PRORATED_PRICE -> BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE + ReplacementMode.DEFERRED -> BillingFlowParams.ProrationMode.DEFERRED + ReplacementMode.UNKNOWN_REPLACEMENT_MODE -> BillingFlowParams.ProrationMode.UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY + ReplacementMode.WITHOUT_PRORATION -> BillingFlowParams.ProrationMode.IMMEDIATE_WITHOUT_PRORATION + ReplacementMode.WITH_TIME_PRORATION -> BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingClientWrapper.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/PlayBilling4ClientWrapper.kt similarity index 94% rename from glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingClientWrapper.kt rename to glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/PlayBilling4ClientWrapper.kt index 1cb1dcd..4f33780 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingClientWrapper.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/PlayBilling4ClientWrapper.kt @@ -1,4 +1,4 @@ -package io.glassfy.androidsdk.internal.billing.google +package io.glassfy.androidsdk.internal.billing.play.legacy import android.app.Activity import android.content.Context @@ -21,7 +21,9 @@ import com.android.billingclient.api.queryPurchaseHistory import com.android.billingclient.api.queryPurchasesAsync import com.android.billingclient.api.querySkuDetails import io.glassfy.androidsdk.Glassfy -import io.glassfy.androidsdk.internal.billing.google.PlayBillingService.Companion.PURCHASING +import io.glassfy.androidsdk.GlassfyErrorCode +import io.glassfy.androidsdk.internal.billing.play.IPlayBillingPurchaseDelegate +import io.glassfy.androidsdk.internal.billing.play.PlayBillingResource import io.glassfy.androidsdk.internal.logger.Logger import io.glassfy.androidsdk.model.SubscriptionUpdate import kotlinx.coroutines.Dispatchers @@ -36,7 +38,7 @@ import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -internal class PlayBillingClientWrapper( +internal class PlayBilling4ClientWrapper( ctx: Context, private val delegate: IPlayBillingPurchaseDelegate, private val watcherMode: Boolean @@ -79,9 +81,9 @@ internal class PlayBillingClientWrapper( // 3 - call continuation purchase.products.intersect(purchasingCallbacks.keys).onEach { - purchasingCallbacks.remove(it)?.takeIf { c -> c.context.isActive } - ?.also { c -> c.resume(PlayBillingResource.Success(purchase)) } - } + purchasingCallbacks.remove(it)?.takeIf { c -> c.context.isActive } + ?.also { c -> c.resume(PlayBillingResource.Success(purchase)) } + } } } else { Logger.logDebug("handlePurchasesUpdate result error - code:${billingResult.responseCode} ; msg:${billingResult.debugMessage} - ${Thread.currentThread().name}") @@ -176,7 +178,7 @@ internal class PlayBillingClientWrapper( paramsBuilder.setSkuDetails(sku).setSubscriptionUpdateParams( BillingFlowParams.SubscriptionUpdateParams.newBuilder() .setOldSkuPurchaseToken(purchaseToken) - .setReplaceSkusProrationMode(proration.mode).build() + .setReplaceSkusProrationMode(replacement.prorationMode()).build() ) } accountId?.let { @@ -221,7 +223,8 @@ internal class PlayBillingClientWrapper( ): PlayBillingResource { Logger.logDebug("purchaseSku - 0 - ${Thread.currentThread().name}") if (purchasingSku.contains(sku.sku)) { - val err = BillingResult.newBuilder().setResponseCode(PURCHASING) + val err = BillingResult.newBuilder() + .setResponseCode(GlassfyErrorCode.Purchasing.internalCode!!) .setDebugMessage("Already Purchasing...").build() return PlayBillingResource.Error(err) } diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingService.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/PlayBilling4Service.kt similarity index 67% rename from glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingService.kt rename to glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/PlayBilling4Service.kt index 5a1b83b..1b2bcc9 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/google/PlayBillingService.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/billing/play/legacy/PlayBilling4Service.kt @@ -1,4 +1,4 @@ -package io.glassfy.androidsdk.internal.billing.google +package io.glassfy.androidsdk.internal.billing.play.legacy import android.app.Activity import android.content.Context @@ -6,31 +6,38 @@ import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingResult import io.glassfy.androidsdk.GlassfyError import io.glassfy.androidsdk.GlassfyErrorCode +import io.glassfy.androidsdk.PurchaseDelegate import io.glassfy.androidsdk.internal.billing.IBillingPurchaseDelegate import io.glassfy.androidsdk.internal.billing.IBillingService +import io.glassfy.androidsdk.internal.billing.SkuDetailsQuery +import io.glassfy.androidsdk.internal.billing.play.IPlayBillingPurchaseDelegate +import io.glassfy.androidsdk.internal.billing.play.PlayBillingResource import io.glassfy.androidsdk.internal.logger.Logger import io.glassfy.androidsdk.internal.network.model.utils.Resource import io.glassfy.androidsdk.model.AccountIdentifiers import io.glassfy.androidsdk.model.HistoryPurchase +import io.glassfy.androidsdk.model.ProductType import io.glassfy.androidsdk.model.Purchase import io.glassfy.androidsdk.model.SkuDetails import io.glassfy.androidsdk.model.SubscriptionUpdate -internal class PlayBillingService( +internal class PlayBilling4Service( override val delegate: IBillingPurchaseDelegate, ctx: Context, watcherMode: Boolean ) : IBillingService, IPlayBillingPurchaseDelegate { - companion object { - internal const val PURCHASING = -199 - } + override val version: Int + get() = 4 + - private val billingClientWrapper = PlayBillingClientWrapper(ctx, this, watcherMode) + private var _delegate: PurchaseDelegate? = null + + private val billingClientWrapper = PlayBilling4ClientWrapper(ctx, this, watcherMode) override suspend fun inAppPurchaseHistory(): Resource> = billingClientWrapper.queryPurchaseHistory(arrayOf(BillingClient.SkuType.INAPP)).let { return when (it) { is PlayBillingResource.Success -> Resource.Success(convertHistoryPurchases(it.data!!)) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) } } @@ -38,7 +45,7 @@ internal class PlayBillingService( billingClientWrapper.queryPurchaseHistory(arrayOf(BillingClient.SkuType.SUBS)).let { return when (it) { is PlayBillingResource.Success -> Resource.Success(convertHistoryPurchases(it.data!!)) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) } } @@ -46,7 +53,7 @@ internal class PlayBillingService( billingClientWrapper.queryPurchaseHistory().let { return when (it) { is PlayBillingResource.Success -> Resource.Success(convertHistoryPurchases(it.data!!)) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) } } @@ -54,7 +61,7 @@ internal class PlayBillingService( billingClientWrapper.queryPurchase().let { return when (it) { is PlayBillingResource.Success -> Resource.Success(convertPurchases(it.data!!)) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) } } @@ -62,7 +69,7 @@ internal class PlayBillingService( billingClientWrapper.queryPurchase(arrayOf(BillingClient.SkuType.SUBS)).let { return when (it) { is PlayBillingResource.Success -> Resource.Success(convertPurchases(it.data!!)) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) } } @@ -70,36 +77,51 @@ internal class PlayBillingService( billingClientWrapper.queryPurchase(arrayOf(BillingClient.SkuType.INAPP)).let { return when (it) { is PlayBillingResource.Success -> Resource.Success(convertPurchases(it.data!!)) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) + } + } + + override suspend fun skuDetails(query: SkuDetailsQuery): Resource = + skusDetails(listOf(query)).let { res -> + when (res) { + is Resource.Success -> res.data?.firstOrNull()?.let { + Resource.Success(it) + } ?: Resource.Error(GlassfyErrorCode.NotFoundOnStore.toError()) + + is Resource.Error -> Resource.Error(res.err!!) } } - override suspend fun skuDetails(skuList: Set): Resource> = - billingClientWrapper.querySkuDetails(skuList).let { billingRes -> - return when (billingRes) { + override suspend fun skusDetails(queries: List): Resource> { + val skuList = queries.map { it.productId }.toSet() + return billingClientWrapper.querySkuDetails(skuList).let { billingRes -> + when (billingRes) { is PlayBillingResource.Success -> { val skuDetails = convertSkusDetails(billingRes.data!!) - val invalidProductIdentifiers = - skuList.minus(skuDetails.map { it.sku }.toSet()).joinToString("\n\t") + + val invalidProductIdentifiers = skuList.minus(skuDetails.map { it.sku }.toSet()) if (invalidProductIdentifiers.isNotEmpty()) { - Logger.logDebug("PlayStore does not return details for the following products:\n\t${invalidProductIdentifiers}\nCheck the guide at 🔗 https://docs.glassfy.io/26293898") + val message = "PlayStore did not return details for the following products:" + val docs = + "Check the guide at \uD83D\uDD17 https://docs.glassfy.io/26293898" + Logger.logDebug("$message\n\t${invalidProductIdentifiers.joinToString("\n\t")}\n$docs") } - return Resource.Success(skuDetails) } - else -> Resource.Error(convertError(billingRes.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(billingRes.err!!)) } } + } override suspend fun purchase( - activity: Activity, sku: SkuDetails, update: SubscriptionUpdate?, accountId: String? + activity: Activity, product: SkuDetails, update: SubscriptionUpdate?, accountId: String? ): Resource = - billingClientWrapper.purchaseSku(activity, convertToSkuDetails(sku), update, accountId) + billingClientWrapper.purchaseSku(activity, convertToSkuDetails(product), update, accountId) .let { return when (it) { is PlayBillingResource.Success -> Resource.Success(convertPurchase(it.data!!)) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) } } @@ -107,7 +129,7 @@ internal class PlayBillingService( billingClientWrapper.consumeToken(purchaseToken).let { return when (it) { is PlayBillingResource.Success -> Resource.Success(purchaseToken) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) } } @@ -115,7 +137,7 @@ internal class PlayBillingService( billingClientWrapper.acknowledgeToken(purchaseToken).let { return when (it) { is PlayBillingResource.Success -> Resource.Success(purchaseToken) - else -> Resource.Error(convertError(it.err!!)) + is PlayBillingResource.Error -> Resource.Error(convertError(it.err!!)) } } @@ -146,7 +168,7 @@ internal class PlayBillingService( Purchase( convertAccountIdentifier(accountIdentifiers), developerPayload, - orderId, + orderId.orEmpty(), // orderId is null if purchaseState != PURCHASED packageName, purchaseState, purchaseTime, @@ -177,30 +199,42 @@ internal class PlayBillingService( private fun convertSkuDetails(s: com.android.billingclient.api.SkuDetails) = s.run { SkuDetails( - description, - freeTrialPeriod, - iconUrl, - introductoryPrice, - introductoryPriceAmountMicros, - introductoryPriceCycles, - introductoryPricePeriod, - originalPrice, - originalPriceAmountMicros, - price, - priceAmountMicros, - priceCurrencyCode, - sku, - subscriptionPeriod, - title, - type, - hashCode(), - originalJson + description = description, + freeTrialPeriod = freeTrialPeriod, + iconUrl = iconUrl, + sku = sku, + subscriptionPeriod = subscriptionPeriod, + title = title, + type = convertSkuType(type), + basePlanId = "", + offerId = "", + offerToken = "", + hashCode = hashCode(), + introductoryPrice = introductoryPrice, + introductoryPriceAmountMicro = introductoryPriceAmountMicros, + introductoryPriceAmountCycles = introductoryPriceCycles, + introductoryPriceAmountPeriod = introductoryPricePeriod, + originalPrice = originalPrice, + originalPriceAmountMicro = originalPriceAmountMicros, + price = price, + priceAmountMicro = priceAmountMicros, + priceCurrencyCode = priceCurrencyCode, + originalJson = originalJson ) } + private fun convertSkuType(type: String) = when (type) { + BillingClient.SkuType.INAPP -> ProductType.INAPP + BillingClient.SkuType.SUBS -> ProductType.SUBS + else -> ProductType.UNKNOWN + } + private fun convertError(b: BillingResult): GlassfyError = b.run { return when (responseCode) { - PURCHASING -> GlassfyErrorCode.Purchasing.toError() + GlassfyErrorCode.Purchasing.internalCode!! -> GlassfyErrorCode.Purchasing.toError( + "Purchase already in progress..." + ) + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> GlassfyErrorCode.ProductAlreadyOwned.toError( "Failure to purchase since item is already owned (ITEM_ALREADY_OWNED)" ) diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/IApiService.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/IApiService.kt index 83f79ad..d4b1428 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/IApiService.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/IApiService.kt @@ -17,14 +17,14 @@ internal interface IApiService { @Query("pricelocale") locale: String ): Response - @GET("/v0/sku") - suspend fun getSkuByProductId(@Query("productid") productid: String): Response - @POST("/v0/init") suspend fun initialize(@Body body: InitializeRequest): Response @GET("/v0/offerings") - suspend fun getOfferings(): Response + suspend fun getOfferingsBilling4(): Response + + @GET("/v1/offerings") + suspend fun getOfferingsBilling5(): Response @POST("/v1/token") suspend fun postToken(@Body token: TokenRequest): Response diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/AccountableSkuDto.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/AccountableSkuDto.kt index 9d8524a..5ec7ea0 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/AccountableSkuDto.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/AccountableSkuDto.kt @@ -12,6 +12,10 @@ data class AccountableSkuDto( val identifier: String?, @field:Json(name = "productid") val productId: String?, + @field:Json(name = "baseplan") + val baseplanId: String?, + @field:Json(name = "offerId") + val offerId: String?, @field:Json(name = "isinintrooffer") val isInIntroOfferPeriod: Boolean?, @field:Json(name = "istrial") @@ -26,6 +30,8 @@ data class AccountableSkuDto( } else { AccountableSku(identifier, productId, + baseplanId?.ifEmpty { null }, + offerId?.ifEmpty { null }, isInIntroOfferPeriod ?: false, isInTrialPeriod ?: false, store) diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/OfferingDto.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/OfferingDto.kt index 0aa38dd..91cc038 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/OfferingDto.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/OfferingDto.kt @@ -22,9 +22,8 @@ internal data class OfferingDto( } val skuList = skus?.mapNotNull { - val sku = it.toSku(identifier) - if (sku is Sku) sku else null - } - return Offering(identifier, skuList ?: emptyList()) + it.toSku(identifier) as? Sku + }.orEmpty() + return Offering(identifier, skuList) } } diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/SkuDetailsParamsDto.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/SkuDetailsParamsDto.kt new file mode 100644 index 0000000..388092d --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/SkuDetailsParamsDto.kt @@ -0,0 +1,31 @@ +package io.glassfy.androidsdk.internal.network.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import io.glassfy.androidsdk.internal.network.model.utils.DTOException +import io.glassfy.androidsdk.model.ProductType +import io.glassfy.androidsdk.model.SkuDetailsParams + +@JsonClass(generateAdapter = true) +data class SkuDetailsParamsDto( + @field:Json(name = "productid") + val productId: String?, + @field:Json(name = "baseplan") + val basePlanId: String?, + @field:Json(name = "offerid") + val offerId: String?, +) { + @Throws(DTOException::class) + internal fun toSkuDetailsParams(type: ProductType): SkuDetailsParams? { + if (productId == null || basePlanId == null) { + return null + } + + return SkuDetailsParams( + productId = productId, + basePlanId = basePlanId, + offerId = offerId, + type + ) + } +} diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/SkuDto.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/SkuDto.kt index 040b8ef..bcaf0b8 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/SkuDto.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/SkuDto.kt @@ -9,18 +9,36 @@ import io.glassfy.androidsdk.model.* internal data class SkuDto( @field:Json(name = "identifier") val identifier: String?, + @field:Json(name = "productid") val productId: String?, + @field:Json(name = "store") val store: Store?, + @field:Json(name = "extravars") val extravars: Map?, + @field:Json(name = "name") val name: String?, + @field:Json(name = "recurringprice") val initialprice: PaddlePriceDto?, + @field:Json(name = "initialprice") val recurringprice: PaddlePriceDto?, + + @field:Json(name = "baseplan") + val basePlanId: String?, + + @field:Json(name = "offerid") + val offerId: String?, + + @field:Json(name = "type") + val productType: ProductType?, + + @field:Json(name = "fallbacksku") + val fallbackSku: SkuDetailsParamsDto?, ) { @Throws(DTOException::class) internal fun toSku(offeringId: String? = null): ISkuBase { @@ -45,18 +63,23 @@ internal data class SkuDto( ) } Store.PlayStore -> { - if (identifier.isEmpty() || productId.isEmpty()) { + if (identifier.isBlank() || productId.isBlank()) { throw DTOException("Missing sku identifier/productId") } - Sku( identifier, - productId, extravars ?: emptyMap(), - offeringId + offeringId, + SkuDetailsParams( + productId, + basePlanId?.ifBlank { null }, + offerId?.ifBlank { null }, + productType ?: ProductType.UNKNOWN + ), + fallbackSku?.toSkuDetailsParams(productType ?: ProductType.UNKNOWN) ) } else -> SkuBase(identifier, productId, store) } } -} +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/InitializeRequest.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/InitializeRequest.kt index 6188fa4..07841f4 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/InitializeRequest.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/InitializeRequest.kt @@ -7,16 +7,12 @@ import io.glassfy.androidsdk.model.Purchase @JsonClass(generateAdapter = true) internal data class InitializeRequest( - @field:Json(name = "packagename") - val applicationId: String?, - @field:Json(name = "tokens") - val tokens: List?, - @field:Json(name = "install_time") - val installTime: Long?, - @field:Json(name = "cross_platform_sdk_framework") - val crossPlatformSdkFramework: String?, - @field:Json(name = "cross_platform_sdk_version") - val crossPlatformSdkVersion: String?, + @field:Json(name = "packagename") val applicationId: String?, + @field:Json(name = "owned_purchases") val ownedPurchases: List?, + @field:Json(name = "recent_purchases") val recentPurchases: List?, + @field:Json(name = "install_time") val installTime: Long?, + @field:Json(name = "cross_platform_sdk_framework") val crossPlatformSdkFramework: String?, + @field:Json(name = "cross_platform_sdk_version") val crossPlatformSdkVersion: String?, ) { companion object { internal fun from( @@ -28,16 +24,15 @@ internal data class InitializeRequest( installTime: Long?, crossPlatformSdkFramework: String?, crossPlatformSdkVersion: String?, - ) = - InitializeRequest( - applicationId, - hSubs.map { TokenRequest.from(it, true) } + - hInapp.map { TokenRequest.from(it, false) } + - subs.map { TokenRequest.from(it, true) } + - inapp.map { TokenRequest.from(it, false) } , - installTime, - crossPlatformSdkFramework, - crossPlatformSdkVersion, - ) + ) = InitializeRequest( + applicationId = applicationId, + ownedPurchases = subs.map { TokenRequest.from(it, true) } + + inapp.map { TokenRequest.from(it, false) }, + recentPurchases = hSubs.map { TokenRequest.from(it, true) } + + hInapp.map { TokenRequest.from(it, false) }, + installTime = installTime, + crossPlatformSdkFramework = crossPlatformSdkFramework, + crossPlatformSdkVersion = crossPlatformSdkVersion, + ) } -} \ No newline at end of file +} diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/SkuDetailsRequest.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/SkuDetailsRequest.kt new file mode 100644 index 0000000..a190f7f --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/SkuDetailsRequest.kt @@ -0,0 +1,40 @@ +package io.glassfy.androidsdk.internal.network.model.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import io.glassfy.androidsdk.model.SkuDetails + +@JsonClass(generateAdapter = true) +internal data class SkuDetailsRequest( + @field:Json(name = "productid") val sku: String?, + @field:Json(name = "baseplan") var basePlanId: String?, + @field:Json(name = "offerid") var offerId: String?, + + @field:Json(name = "subscription_period") val subscriptionPeriod: String?, + @field:Json(name = "freetrial_period") val freeTrialPeriod: String?, + + @field:Json(name = "price_currency") val priceCurrencyCode: String?, + @field:Json(name = "price_micro") val priceAmountMicro: Long?, + @field:Json(name = "originalprice_micro") val originalPriceAmountMicro: Long?, + + @field:Json(name = "intro_micro") val introductoryPriceAmountMicro: Long?, + @field:Json(name = "intro_cycles") val introductoryPriceAmountCycles: Int?, + @field:Json(name = "intro_period") val introductoryPriceAmountPeriod: String?, +) { + companion object { + internal fun from(s: SkuDetails) = + SkuDetailsRequest( + s.sku.ifEmpty { null }, + s.basePlanId.ifEmpty { null }, + s.offerId.ifEmpty { null }, + s.subscriptionPeriod.ifEmpty { null }, + s.freeTrialPeriod.ifEmpty { null }, + s.priceCurrencyCode.ifEmpty { null }, + s.priceAmountMicro.takeIf { it > 0 }, + s.originalPriceAmountMicro.takeIf { it != s.priceAmountMicro }, + s.introductoryPriceAmountMicro.takeIf { it > 0 }, + s.introductoryPriceAmountCycles.takeIf { it > 0 }, + s.introductoryPriceAmountPeriod.ifEmpty { null } + ) + } +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/TokenRequest.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/TokenRequest.kt index c1a7e03..69a55ff 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/TokenRequest.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/request/TokenRequest.kt @@ -4,30 +4,37 @@ import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import io.glassfy.androidsdk.model.HistoryPurchase import io.glassfy.androidsdk.model.Purchase +import io.glassfy.androidsdk.model.SkuDetails @JsonClass(generateAdapter = true) internal data class TokenRequest( - @field:Json(name = "purchasesubscription") - val isSubscription: Boolean?, - @field:Json(name = "productid") - val productid: List?, - @field:Json(name = "orderid") - val orderid: String?, - @field:Json(name = "purchasetime") - val purchasetime: Long?, - @field:Json(name = "token") - val token: String?, - @field:Json(name = "quantity") - val quantity: Int?, + @field:Json(name = "purchasesubscription") val isSubscription: Boolean?, + @field:Json(name = "productid") val productid: List?, + @field:Json(name = "orderid") val orderid: String?, + @field:Json(name = "purchasetime") val purchasetime: Long?, + @field:Json(name = "token") val token: String?, + @field:Json(name = "quantity") val quantity: Int?, + @field:Json(name = "offeringidentifier") val offeringId: String?, + @field:Json(name = "details") val details: SkuDetailsRequest? ) { - @field:Json(name = "offeringidentifier") - var offeringId: String? = null - companion object { - internal fun from(p: HistoryPurchase, isSubscription: Boolean) = - TokenRequest(isSubscription, p.skus, null, p.purchaseTime, p.purchaseToken, p.quantity) + internal fun from(p: HistoryPurchase, isSubscription: Boolean) = TokenRequest( + isSubscription, p.skus, null, p.purchaseTime, p.purchaseToken, p.quantity, null, null + ) + + internal fun from(p: Purchase, isSubscription: Boolean) = from( + p, isSubscription, null, null + ) - internal fun from(p: Purchase, isSubscription: Boolean) = - TokenRequest(isSubscription, p.skus, null, p.purchaseTime, p.purchaseToken, p.quantity) + internal fun from( + p: Purchase, isSubscription: Boolean, offeringId: String?, details: SkuDetails? + ) = TokenRequest(isSubscription, + p.skus, + null, + p.purchaseTime, + p.purchaseToken, + p.quantity, + offeringId, + details?.let { SkuDetailsRequest.from(it) }) } } diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/response/OfferingsResponse.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/response/OfferingsResponse.kt index 55ddf55..04d37e2 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/response/OfferingsResponse.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/response/OfferingsResponse.kt @@ -17,5 +17,6 @@ internal data class OfferingsResponse( val error: ErrorDto? ) { @Throws(DTOException::class) - internal fun toOfferings():Offerings = Offerings((offerings ?: emptyList()).map { o -> o.toOffering() }) + internal fun toOfferings(): Offerings = + Offerings((offerings ?: emptyList()).map { o -> o.toOffering() }) } diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/utils/DTOException.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/utils/DTOException.kt index 67663cb..4323bea 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/utils/DTOException.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/utils/DTOException.kt @@ -1,3 +1,3 @@ package io.glassfy.androidsdk.internal.network.model.utils -internal class DTOException(s: String) : Throwable(s) {} +internal class DTOException(s: String) : Throwable(s) diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/utils/ProductTypeAdapter.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/utils/ProductTypeAdapter.kt new file mode 100644 index 0000000..3e32355 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/network/model/utils/ProductTypeAdapter.kt @@ -0,0 +1,24 @@ +package io.glassfy.androidsdk.internal.network.model.utils + +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson +import io.glassfy.androidsdk.model.ProductType + +internal class ProductTypeAdapter { + @ToJson + private fun toJson(enum: ProductType): Int { + return when(enum) { + ProductType.INAPP -> 1 + ProductType.SUBS -> 2 + ProductType.NON_RENEWABLE -> 3 + ProductType.LICENSE_CODE -> 4 + ProductType.GLASSFY_CODE -> 5 + ProductType.UNKNOWN -> 0 + } + } + + @FromJson + fun fromJson(value: Int): ProductType { + return ProductType.fromValue(value) + } +} \ No newline at end of file diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/repository/IRepository.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/repository/IRepository.kt index d3652a8..185a55d 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/repository/IRepository.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/repository/IRepository.kt @@ -12,8 +12,7 @@ import io.glassfy.androidsdk.paywall.Paywall internal interface IRepository { suspend fun skuByIdentifier(id: String): Resource suspend fun skuByIdentifierAndStore(id: String, store: Store): Resource - suspend fun skuByProductId(id: String): Resource - suspend fun offerings(): Resource + suspend fun offerings(playBillingVersion: Int): Resource suspend fun token(token: TokenRequest): Resource suspend fun permissions(): Resource suspend fun lastSeen(): Resource diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/internal/repository/Repository.kt b/glassfy/src/main/java/io/glassfy/androidsdk/internal/repository/Repository.kt index b43fb28..725f3ac 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/internal/repository/Repository.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/internal/repository/Repository.kt @@ -219,36 +219,13 @@ internal class Repository( } } - override suspend fun skuByProductId(id: String): Resource { + override suspend fun offerings(playBillingVersion: Int): Resource { return try { - val response = api.getSkuByProductId(id) - val result = response.body() - if (response.isSuccessful && result?.sku != null) { - Resource.Success(result.sku.toSku() as Sku) + val response = if (playBillingVersion >= 5) { + api.getOfferingsBilling5() } else { - val err = - result?.error?.description?.let { GlassfyErrorCode.ServerError.toError(it) } - ?: GlassfyErrorCode.UnknowError.toError(response.message()) - Resource.Error(err) + api.getOfferingsBilling4() } - } catch (e: HttpException) { - Resource.Error(GlassfyErrorCode.HttpException.toError(e.message ?: e.toString())) - } catch (e: UnknownHostException) { - Resource.Error(GlassfyErrorCode.InternetConnection.toError(e.message ?: e.toString())) - } catch (e: IOException) { - Resource.Error(GlassfyErrorCode.IOException.toError(e.message ?: e.toString())) - } catch (e: JsonDataException) { - Resource.Error(GlassfyErrorCode.ServerError.toError(e.message ?: e.toString())) - } catch (e: DTOException) { - Resource.Error(GlassfyErrorCode.ServerError.toError(e.message ?: e.toString())) - } catch (e: Exception) { - Resource.Error(GlassfyErrorCode.UnknowError.toError(e.message ?: e.toString())) - } - } - - override suspend fun offerings(): Resource { - return try { - val response = api.getOfferings() val result = response.body() if (response.isSuccessful && result != null && result.error == null) { Resource.Success(result.toOfferings()) @@ -310,8 +287,8 @@ internal class Repository( val err = result?.error?.description?.let { when (result.error.code) { - GlassfyErrorCode.LicenseAlreadyConnected.internalCode -> GlassfyErrorCode.LicenseAlreadyConnected.toError(it) - GlassfyErrorCode.LicenseNotFound.internalCode -> GlassfyErrorCode.LicenseNotFound.toError(it) + GlassfyErrorCode.LicenseAlreadyConnected.internalCode!! -> GlassfyErrorCode.LicenseAlreadyConnected.toError(it) + GlassfyErrorCode.LicenseNotFound.internalCode!! -> GlassfyErrorCode.LicenseNotFound.toError(it) else -> GlassfyErrorCode.ServerError.toError(it) } } ?: GlassfyErrorCode.UnknowError.toError(response.message()) @@ -342,8 +319,8 @@ internal class Repository( val err = result?.error?.description?.let { when (result.error.code) { - GlassfyErrorCode.UniversalCodeAlreadyConnected.internalCode -> GlassfyErrorCode.LicenseAlreadyConnected.toError(it) - GlassfyErrorCode.UniversalCodeNotFound.internalCode -> GlassfyErrorCode.LicenseNotFound.toError(it) + GlassfyErrorCode.UniversalCodeAlreadyConnected.internalCode!! -> GlassfyErrorCode.LicenseAlreadyConnected.toError(it) + GlassfyErrorCode.UniversalCodeNotFound.internalCode!! -> GlassfyErrorCode.LicenseNotFound.toError(it) else -> GlassfyErrorCode.ServerError.toError(it) } } ?: GlassfyErrorCode.UnknowError.toError(response.message()) diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/model/AccountableSku.kt b/glassfy/src/main/java/io/glassfy/androidsdk/model/AccountableSku.kt index 1dcaadc..3e5e67e 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/model/AccountableSku.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/model/AccountableSku.kt @@ -3,6 +3,8 @@ package io.glassfy.androidsdk.model data class AccountableSku( override val skuId: String, override val productId: String, + val basePlanId: String?, + val offerId: String?, val isInIntroOfferPeriod: Boolean, val isInTrialPeriod: Boolean, override val store: Store diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/model/ProductType.kt b/glassfy/src/main/java/io/glassfy/androidsdk/model/ProductType.kt new file mode 100644 index 0000000..4a5fc69 --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/model/ProductType.kt @@ -0,0 +1,24 @@ +package io.glassfy.androidsdk.model + +enum class ProductType { + INAPP, + SUBS, + NON_RENEWABLE, + LICENSE_CODE, + GLASSFY_CODE, + UNKNOWN; + + companion object { + internal fun fromValue(value: Int): ProductType { + return when (value) { + 1 -> SUBS + 2 -> INAPP + 3 -> NON_RENEWABLE + 4 -> LICENSE_CODE + 5 -> GLASSFY_CODE + else -> UNKNOWN + } + } + } +} + diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/model/PurchaseHistory.kt b/glassfy/src/main/java/io/glassfy/androidsdk/model/PurchaseHistory.kt index a5ed03d..c916e06 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/model/PurchaseHistory.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/model/PurchaseHistory.kt @@ -1,6 +1,6 @@ package io.glassfy.androidsdk.model -import java.util.* +import java.util.Date data class PurchaseHistory( val productId: String, diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/model/Sku.kt b/glassfy/src/main/java/io/glassfy/androidsdk/model/Sku.kt index 0fecaec..0395d49 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/model/Sku.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/model/Sku.kt @@ -2,12 +2,30 @@ package io.glassfy.androidsdk.model data class Sku( override val skuId: String, - override val productId: String, val extravars: Map, - internal val offeringId: String? + internal val offeringId: String?, + internal val skuParams: SkuDetailsParams, + internal val fallbackSkuParams: SkuDetailsParams?, ) : ISkuBase { + override val store: Store get() = Store.PlayStore + override val productId: String + get() = product.sku + + val basePlanId: String? + get() = product.basePlanId.ifEmpty { null } + + val offerId: String? + get() = product.offerId.ifEmpty { null } + + val productType: ProductType + get() = product.type + lateinit var product: SkuDetails + + fun isSubscription(): Boolean { + return product.type == ProductType.SUBS + } } diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/model/SkuDetails.kt b/glassfy/src/main/java/io/glassfy/androidsdk/model/SkuDetails.kt index 3412959..3a4ca6b 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/model/SkuDetails.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/model/SkuDetails.kt @@ -4,19 +4,39 @@ data class SkuDetails( val description: String, val freeTrialPeriod: String, val iconUrl: String, + val sku: String, + val subscriptionPeriod: String, + val title: String, + val type: ProductType, + var basePlanId: String, + var offerId: String, + val offerToken: String, + val hashCode: Int, + + /** + * This is a special discounted price, not accounting for any free-trial period. + */ val introductoryPrice: String, val introductoryPriceAmountMicro: Long, val introductoryPriceAmountCycles: Int, val introductoryPriceAmountPeriod: String, + + /** + * Represents the full, undiscounted price of the product or subscription. + */ val originalPrice: String, val originalPriceAmountMicro: Long, + + /** + * Represents the current price of the product or subscription, which could be the full price or a discounted price. + * This price will be applied to subscription renewal after both the free-trial and introductory pricing period have ended. + */ val price: String, val priceAmountMicro: Long, val priceCurrencyCode: String, - val sku: String, - val subscriptionPeriod: String, - val title: String, - val type: String, - val hashCode: Int, - val originalJson: String + + /** + * Required for compatibility with LegacyBillingService + */ + internal var originalJson: String = "" ) diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/model/SkuDetailsParams.kt b/glassfy/src/main/java/io/glassfy/androidsdk/model/SkuDetailsParams.kt new file mode 100644 index 0000000..9727aee --- /dev/null +++ b/glassfy/src/main/java/io/glassfy/androidsdk/model/SkuDetailsParams.kt @@ -0,0 +1,8 @@ +package io.glassfy.androidsdk.model + +data class SkuDetailsParams( + val productId: String, + val basePlanId: String?, + val offerId: String?, + var productType: ProductType, +) diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/model/SubscriptionUpdate.kt b/glassfy/src/main/java/io/glassfy/androidsdk/model/SubscriptionUpdate.kt index 56bf524..0d96ffe 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/model/SubscriptionUpdate.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/model/SubscriptionUpdate.kt @@ -2,30 +2,32 @@ package io.glassfy.androidsdk.model data class SubscriptionUpdate( val originalSku: String, - val proration: ProrationMode = ProrationMode.IMMEDIATE_WITH_TIME_PRORATION + val replacement: ReplacementMode = ReplacementMode.WITH_TIME_PRORATION ) { internal var purchaseToken: String = "" } -enum class ProrationMode(internal val mode: Int) { - DEFERRED(4), - IMMEDIATE_AND_CHARGE_FULL_PRICE(5), - IMMEDIATE_AND_CHARGE_PRORATED_PRICE(2), // available only for subscription upgrade - IMMEDIATE_WITHOUT_PRORATION(3), - IMMEDIATE_WITH_TIME_PRORATION(1), - UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY(0); +enum class ReplacementMode(internal val mode: Int) { + CHARGE_FULL_PRICE(5), + CHARGE_PRORATED_PRICE(2), + DEFERRED(6), + UNKNOWN_REPLACEMENT_MODE(0), + WITHOUT_PRORATION(3), + WITH_TIME_PRORATION(1); companion object { - fun fromProrationModeValue(value: Int): ProrationMode { + fun fromReplacementModeValue(value: Int): ReplacementMode { return when (value) { - 0 -> UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY - 1 -> IMMEDIATE_WITH_TIME_PRORATION - 2 -> IMMEDIATE_AND_CHARGE_PRORATED_PRICE - 3 -> IMMEDIATE_WITHOUT_PRORATION - 4 -> DEFERRED - 5 -> IMMEDIATE_AND_CHARGE_FULL_PRICE - else -> throw IllegalArgumentException("Undefined ProrationMode") + 0 -> UNKNOWN_REPLACEMENT_MODE + 1 -> WITH_TIME_PRORATION + 2 -> CHARGE_PRORATED_PRICE + 3 -> WITHOUT_PRORATION + 5 -> CHARGE_FULL_PRICE + 6 -> DEFERRED + else -> throw IllegalArgumentException("Undefined ReplacementMode") } } } } + + diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/paywall/DurationFormatter.kt b/glassfy/src/main/java/io/glassfy/androidsdk/paywall/DurationFormatter.kt index ccc3ce2..ce4837f 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/paywall/DurationFormatter.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/paywall/DurationFormatter.kt @@ -122,7 +122,7 @@ internal class DurationFormatter private constructor( @RequiresApi(Build.VERSION_CODES.N) fun unitName(): String? { - return DurationFormatter.unitName(unit) + return unitName(unit) } private val unit = bestUnit(weeks, months, years) diff --git a/glassfy/src/main/java/io/glassfy/androidsdk/paywall/PaywallResponse.kt b/glassfy/src/main/java/io/glassfy/androidsdk/paywall/PaywallResponse.kt index 66e2557..c998941 100644 --- a/glassfy/src/main/java/io/glassfy/androidsdk/paywall/PaywallResponse.kt +++ b/glassfy/src/main/java/io/glassfy/androidsdk/paywall/PaywallResponse.kt @@ -40,8 +40,7 @@ internal data class PaywallResponse( } val skuList = skus?.mapNotNull { - val sku = it.toSku(paywall.pwid) - if (sku is Sku) sku else null + it.toSku(paywall.pwid) as? Sku } ?: emptyList() return Paywall( diff --git a/glassfy/src/test/java/io/glassfy/androidsdk/GlassfyTest.kt b/glassfy/src/test/java/io/glassfy/androidsdk/GlassfyTest.kt index c2007da..b91f949 100644 --- a/glassfy/src/test/java/io/glassfy/androidsdk/GlassfyTest.kt +++ b/glassfy/src/test/java/io/glassfy/androidsdk/GlassfyTest.kt @@ -45,7 +45,7 @@ class GlassfyTest { fun `Offering no initialize`() { var _offerings: Offerings? = null var _error: GlassfyError? = null - Glassfy.offerings() { result, error -> + Glassfy.offerings { result, error -> assertEquals(Thread.currentThread().id, mainThreadId) _offerings = result