diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b510b7d2..f03686cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,7 +146,8 @@ dependencies { addTesting() - "gplayImplementation"("com.android.billingclient:billing:4.0.0") + "gplayImplementation"("com.android.billingclient:billing:5.1.0") + "gplayImplementation"("com.android.billingclient:billing-ktx:5.1.0") "gplayImplementation"("com.bugsnag:bugsnag-android:5.9.2") "gplayImplementation"("com.getkeepsafe.relinker:relinker:1.4.3") diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingExtensions.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingExtensions.kt deleted file mode 100644 index eafeca20..00000000 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/BillingExtensions.kt +++ /dev/null @@ -1,13 +0,0 @@ -package eu.darken.capod.common.upgrade.core - -import com.android.billingclient.api.Purchase -import eu.darken.capod.common.debug.logging.logTag -import eu.darken.capod.common.upgrade.core.data.PurchasedSku -import eu.darken.capod.common.upgrade.core.data.Sku - - -fun Purchase.toPurchasedSku(): Collection = skus.map { - PurchasedSku(Sku(it), this) -} - -private val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Extensions") diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt index de28a1ee..50b2c969 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/UpgradeRepoGplay.kt @@ -49,7 +49,7 @@ class UpgradeRepoGplay @Inject constructor( lastProStateAt = now Info(billingData = data) } - (now - lastProStateAt) < 6 * 60 * 1000L -> { // 6 hours + (now - lastProStateAt) < 6 * 60 * 60 * 1000L -> { // 6 hours log(TAG, VERBOSE) { "We are not pro, but were recently, did GPlay try annoy us again?" } Info(gracePeriod = true, billingData = null) } @@ -62,7 +62,7 @@ class UpgradeRepoGplay @Inject constructor( // Ignore Google Play errors if the last pro state was recent val now = System.currentTimeMillis() log(TAG) { "now=$now, lastProStateAt=$lastProStateAt, error=$it" } - if ((now - lastProStateAt) < 6 * 60 * 60 * 1000L) { // 6 hours + if ((now - lastProStateAt) < 24 * 60 * 60 * 1000L) { // 24 hours log(TAG, VERBOSE) { "We are not pro, but were recently, and just and an error, what is GPlay doing???" } emit(Info(gracePeriod = true, billingData = null)) } else { @@ -123,7 +123,7 @@ class UpgradeRepoGplay @Inject constructor( get() = UpgradeRepo.Type.GPLAY override val isPro: Boolean - get() = billingData?.getProSku() != null + get() = billingData?.getProSku() != null || gracePeriod override val upgradedAt: Instant? get() = billingData diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt index 0d524f20..741ddd66 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnection.kt @@ -2,6 +2,7 @@ package eu.darken.capod.common.upgrade.core.client import android.app.Activity import com.android.billingclient.api.* +import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag @@ -38,7 +39,7 @@ data class BillingClientConnection( if (!result.isSuccess) { log(TAG, WARN) { "queryPurchases() failed" } - throw BillingClientException(result) + throw BillingResultException(result) } else { requireNotNull(purchases) } @@ -47,34 +48,31 @@ data class BillingClientConnection( return purchases } - suspend fun acknowledgePurchase(purchase: Purchase): BillingResult { + suspend fun acknowledgePurchase(purchase: Purchase) { val ack = AcknowledgePurchaseParams.newBuilder().apply { setPurchaseToken(purchase.purchaseToken) }.build() - val ackResult = suspendCoroutine { continuation -> + val result = suspendCoroutine { continuation -> client.acknowledgePurchase(ack) { continuation.resume(it) } } - log(TAG) { - "acknowledgePurchase(purchase=$purchase): code=${ackResult.responseCode}, message=${ackResult.debugMessage})" - } - if (!ackResult.isSuccess) { - throw BillingClientException(ackResult) - } + log(TAG, INFO) { "acknowledgePurchase($purchase): code=${result.responseCode} (${result.debugMessage})" } - return ackResult + if (!result.isSuccess) throw BillingResultException(result) } suspend fun querySku(sku: Sku): Sku.Details { - val skuParams = SkuDetailsParams.newBuilder().apply { - setType(BillingClient.SkuType.INAPP) - setSkusList(listOf(sku.id)) + val productDetails = QueryProductDetailsParams.Product.newBuilder().apply { + setProductType(BillingClient.ProductType.INAPP) + setProductId(sku.id) }.build() - val (result, details) = suspendCoroutine?>> { continuation -> - client.querySkuDetailsAsync(skuParams) { skuResult, skuDetails -> - continuation.resume(skuResult to skuDetails) + val params = QueryProductDetailsParams.newBuilder().setProductList(listOf(productDetails)).build() + + val (result, details) = suspendCoroutine?>> { continuation -> + client.queryProductDetailsAsync(params) { result, skuDetails -> + continuation.resume(result to skuDetails) } } @@ -82,7 +80,7 @@ data class BillingClientConnection( "querySku(sku=$sku): code=${result.responseCode}, debug=${result.debugMessage}), skuDetails=$details" } - if (!result.isSuccess) throw BillingClientException(result) + if (!result.isSuccess) throw BillingResultException(result) if (details.isNullOrEmpty()) throw IllegalStateException("Unknown SKU, no details available.") @@ -97,10 +95,16 @@ data class BillingClientConnection( suspend fun launchBillingFlow(activity: Activity, skuDetails: Sku.Details): BillingResult { log(TAG) { "launchBillingFlow(activity=$activity, skuDetails=$skuDetails)" } - return client.launchBillingFlow( - activity, - BillingFlowParams.newBuilder().setSkuDetails(skuDetails.details.single()).build() - ) + + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().apply { + setProductDetails(skuDetails.details.first()) + }.build() + + val billingFlowParams = BillingFlowParams.newBuilder().apply { + setProductDetailsParamsList(listOf(productParams)) + }.build() + + return client.launchBillingFlow(activity, billingFlowParams) } companion object { diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt index 80b9cf4f..0d9089d0 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientConnectionProvider.kt @@ -72,7 +72,7 @@ class BillingClientConnectionProvider @Inject constructor( } } else -> { - close(BillingClientException(result)) + close(BillingResultException(result)) } } } @@ -105,7 +105,7 @@ class BillingClientConnectionProvider @Inject constructor( return@retryWhen false } - if (cause is BillingClientException && cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) { + if (cause is BillingResultException && cause.result.isGplayUnavailablePermanent) { log(TAG) { "Got BILLING_UNAVAILABLE while trying to connect client." } return@retryWhen false } diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt index 65e1bbef..fb025feb 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt @@ -4,4 +4,14 @@ import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingResult internal val BillingResult.isSuccess: Boolean - get() = responseCode == BillingClient.BillingResponseCode.OK \ No newline at end of file + get() = responseCode == BillingClient.BillingResponseCode.OK + +internal val BillingResult.isGplayUnavailableTemporary: Boolean + get() = setOf( + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, + BillingClient.BillingResponseCode.SERVICE_DISCONNECTED, + BillingClient.BillingResponseCode.SERVICE_TIMEOUT + ).contains(responseCode) + +internal val BillingResult.isGplayUnavailablePermanent: Boolean + get() = responseCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientException.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingResultException.kt similarity index 61% rename from app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientException.kt rename to app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingResultException.kt index f5b7acea..88cb9d1f 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientException.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingResultException.kt @@ -2,8 +2,8 @@ package eu.darken.capod.common.upgrade.core.client import com.android.billingclient.api.BillingResult -class BillingClientException(val result: BillingResult) : BillingException(result.debugMessage) { +class BillingResultException(val result: BillingResult) : BillingException(result.debugMessage) { override fun toString(): String = - "BillingClientException(code=${result.responseCode}, message=${result.debugMessage})" + "BillingResultException(code=${result.responseCode}, message=${result.debugMessage})" } \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/GplayServiceUnavailableException.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/GplayServiceUnavailableException.kt index 0c679d24..655bfd86 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/GplayServiceUnavailableException.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/GplayServiceUnavailableException.kt @@ -7,13 +7,9 @@ import eu.darken.capod.common.error.LocalizedError class GplayServiceUnavailableException(cause: Throwable) : Exception("Google Play services are unavailable.", cause), HasLocalizedError { - override fun getLocalizedError(context: Context): LocalizedError { - return LocalizedError( - throwable = this, - label = "Google Play Services Unavailable", - description = context.getString(R.string.upgrades_gplay_unavailable_error) - ) - } - - + override fun getLocalizedError(context: Context): LocalizedError = LocalizedError( + throwable = this, + label = "Google Play Services Unavailable", + description = context.getString(R.string.upgrades_gplay_unavailable_error) + ) } \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt index 2b941374..e5e3e34e 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingData.kt @@ -1,11 +1,14 @@ package eu.darken.capod.common.upgrade.core.data import com.android.billingclient.api.Purchase -import eu.darken.capod.common.upgrade.core.toPurchasedSku data class BillingData( val purchases: Collection ) { val purchasedSkus: Collection get() = purchases.map { it.toPurchasedSku() }.flatten() + + private fun Purchase.toPurchasedSku(): Collection = skus.map { + PurchasedSku(Sku(it), this) + } } \ No newline at end of file diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt index 093a09a8..43d6308b 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt @@ -1,7 +1,6 @@ package eu.darken.capod.common.upgrade.core.data import android.app.Activity -import com.android.billingclient.api.BillingClient.BillingResponseCode import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.debug.Bugs import eu.darken.capod.common.debug.logging.Logging.Priority.* @@ -10,9 +9,7 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.replayingShare import eu.darken.capod.common.flow.setupCommonEventHandlers -import eu.darken.capod.common.upgrade.core.client.BillingClientConnectionProvider -import eu.darken.capod.common.upgrade.core.client.BillingClientException -import eu.darken.capod.common.upgrade.core.client.GplayServiceUnavailableException +import eu.darken.capod.common.upgrade.core.client.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -53,37 +50,34 @@ class BillingDataRepo @Inject constructor( } .forEach { log(TAG, INFO) { "Acknowledging purchase: $it" } - - try { - client.acknowledgePurchase(it) - } catch (e: Exception) { - log(TAG, ERROR) { "Failed to ancknowledge purchase: $it\n${e.asLog()}" } - } + client.acknowledgePurchase(it) } } .setupCommonEventHandlers(TAG) { "connection-acks" } .retryWhen { cause, attempt -> + log(TAG, ERROR) { "Failed to acknowledge purchase: ${cause.asLog()}" } + if (cause is CancellationException) { log(TAG) { "Ack was cancelled (appScope?) cancelled." } return@retryWhen false } + if (attempt > 5) { log(TAG, WARN) { "Reached attempt limit: $attempt due to $cause" } return@retryWhen false } - if (cause !is BillingClientException) { - log(TAG, WARN) { "Unknown BillingClient exception type: $cause" } + + if (cause !is BillingException) { + log(TAG, WARN) { "Unknown exception type: $cause" } return@retryWhen false - } else { - log(TAG) { "BillingClient exception: $cause; ${cause.result}" } } - if (cause.result.responseCode == BillingResponseCode.BILLING_UNAVAILABLE) { + if (cause is BillingResultException && cause.result.isGplayUnavailablePermanent) { log(TAG) { "Got BILLING_UNAVAILABLE while trying to ACK purchase." } return@retryWhen false } - log(TAG) { "Will retry ACK" } + log(TAG) { "Will retry ACK (attempt=$attempt)" } delay(3000 * attempt) true } @@ -108,7 +102,7 @@ class BillingDataRepo @Inject constructor( } catch (e: Exception) { log(TAG, WARN) { "Failed to start IAP flow:\n${e.asLog()}" } val ignoredCodes = listOf(3, 6) - if (e !is BillingClientException || !e.result.responseCode.let { ignoredCodes.contains(it) }) { + if (e !is BillingResultException || !e.result.responseCode.let { ignoredCodes.contains(it) }) { Bugs.report(TAG, "IAP flow failed for $sku", e) } @@ -119,16 +113,14 @@ class BillingDataRepo @Inject constructor( companion object { val TAG: String = logTag("Upgrade", "Gplay", "Billing", "DataRepo") - internal fun Throwable.tryMapUserFriendly(): Throwable { - if (this !is BillingClientException) return this - - return when (result.responseCode) { - BillingResponseCode.BILLING_UNAVAILABLE, - BillingResponseCode.SERVICE_UNAVAILABLE, - BillingResponseCode.SERVICE_DISCONNECTED, - BillingResponseCode.SERVICE_TIMEOUT -> GplayServiceUnavailableException(this) - else -> this + internal fun Throwable.tryMapUserFriendly(): Throwable = when { + this is BillingResultException && this.result.isGplayUnavailableTemporary -> { + GplayServiceUnavailableException(this) } + this is BillingResultException && this.result.isGplayUnavailablePermanent -> { + GplayServiceUnavailableException(this) + } + else -> this } } } diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt index 2ab5ce9c..74ae50d1 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/Sku.kt @@ -1,12 +1,12 @@ package eu.darken.capod.common.upgrade.core.data -import com.android.billingclient.api.SkuDetails +import com.android.billingclient.api.ProductDetails data class Sku( val id: String ) { data class Details( val sku: Sku, - val details: Collection, + val details: Collection, ) } \ No newline at end of file