Skip to content

Commit

Permalink
Paywalls can use custom in-app purchase/restore code (#1777)
Browse files Browse the repository at this point in the history
This allows someone who's adopting RevenueCat paywalls to use their own
app purchase/restore logic rather than RevenueCat's.

This is done by implementing `MyAppPurchaseLogic`, which has two
functions that implement the custom purchase and restore functions.

```kotlin
interface MyAppPurchaseLogic {

    suspend fun performPurchase(activity: Activity, rcPackage: Package): MyAppPurchaseResult

    suspend fun performRestore(customerInfo: CustomerInfo): MyAppRestoreResult

}
```
Users can also use completion handlers by implementing this abstract
class instead:

```kotlin
abstract class MyAppPurchaseLogicCompletion : MyAppPurchaseLogic {

    abstract fun performPurchaseWithCompletion(activity: Activity, rcPackage: Package, completion: (MyAppPurchaseResult) -> Unit)

    abstract fun performRestoreWithCompletion(completion: (MyAppRestoreResult) -> Unit)

    final override suspend fun performPurchase(activity: Activity, rcPackage: Package): MyAppPurchaseResult =
        suspendCoroutine { continuation ->
            performPurchaseWithCompletion(activity, rcPackage) { result ->
                continuation.resume(result)
            }
        }

    final override suspend fun performRestore(customerInfo: CustomerInfo): MyAppRestoreResult =
        suspendCoroutine { continuation ->
            performRestoreWithCompletion { result ->
                continuation.resume(result)
            }
        }
}
```

An nullable instance of this interface is now part of `PaywallOptions`
and `PaywallDialogOptions`, and settable via the builders. These classes
are used when creating a Paywall.

Builder function:
```kotlin
fun setMyAppPurchaseLogic(myAppPurchaseLogic: MyAppPurchaseLogic?) = apply {
    this.myAppPurchaseLogic = myAppPurchaseLogic
}
```
When a `MyAppPurchaseLogic` is provided, the paywall viewmodel will use
this logic instead of RevenueCat's _if_ `Purchases` is configured such
that `PurchasesAreCompletedBy.MY_APP`. This happens in
`PaywallViewModelImpl`, methods `awaitRestorePurchases` and
`awaitRestorePurchases`.

If a `PaywallViewModel` is constructed _without_ being provided with an
instance of MyAppPurchaseLogic when PurchasesAreCompletedBy.MY_APP, it
will throw:

```kotlin
// called by constructor
private fun validateState() {
    if (purchases.purchasesAreCompletedBy == PurchasesAreCompletedBy.MY_APP && options.myAppPurchaseLogic == null) {
        error(
            "myAppPurchaseLogic is null, but is required when purchases.purchasesAreCompletedBy is .MY_APP."
        )
    }
}
```


PaywallViewModel` refactored a bit to make awaitable versions of
purchase and restore functions. This has made the PaywallViewModel.kt
diff a bit messy, but the change here was breaking it up so that the
awaitable versions wouldn't create a co-routine context, and the code
that checks what `purchasesAreCompletedBy` is, and executing either the
customer code or (as previous) the RC code to do the purchase/restore.

To make the paywalls tester app run the custom code blocks, make these
temporary additional changes:

<img width="646" alt="image"
src="https://github.com/user-attachments/assets/c1570723-fafe-41f4-86d2-abf6411016cd">

---------

Co-authored-by: JayShortway <29483617+JayShortway@users.noreply.github.com>
  • Loading branch information
jamesrb1 and JayShortway authored Aug 23, 2024
1 parent cabcff0 commit 4c89ab6
Show file tree
Hide file tree
Showing 12 changed files with 823 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,28 @@ package com.revenuecat.apitester.kotlin.revenuecatui
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.ui.revenuecatui.PaywallListener
import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions
import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic
import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider

@Suppress("unused", "UNUSED_VARIABLE")
private class PaywallOptionsAPI {
fun check(offering: Offering?, listener: PaywallListener?, fontProvider: FontProvider?) {

suspend fun check(
offering: Offering?,
listener: PaywallListener?,
fontProvider: FontProvider?,
purchaseLogic: PurchaseLogic?,
) {
val options: PaywallOptions = PaywallOptions.Builder(dismissRequest = {})
.setOffering(offering)
.setListener(listener)
.setShouldDisplayDismissButton(true)
.setFontProvider(fontProvider)
.setPurchaseLogic(purchaseLogic)
.build()
val listener2: PaywallListener? = options.listener
val fontProvider2: FontProvider? = options.fontProvider
val dismissRequest: () -> Unit = options.dismissRequest
val purchaseLogic2: PurchaseLogic? = options.purchaseLogic
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.revenuecat.apitester.kotlin.revenuecatui

import android.app.Activity
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic
import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult
import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicWithCallback

@Suppress("unused", "UNUSED_VARIABLE")
private class PurchaseLogicAPI {

suspend fun check(
mySuspendLogic: PurchaseLogic,
activity: Activity,
rcPackage: Package,
customerInfo: CustomerInfo,
) {
val suspendLogicPurchase: PurchaseLogicResult = mySuspendLogic.performPurchase(activity, rcPackage)
val suspendLogicRestore: PurchaseLogicResult = mySuspendLogic.performRestore(customerInfo)
}
}

@Suppress("unused")
private class PurchaseLogicWithCallbackAPI : PurchaseLogicWithCallback() {

override fun performPurchaseWithCompletion(
activity: Activity,
rcPackage: Package,
completion: (PurchaseLogicResult) -> Unit,
) {
val success = PurchaseLogicResult.Success
val cancelled = PurchaseLogicResult.Cancellation
val failed = PurchaseLogicResult.Error(PurchasesError(PurchasesErrorCode.StoreProblemError))
completion(success)
}

override fun performRestoreWithCompletion(customerInfo: CustomerInfo, completion: (PurchaseLogicResult) -> Unit) {
val success = PurchaseLogicResult.Success
val cancelled = PurchaseLogicResult.Cancellation
val failed = PurchaseLogicResult.Error(PurchasesError(PurchasesErrorCode.StoreProblemError))
completion(failed)
}

@Suppress("unused")
fun check(
activity: Activity,
rcPackage: Package,
customerInfo: CustomerInfo,
) {
performPurchaseWithCompletion(
activity,
rcPackage,
) { result: PurchaseLogicResult -> }

performRestoreWithCompletion(
customerInfo,
) { result: PurchaseLogicResult -> }
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.revenuecat.paywallstester.ui.screens.main.paywalls

import android.app.Activity
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -28,20 +30,89 @@ import com.revenuecat.paywallstester.SamplePaywalls
import com.revenuecat.paywallstester.SamplePaywallsLoader
import com.revenuecat.paywallstester.ui.screens.paywallfooter.SamplePaywall
import com.revenuecat.paywallstester.ui.theme.bundledLobsterTwoFontFamily
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offering
import com.revenuecat.purchases.Package
import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialog
import com.revenuecat.purchases.ui.revenuecatui.PaywallDialogOptions
import com.revenuecat.purchases.ui.revenuecatui.PaywallFooter
import com.revenuecat.purchases.ui.revenuecatui.PaywallOptions
import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogic
import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicResult
import com.revenuecat.purchases.ui.revenuecatui.PurchaseLogicWithCallback
import com.revenuecat.purchases.ui.revenuecatui.fonts.CustomFontProvider
import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider

private class TestAppPurchaseLogicSuspend : PurchaseLogic {

companion object { private const val TAG = "PaywallTester" }

override suspend fun performPurchase(
activity: Activity,
rcPackage: com.revenuecat.purchases.Package,
): PurchaseLogicResult {
// Implement your app's custom purchase logic here.
// If you are using Google Play, RevenueCat will automatically call `purchases.syncPurchases()` if
// you return `.Success`. If you are using Amazon, you must call `purchases.syncAmazonPurchase()`.
Log.d(TAG, "Custom purchase code in performPurchase was called.")
return PurchaseLogicResult.Success
}

override suspend fun performRestore(customerInfo: CustomerInfo): PurchaseLogicResult {
// Implement your app's custom restore logic here.
// If you are using Google Play, RevenueCat will automatically call `purchases.syncPurchases()` if
// you return `.Success`. If you are using Amazon, you must call `purchases.syncAmazonPurchase()`.
Log.d(TAG, "Custom restore code in performRestore was called.")
return PurchaseLogicResult.Error(PurchasesError(PurchasesErrorCode.PurchaseCancelledError))
}
}

private class TestAppPurchaseLogicCallbacks : PurchaseLogicWithCallback() {

companion object { private const val TAG = "PaywallTester" }

override fun performPurchaseWithCompletion(
activity: Activity,
rcPackage: Package,
completion: (PurchaseLogicResult) -> Unit,
) {
// Implement your app's custom purchase logic here.
// If you are using Google Play, RevenueCat will automatically call `purchases.syncPurchases()` if
// you return `.Success`. If you are using Amazon, you must call `purchases.syncAmazonPurchase()`.
Log.d(TAG, "Custom purchase code in performPurchaseWithCompletion was called.")
completion(PurchaseLogicResult.Success)
}

override fun performRestoreWithCompletion(customerInfo: CustomerInfo, completion: (PurchaseLogicResult) -> Unit) {
// Implement your app's custom restore logic here.
// If you are using Google Play, RevenueCat will automatically call `purchases.syncPurchases()` if
// you return `.Success`. If you are using Amazon, you must call `purchases.syncAmazonPurchase()`.
Log.d(TAG, "Custom restore code in performRestoreWithCompletion was called.")
completion(PurchaseLogicResult.Success)
}
}

@Suppress("LongMethod")
@Composable
fun PaywallsScreen(
samplePaywallsLoader: SamplePaywallsLoader = SamplePaywallsLoader(),
) {
var displayPaywallState by remember { mutableStateOf<DisplayPaywallState>(DisplayPaywallState.None) }

// This should be part of a view model so it survives activity recreations.
// Temporarily holding this here for this test app.
val useCallbackPurchaseLogic = true

val myAppPurchaseLogic = remember {
if (useCallbackPurchaseLogic) {
TestAppPurchaseLogicCallbacks()
} else {
TestAppPurchaseLogicSuspend()
}
}

LazyColumn {
items(SamplePaywalls.SampleTemplate.values()) { template ->
val offering = samplePaywallsLoader.offeringForTemplate(template)
Expand All @@ -53,21 +124,32 @@ fun PaywallsScreen(
)
ButtonWithEmoji(
onClick = {
displayPaywallState = DisplayPaywallState.FullScreen(offering)
displayPaywallState = DisplayPaywallState.FullScreen(
offering,
purchaseLogic = myAppPurchaseLogic,
)
},
emoji = "\uD83D\uDCF1",
label = "Full screen",
)
ButtonWithEmoji(
onClick = {
displayPaywallState = DisplayPaywallState.Footer(offering, condensed = false)
displayPaywallState = DisplayPaywallState.Footer(
offering,
condensed = false,
purchaseLogic = myAppPurchaseLogic,
)
},
emoji = "\uD83D\uDD3D",
label = "Footer",
)
ButtonWithEmoji(
onClick = {
displayPaywallState = DisplayPaywallState.Footer(offering, condensed = true)
displayPaywallState = DisplayPaywallState.Footer(
offering,
condensed = true,
purchaseLogic = myAppPurchaseLogic,
)
},
emoji = "\uD83D\uDDDC",
label = "Condenser footer",
Expand All @@ -77,6 +159,7 @@ fun PaywallsScreen(
displayPaywallState = DisplayPaywallState.FullScreen(
offering,
CustomFontProvider(bundledLobsterTwoFontFamily),
purchaseLogic = myAppPurchaseLogic,
)
},
emoji = "\uD83C\uDD70",
Expand Down Expand Up @@ -104,6 +187,7 @@ private fun FullScreenDialog(currentState: DisplayPaywallState.FullScreen, onDis
.setDismissRequest(onDismiss)
.setOffering(currentState.offering)
.setFontProvider(currentState.fontProvider)
.setCustomPurchaseLogic(currentState.purchaseLogic)
.build(),
)
}
Expand All @@ -119,6 +203,7 @@ private fun FooterDialog(currentState: DisplayPaywallState.Footer, onDismiss: ()
PaywallFooter(
options = PaywallOptions.Builder(dismissRequest = onDismiss)
.setOffering(currentState.offering)
.setPurchaseLogic(currentState.purchaseLogic)
.build(),
condensed = currentState.condensed,
) { footerPadding ->
Expand All @@ -135,10 +220,12 @@ private sealed class DisplayPaywallState {
constructor(
val offering: Offering? = null,
val fontProvider: FontProvider? = null,
var purchaseLogic: PurchaseLogic? = null,
) : DisplayPaywallState()
data class Footer(
val offering: Offering? = null,
val condensed: Boolean = false,
var purchaseLogic: PurchaseLogic? = null,
) : DisplayPaywallState()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ data class PaywallDialogOptions internal constructor(
val shouldDisplayDismissButton: Boolean,
val fontProvider: FontProvider?,
val listener: PaywallListener?,
val purchaseLogic: PurchaseLogic?,
) {

constructor(builder: Builder) : this(
Expand All @@ -21,6 +22,7 @@ data class PaywallDialogOptions internal constructor(
shouldDisplayDismissButton = builder.shouldDisplayDismissButton,
fontProvider = builder.fontProvider,
listener = builder.listener,
purchaseLogic = builder.purchaseLogic,
)

internal fun toPaywallOptions(dismissRequest: () -> Unit): PaywallOptions {
Expand All @@ -32,6 +34,7 @@ data class PaywallDialogOptions internal constructor(
.setShouldDisplayDismissButton(shouldDisplayDismissButton)
.setFontProvider(fontProvider)
.setListener(listener)
.setPurchaseLogic(purchaseLogic)
.build()
}

Expand All @@ -42,6 +45,7 @@ data class PaywallDialogOptions internal constructor(
internal var shouldDisplayDismissButton: Boolean = true
internal var fontProvider: FontProvider? = null
internal var listener: PaywallListener? = null
internal var purchaseLogic: PurchaseLogic? = null

/**
* Allows to configure whether to display the paywall dialog depending on operations on the CustomerInfo
Expand Down Expand Up @@ -82,6 +86,10 @@ data class PaywallDialogOptions internal constructor(
this.listener = listener
}

fun setCustomPurchaseLogic(purchaseLogic: PurchaseLogic?) = apply {
this.purchaseLogic = purchaseLogic
}

fun build(): PaywallDialogOptions {
return PaywallDialogOptions(this)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ data class PaywallOptions internal constructor(
internal val shouldDisplayDismissButton: Boolean,
val fontProvider: FontProvider?,
val listener: PaywallListener?,
val purchaseLogic: PurchaseLogic?,
internal val mode: PaywallMode,
val dismissRequest: () -> Unit,
) {
Expand All @@ -37,6 +38,7 @@ data class PaywallOptions internal constructor(
shouldDisplayDismissButton = builder.shouldDisplayDismissButton,
fontProvider = builder.fontProvider,
listener = builder.listener,
purchaseLogic = builder.purchaseLogic,
mode = builder.mode,
dismissRequest = builder.dismissRequest,
)
Expand All @@ -48,6 +50,7 @@ data class PaywallOptions internal constructor(
internal var shouldDisplayDismissButton: Boolean = false
internal var fontProvider: FontProvider? = null
internal var listener: PaywallListener? = null
internal var purchaseLogic: PurchaseLogic? = null
internal var mode: PaywallMode = PaywallMode.default

fun setOffering(offering: Offering?) = apply {
Expand Down Expand Up @@ -76,6 +79,10 @@ data class PaywallOptions internal constructor(
this.listener = listener
}

fun setPurchaseLogic(purchaseLogic: PurchaseLogic?) = apply {
this.purchaseLogic = purchaseLogic
}

internal fun setMode(mode: PaywallMode) = apply {
this.mode = mode
}
Expand Down
Loading

0 comments on commit 4c89ab6

Please sign in to comment.