Skip to content

Commit

Permalink
Merge pull request #96 from superwall-me/develop
Browse files Browse the repository at this point in the history
v1.0.1
  • Loading branch information
yusuftor authored Mar 8, 2024
2 parents a4dcd73 + 336627c commit f1a16a0
Show file tree
Hide file tree
Showing 16 changed files with 767 additions and 360 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

The changelog for `Superwall`. Also see the [releases](https://github.com/superwall-me/Superwall-Android/releases) on GitHub.

## 1.0.1

### Fixes

- Fixes serialization of `feature_gating` in `SuperwallEvents`.
- Changes the product loading so that if preloading is enabled, it makes one API request to get all
products available in paywalls. This results in fewer API requests. Also, it adds retry logic on failure.
If billing isn't available on device, the `onError` handler will be called.

## 1.0.0

### Breaking Changes
Expand Down
2 changes: 1 addition & 1 deletion superwall/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ plugins {
id("maven-publish")
}

version = "1.0.0"
version = "1.0.1"

android {
compileSdk = 33
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.superwall.sdk.billing

import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchaseHistoryParams
import com.android.billingclient.api.QueryPurchasesParams

internal fun @receiver:BillingClient.ProductType String.buildQueryPurchaseHistoryParams(): QueryPurchaseHistoryParams? {
return when (this) {
BillingClient.ProductType.INAPP,
BillingClient.ProductType.SUBS,
-> QueryPurchaseHistoryParams.newBuilder().setProductType(this).build()
else -> null
}
}

internal fun @receiver:BillingClient.ProductType String.buildQueryPurchasesParams(): QueryPurchasesParams? {
return when (this) {
BillingClient.ProductType.INAPP,
BillingClient.ProductType.SUBS,
-> QueryPurchasesParams.newBuilder().setProductType(this).build()
else -> null
}
}

internal fun @receiver:BillingClient.ProductType String.buildQueryProductDetailsParams(
productIds: Set<String>,
): QueryProductDetailsParams {
val productList = productIds.map { productId ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(this)
.build()
}
return QueryProductDetailsParams.newBuilder()
.setProductList(productList).build()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package com.superwall.sdk.billing

import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
import java.io.PrintWriter
import java.io.StringWriter
import kotlin.math.min

internal typealias ExecuteRequestOnUIThreadFunction = (delayInMillis: Long, onError: (BillingError?) -> Unit) -> Unit

private const val MAX_RETRIES_DEFAULT = 3
private const val RETRY_TIMER_START_MILLISECONDS = 878L // So it gets close to 15 minutes in last retry
internal const val RETRY_TIMER_MAX_TIME_MILLISECONDS = 1000L * 60L * 15L // 15 minutes

internal interface UseCaseParams {
val appInBackground: Boolean
}

/**
* A superclass that is used to interact with the billing client. It coordinates the request on the
* UI thread. Deals with error handling such as retries and returning errors.
*/
internal abstract class BillingClientUseCase<T>(
private val useCaseParams: UseCaseParams,
private val onError: (BillingError) -> Unit,
val executeRequestOnUIThread: ExecuteRequestOnUIThreadFunction,
) {
protected open val backoffForNetworkErrors = false

private val maxRetries: Int = MAX_RETRIES_DEFAULT
private var retryAttempt: Int = 0
private var retryBackoffMilliseconds = RETRY_TIMER_START_MILLISECONDS

fun run(
delayMilliseconds: Long = 0,
) {
executeRequestOnUIThread(delayMilliseconds) { connectionError ->
if (connectionError == null) {
this.executeAsync()
} else {
onError(connectionError)
}
}
}

abstract fun executeAsync()
abstract fun onOk(received: T)

fun processResult(
billingResult: BillingResult,
response: T,
onSuccess: (T) -> Unit = ::onOk,
onError: (BillingResult) -> Unit = ::forwardError,
) {
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
retryBackoffMilliseconds = RETRY_TIMER_START_MILLISECONDS
onSuccess(response)
}

BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> {
Logger.debug(
logLevel = LogLevel.error,
scope = LogScope.productsManager,
message = "Billing Service disconnected."
)
run()
}

BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> {
backoffOrErrorIfUseInSession(onError, billingResult)
}

BillingClient.BillingResponseCode.NETWORK_ERROR,
BillingClient.BillingResponseCode.ERROR,
-> {
backoffOrRetryNetworkError(onError, billingResult)
}

else -> {
onError(billingResult)
}
}
}

protected fun BillingClient?.withConnectedClient(receivingFunction: BillingClient.() -> Unit) {
this?.takeIf { it.isReady }?.let {
it.receivingFunction()
} ?: Logger.debug(
logLevel = LogLevel.warn,
scope = LogScope.productsManager,
message = "Billing Service disconnected. Stack trace: ${getStackTrace()}"
)
}

private fun getStackTrace(): String {
val stringWriter = StringWriter()
val printWriter = PrintWriter(stringWriter)
Throwable().printStackTrace(printWriter)
return stringWriter.toString()
}

private fun forwardError(billingResult: BillingResult) {
val underlyingErrorMessage = "Error loading products - DebugMessage: ${billingResult.debugMessage} " +
"ErrorCode: ${billingResult.responseCode}."
val error = BillingError.BillingNotAvailable(underlyingErrorMessage)
Logger.debug(
logLevel = LogLevel.error,
scope = LogScope.productsManager,
message = underlyingErrorMessage
)
onError(error)
}

private fun backoffOrRetryNetworkError(
onError: (BillingResult) -> Unit,
billingResult: BillingResult,
) {
if (backoffForNetworkErrors && retryBackoffMilliseconds < RETRY_TIMER_MAX_TIME_MILLISECONDS) {
retryWithBackoff()
} else if (!backoffForNetworkErrors && retryAttempt < maxRetries) {
retryAttempt++
executeAsync()
} else {
onError(billingResult)
}
}

private fun backoffOrErrorIfUseInSession(
onError: (BillingResult) -> Unit,
billingResult: BillingResult,
) {
if (useCaseParams.appInBackground) {
Logger.debug(
logLevel = LogLevel.warn,
scope = LogScope.productsManager,
message = "Billing is unavailable. App is in background, will retry with backoff."
)
if (retryBackoffMilliseconds < RETRY_TIMER_MAX_TIME_MILLISECONDS) {
retryWithBackoff()
} else {
onError(billingResult)
}
} else {
Logger.debug(
logLevel = LogLevel.error,
scope = LogScope.productsManager,
message = "Billing is unavailable. App is in foreground. Won't retry."
)
onError(billingResult)
}
}

private fun retryWithBackoff() {
retryBackoffMilliseconds.let { currentDelayMilliseconds ->
retryBackoffMilliseconds = min(
retryBackoffMilliseconds * 2,
RETRY_TIMER_MAX_TIME_MILLISECONDS,
)
run(currentDelayMilliseconds)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.superwall.sdk.billing

sealed class BillingError(val code: Int, val description: String) : Exception(description) {
object UnknownError : BillingError(0, "Unknown error.")
object IllegalStateException : BillingError(1, "IllegalStateException when connecting to billing client")
// Define a class for custom errors where you can pass a message
class BillingNotAvailable(description: String) : BillingError(2, description)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.superwall.sdk.billing

import com.superwall.sdk.store.abstractions.product.OfferType

data class DecomposedProductIds(
val subscriptionId: String,
val basePlanId: String?,
val offerType: OfferType?,
val fullId: String
) {
companion object {
fun from(productId: String): DecomposedProductIds {
val components = productId.split(":")
val subscriptionId = components.getOrNull(0) ?: ""
val basePlanId = components.getOrNull(1)
val offerId = components.getOrNull(2)
var offerType: OfferType? = null

if (offerId == "sw-auto") {
offerType = OfferType.Auto
} else if (offerId != null) {
offerType = OfferType.Offer(id = offerId)
}
return DecomposedProductIds(
subscriptionId = subscriptionId,
basePlanId = basePlanId,
offerType = offerType,
fullId = productId
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.superwall.sdk.billing

import com.superwall.sdk.store.abstractions.product.StoreProduct

interface GetStoreProductsCallback {
/**
* Will be called after products have been fetched successfully
*
* @param [storeProducts] The list of [StoreProduct] that have been able to be successfully fetched from the store.
* Not found products will be ignored.
*/
@JvmSuppressWildcards
fun onReceived(storeProducts: Set<StoreProduct>)

/**
* Will be called after the purchase has completed with error
*
* @param error A [Error] containing the reason for the failure when fetching the [StoreProduct]
*/
fun onError(error: BillingError)
}
Loading

0 comments on commit f1a16a0

Please sign in to comment.