Skip to content

Commit

Permalink
in app messages support (#1290)
Browse files Browse the repository at this point in the history
This PR provides support for in app messages for declined payments with
BillingClient. They can be shown using
`Purchases.showDeclinedPaymentMessageIfNeeded`. It also provides an API
in `PurchasesConfiguration` to display those messages automatically.


![image](https://github.com/RevenueCat/purchases-android/assets/808417/12c5980c-340a-48b0-ba44-825d138c0991)

---------

Co-authored-by: Toni Rico <antonio.rico.diez@revenuecat.com>
  • Loading branch information
aboedo and tonidero authored Sep 26, 2023
1 parent be660b2 commit e7bbdcd
Show file tree
Hide file tree
Showing 23 changed files with 487 additions and 87 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,11 @@ static void checkConfiguration(final Context context,
.service(executorService)
.diagnosticsEnabled(true)
.entitlementVerificationMode(EntitlementVerificationMode.INFORMATIONAL)
.showDeclinedPaymentMessagesAutomatically(true)
.build();

final Boolean showDeclinedPaymentMessagesAutomatically = build.getShowDeclinedPaymentMessagesAutomatically();

final Purchases instance = Purchases.getSharedInstance();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,15 @@ private class PurchasesCommonAPI {
.appUserID("")
.observerMode(true)
.observerMode(false)
.showDeclinedPaymentMessagesAutomatically(true)
.service(executorService)
.diagnosticsEnabled(true)
.entitlementVerificationMode(EntitlementVerificationMode.INFORMATIONAL)
.informationalVerificationModeAndDiagnosticsEnabled(true)
.build()

val showDeclinedPaymentMessagesAutomatically: Boolean = build.showDeclinedPaymentMessagesAutomatically

val instance: Purchases = Purchases.sharedInstance
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ class Purchases internal constructor(
purchasesOrchestrator.removeUpdatedCustomerInfoListener()
}

/**
* Google Play only, no-op for Amazon.
* If the user has had a payment declined, this will show a toast notification notifying them and
* providing instructions for recovery of the subscription.
* If [PurchasesConfiguration.showDeclinedPaymentMessagesAutomatically] is enabled, this will be done
* automatically on each Activity's onStart.
*
* For more info: https://rev.cat/googleplayinappmessaging
*/
fun showDeclinedPaymentMessageIfNeeded(activity: Activity) {
purchasesOrchestrator.showDeclinedPaymentMessageIfNeeded(activity)
}

/**
* Restores purchases made with the current Play Store account for the current user.
* This method will post all purchases associated with the current Play Store account to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,19 @@ class Purchases internal constructor(
purchasesOrchestrator.removeUpdatedCustomerInfoListener()
}

/**
* Google Play only, no-op for Amazon.
* If the user has had a payment declined, this will show a toast notification notifying them and
* providing instructions for recovery of the subscription.
* If [PurchasesConfiguration.showDeclinedPaymentMessagesAutomatically] is enabled, this will be done
* automatically on each Activity's onStart.
*
* For more info: https://rev.cat/googleplayinappmessaging
*/
fun showDeclinedPaymentMessageIfNeeded(activity: Activity) {
purchasesOrchestrator.showDeclinedPaymentMessageIfNeeded(activity)
}

/**
* Invalidates the cache for customer information.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ open class PurchasesConfiguration(builder: Builder) {
val apiKey: String
val appUserID: String?
val observerMode: Boolean
val showDeclinedPaymentMessagesAutomatically: Boolean
val service: ExecutorService?
val store: Store
val diagnosticsEnabled: Boolean
Expand All @@ -25,6 +26,7 @@ open class PurchasesConfiguration(builder: Builder) {
this.diagnosticsEnabled = builder.diagnosticsEnabled
this.verificationMode = builder.verificationMode
this.dangerousSettings = builder.dangerousSettings
this.showDeclinedPaymentMessagesAutomatically = builder.showDeclinedPaymentMessagesAutomatically
}

open class Builder(
Expand All @@ -38,6 +40,9 @@ open class PurchasesConfiguration(builder: Builder) {
@set:JvmSynthetic @get:JvmSynthetic
internal var observerMode: Boolean = false

@set:JvmSynthetic @get:JvmSynthetic
internal var showDeclinedPaymentMessagesAutomatically: Boolean = true

@set:JvmSynthetic @get:JvmSynthetic
internal var service: ExecutorService? = null

Expand All @@ -57,6 +62,18 @@ open class PurchasesConfiguration(builder: Builder) {
this.appUserID = appUserID
}

/**
* Enable this setting to show a toast with recovery options for users who have had a declined payment
* automatically. Default is enabled.
* For more info: https://rev.cat/googleplayinappmessaging
*
* If this setting is disabled, you can show the snackbar by calling
* [Purchases.showDeclinedPaymentMessageIfNeeded]
*/
fun showDeclinedPaymentMessagesAutomatically(showDeclinedPaymentMessagesAutomatically: Boolean) = apply {
this.showDeclinedPaymentMessagesAutomatically = showDeclinedPaymentMessagesAutomatically
}

fun observerMode(observerMode: Boolean) = apply {
this.observerMode = observerMode
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ internal class PurchasesFactory(
val appConfig = AppConfig(
context,
observerMode,
showDeclinedPaymentMessagesAutomatically,
platformInfo,
proxyURL,
store,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ import com.revenuecat.purchases.strings.IdentityStrings
import com.revenuecat.purchases.strings.PurchaseStrings
import com.revenuecat.purchases.strings.RestoreStrings
import com.revenuecat.purchases.subscriberattributes.SubscriberAttributesManager
import com.revenuecat.purchases.utils.CustomActivityLifecycleHandler
import com.revenuecat.purchases.utils.isAndroidNOrNewer
import java.net.URL
import java.util.Collections

@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
internal class PurchasesOrchestrator constructor(
private val application: Application,
backingFieldAppUserID: String?,
Expand All @@ -81,7 +82,7 @@ internal class PurchasesOrchestrator constructor(
private val offeringsManager: OfferingsManager,
// This is nullable due to: https://github.com/RevenueCat/purchases-flutter/issues/408
private val mainHandler: Handler? = Handler(Looper.getMainLooper()),
) : LifecycleDelegate {
) : LifecycleDelegate, CustomActivityLifecycleHandler {

/** @suppress */
@Suppress("RedundantGetter", "RedundantSetter")
Expand Down Expand Up @@ -144,6 +145,7 @@ internal class PurchasesOrchestrator constructor(
// This needs to happen after the billing client listeners have been set. This is because
// we perform operations with the billing client in the lifecycle observer methods.
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleHandler)
application.registerActivityLifecycleCallbacks(this)
}

if (!appConfig.dangerousSettings.autoSyncPurchases) {
Expand Down Expand Up @@ -188,6 +190,12 @@ internal class PurchasesOrchestrator constructor(
offlineEntitlementsManager.updateProductEntitlementMappingCacheIfStale()
}

override fun onActivityStarted(activity: Activity) {
if (appConfig.showDeclinedPaymentMessagesAutomatically) {
showDeclinedPaymentMessageIfNeeded(activity)
}
}

// region Public Methods

fun syncPurchases(
Expand Down Expand Up @@ -454,6 +462,12 @@ internal class PurchasesOrchestrator constructor(
this.updatedCustomerInfoListener = null
}

fun showDeclinedPaymentMessageIfNeeded(activity: Activity) {
billing.showInAppMessagesIfNeeded(activity) {
syncPurchases()
}
}

fun invalidateCustomerInfoCache() {
log(LogIntent.DEBUG, CustomerInfoStrings.INVALIDATING_CUSTOMERINFO_CACHE)
deviceCache.clearCustomerInfoCache(appUserID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ internal class AmazonBilling constructor(
)
}

override fun showInAppMessagesIfNeeded(activity: Activity, subscriptionStatusChange: () -> Unit) {
// No-op: Amazon doesn't have in-app messages
}

private fun List<Receipt>.toMapOfReceiptHashesToRestoredPurchases(
tokensToSkusMap: Map<String, String>,
userData: UserData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import java.net.URL
internal class AppConfig(
context: Context,
observerMode: Boolean,
val showDeclinedPaymentMessagesAutomatically: Boolean,
val platformInfo: PlatformInfo,
proxyURL: URL?,
val store: Store,
Expand Down Expand Up @@ -56,6 +57,7 @@ internal class AppConfig(
if (forceServerErrors != other.forceServerErrors) return false
if (forceSigningErrors != other.forceSigningErrors) return false
if (baseURL != other.baseURL) return false
if (showDeclinedPaymentMessagesAutomatically != other.showDeclinedPaymentMessagesAutomatically) return false

return true
}
Expand All @@ -71,6 +73,7 @@ internal class AppConfig(
result = 31 * result + forceServerErrors.hashCode()
result = 31 * result + forceSigningErrors.hashCode()
result = 31 * result + baseURL.hashCode()
result = 31 * result + showDeclinedPaymentMessagesAutomatically.hashCode()
return result
}

Expand All @@ -83,6 +86,7 @@ internal class AppConfig(
"versionName='$versionName', " +
"packageName='$packageName', " +
"finishTransactions=$finishTransactions, " +
"showDeclinedPaymentMessagesAutomatically=$showDeclinedPaymentMessagesAutomatically, " +
"baseURL=$baseURL)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ internal abstract class BillingAbstract {
onSuccess(productID)
}

abstract fun showInAppMessagesIfNeeded(activity: Activity, subscriptionStatusChange: () -> Unit)

interface PurchasesUpdatedListener {
fun onPurchasesUpdated(purchases: List<StoreTransaction>)
fun onPurchasesFailedToUpdate(purchasesError: PurchasesError)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ 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.InAppMessageParams
import com.android.billingclient.api.InAppMessageResult
import com.android.billingclient.api.ProductDetailsResponseListener
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchaseHistoryRecord
Expand All @@ -37,13 +39,15 @@ import com.revenuecat.purchases.common.ReplaceProductInfo
import com.revenuecat.purchases.common.StoreProductsCallback
import com.revenuecat.purchases.common.between
import com.revenuecat.purchases.common.caching.DeviceCache
import com.revenuecat.purchases.common.debugLog
import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker
import com.revenuecat.purchases.common.errorLog
import com.revenuecat.purchases.common.firstProductId
import com.revenuecat.purchases.common.log
import com.revenuecat.purchases.common.sha1
import com.revenuecat.purchases.common.sha256
import com.revenuecat.purchases.common.toHumanReadableDescription
import com.revenuecat.purchases.common.verboseLog
import com.revenuecat.purchases.models.GoogleProrationMode
import com.revenuecat.purchases.models.GooglePurchasingData
import com.revenuecat.purchases.models.PurchaseState
Expand All @@ -56,7 +60,7 @@ import com.revenuecat.purchases.strings.RestoreStrings
import com.revenuecat.purchases.utils.Result
import java.io.PrintWriter
import java.io.StringWriter
import java.lang.IllegalStateException
import java.lang.ref.WeakReference
import java.util.Date
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.math.min
Expand Down Expand Up @@ -753,6 +757,38 @@ internal class BillingWrapper(

override fun isConnected(): Boolean = billingClient?.isReady ?: false

override fun showInAppMessagesIfNeeded(activity: Activity, subscriptionStatusChange: () -> Unit) {
val inAppMessageParams = InAppMessageParams.newBuilder()
.addInAppMessageCategoryToShow(InAppMessageParams.InAppMessageCategoryId.TRANSACTIONAL)
.build()
val weakActivity = WeakReference(activity)

executeRequestOnUIThread { error ->
if (error != null) {
errorLog(BillingStrings.BILLING_CONNECTION_ERROR_INAPP_MESSAGES.format(error))
return@executeRequestOnUIThread
}
withConnectedClient {
val activity = weakActivity.get() ?: run {
debugLog("Activity is null, not showing Google Play in-app message.")
return@withConnectedClient
}
showInAppMessages(activity, inAppMessageParams) { inAppMessageResult ->
when (val responseCode = inAppMessageResult.responseCode) {
InAppMessageResult.InAppMessageResponseCode.NO_ACTION_NEEDED -> {
verboseLog(BillingStrings.BILLING_INAPP_MESSAGE_NONE)
}
InAppMessageResult.InAppMessageResponseCode.SUBSCRIPTION_STATUS_UPDATED -> {
debugLog(BillingStrings.BILLING_INAPP_MESSAGE_UPDATE)
subscriptionStatusChange()
}
else -> errorLog(BillingStrings.BILLING_INAPP_MESSAGE_UNEXPECTED_CODE.format(responseCode))
}
}
}
}
}

private fun withConnectedClient(receivingFunction: BillingClient.() -> Unit) {
billingClient?.takeIf { it.isReady }?.let {
it.receivingFunction()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ internal object BillingStrings {
const val BILLING_CLIENT_RETRY = "Retrying BillingClient connection after backoff of %s milliseconds."
const val ILLEGAL_STATE_EXCEPTION_WHEN_CONNECTING = "There was an IllegalStateException when connecting to " +
"BillingClient. This has been reported to occur on Samsung devices on unknown circumstances.\nException: %s"
const val BILLING_CONNECTION_ERROR_INAPP_MESSAGES = "Error connecting to billing client to display " +
"in-app messages: %s"
const val BILLING_INAPP_MESSAGE_NONE = "No Google Play in-app message was available."
const val BILLING_INAPP_MESSAGE_UPDATE = "Subscription status was updated from in-app message."
const val BILLING_INAPP_MESSAGE_UNEXPECTED_CODE = "Unexpected billing code: %s"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.revenuecat.purchases.utils

import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle

@Suppress("EmptyFunctionBlock")
internal interface CustomActivityLifecycleHandler : ActivityLifecycleCallbacks {

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}

override fun onActivityStarted(activity: Activity) {}

override fun onActivityResumed(activity: Activity) {}

override fun onActivityPaused(activity: Activity) {}

override fun onActivityStopped(activity: Activity) {}

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}

override fun onActivityDestroyed(activity: Activity) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -332,10 +332,12 @@ internal open class BasePurchasesTest {
anonymous: Boolean,
autoSync: Boolean = true,
customEntitlementComputation: Boolean = false,
showDeclinedPaymentMessagesAutomatically: Boolean = false,
) {
val appConfig = AppConfig(
context = mockContext,
observerMode = false,
showDeclinedPaymentMessagesAutomatically = showDeclinedPaymentMessagesAutomatically,
platformInfo = PlatformInfo("native", "3.2.0"),
proxyURL = null,
store = Store.PLAY_STORE,
Expand Down
Loading

0 comments on commit e7bbdcd

Please sign in to comment.