Most developers, like myself, aim to earn money from our apps, and there’s a great sense of satisfaction in having that first paying customer. Recently, I considered adding subscription/in-app purchase features to my Compose+Kotlin MultiPlatform project, wanting a quick implementation without dealing with complexities of subscription.
As I thought about this, I came across RevenueCat’s new Paywall UI template feature on the internet (seems like Google’s technology is now advanced enough to show relevant content based on our thoughts xD). In case you’re not familiar, RevenueCat simplifies payment and subscription integration into mobile apps without needing backend development or complex implementations. The new Paywall feature makes it even easier by providing paywall templates you can use and update from the web without updating the app in the store.
Excited about this discovery, I thought, “Perfect! This is exactly what I’ve been looking for. It speeds things up for me without getting into UI and backend complexities related to subscriptions.” So, I decided to use it in FindTravelNow. Initially, I was a bit disappointed since it wasn’t Kotlin Multiplatform ready. But then, I realized it’s just a library with different Swift (Objective-C compatible) and Kotlin implementations. I could easily create a wrapper and use it in a Kotlin+Compose Multiplatform project.
Enough of the story; now, let’s dive into the technical aspect of integrating RevenueCat and Paywall UI in a Kotlin+Compose Multiplatform project.
First step— RevenueCat Library Installation
We add android and iOS dependencies of RevenueCat and RevenueCatUI in our shared module using Gradle.
cocoapods {
ios.deploymentTarget = "15.0"
framework {
baseName = "shared"
isStatic = true
}
pod("RevenueCat"){
extraOpts += listOf("-compiler-option", "-fmodules") //Extra opts is important
}
pod("RevenueCatUI"){
extraOpts += listOf("-compiler-option", "-fmodules") //Extra opts is important
}
}
sourceSets {
androidMain.dependencies {
implementation("com.revenuecat.purchases:purchases:7.5.2")
implementation("com.revenuecat.purchases:purchases-ui:7.5.2")
}
}
Make sure to include RevenueCat and RevenueCatUI from XCode as well; otherwise, errors may occur. For that I used Swift Package Manager in XCode. Select File » Add Packages... and enter the repository URL https://github.com/RevenueCat/purchases-ios.git. Select RevenueCat and RevenueCatUI from the list.
Second Step— Purchases Core class and Functions.
In this step, I’ll cover the main necessary classes and functions that you can copy and paste. Additionally, I’ll share a “magic blueprint” so you can follow the same pattern to implement other functions as needed. Our main goals are to cover the initialization of RevenueCat and display the Paywall view using the RevenueCatUI Paywall composable function — these are our core requirements.
The magic blueprint involves opening the RevenueCat Configuring SDK page in two tabs. In one tab, we focus on the Kotlin implementation, while in the other tab, we refer to the Objective-C implementation page. It’s as simple as that. When creating classes in the commonMain sourceSet, we maintain the same Kotlin implementation code style.
Purchases commonMain class/functions
public interface Purchases {
public var logLevel: LogLevel
public fun configure(apiKey: String)
public fun login(appUserId: String, onResult: (Result<LogInResult>) -> Unit)
public fun logOut(onResult: (Result<CustomerInfo>) -> Unit)
}
And we also need data classes for logging information and customer details. For this, we can create an identical version of Kotlin data classes that are available for the Android Kotlin part in commonMain. Then, in each source set, we can include Mapper functions to map RevenueCat Android and iOS data classes to our custom data class.
Purchases commonMain Data classes
public data class CustomerInfo(
val originalAppUserId: String,
val entitlements: EntitlementInfos
)
data class LogInResult(val customerInfo: CustomerInfo, val created: Boolean)
public data class EntitlementInfos(val all: Map<String, EntitlementInfo>)
public data class EntitlementInfo(
val identifier: String,
val isActive: Boolean,
val willRenew: Boolean,
val latestPurchaseDate: Long,
val originalPurchaseDate: Long,
val expirationDate: Long?,
val productIdentifier: String,
val productPlanIdentifier: String?,
val isSandbox: Boolean,
val unsubscribeDetectedAt: Long?,
val billingIssueDetectedAt: Long?,
)
Purchases implementation in Android (androidMain sourceSet)
import com.revenuecat.purchases.Purchases as RevenueCatPurchases
import com.revenuecat.purchases.interfaces.LogInCallback as RevenueCatLoginCallback
import com.revenuecat.purchases.CustomerInfo as RevenueCatCustomerInfo
internal class PurchasesImpl(private val context: Context) : Purchases {
override var logLevel: LogLevel
get() = RevenueCatPurchases.logLevel.asLogLevel()
set(value) {
RevenueCatPurchases.logLevel = value.asRevenueCatLogLevel()
}
override fun configure(apiKey: String) {
RevenueCatPurchases.configure(PurchasesConfiguration.Builder(context, apiKey).build())
}
override fun login(appUserId: String, onResult: (Result<LogInResult>) -> Unit) {
RevenueCatPurchases.sharedInstance.logIn(appUserId, object : RevenueCatLoginCallback {
override fun onError(error: PurchasesError) {
onResult(Result.failure(Exception(error.message)))
}
override fun onReceived(customerInfo: RevenueCatCustomerInfo, created: Boolean) {
onResult(Result.success(LogInResult(customerInfo.asCustomerInfo(), created)))
}
})
}
override fun logOut(onResult: (Result<CustomerInfo>) -> Unit) {
RevenueCatPurchases.sharedInstance.logOut(object : ReceiveCustomerInfoCallback {
override fun onError(error: PurchasesError) {
onResult(Result.failure(Exception(error.message)))
}
override fun onReceived(customerInfo: RevenueCatCustomerInfo) {
onResult(Result.success(customerInfo.asCustomerInfo()))
}
})
}
}
Purchases Mapper functions for Android
These mapper functions will map RevenueCat Android data classes to our custom data class that we created earlier.
import com.revenuecat.purchases.LogLevel as RevenueCatLogLevel
import com.revenuecat.purchases.CustomerInfo as RevenueCatCustomerInfo
import com.revenuecat.purchases.EntitlementInfos as RevenueCatEntitlementInfos
import com.revenuecat.purchases.EntitlementInfo as RevenueCatEntitlementInfo
internal fun LogLevel.asRevenueCatLogLevel(): RevenueCatLogLevel {
return when (this) {
LogLevel.VERBOSE -> RevenueCatLogLevel.VERBOSE
LogLevel.DEBUG -> RevenueCatLogLevel.DEBUG
LogLevel.INFO -> RevenueCatLogLevel.INFO
LogLevel.WARN -> RevenueCatLogLevel.WARN
LogLevel.ERROR -> RevenueCatLogLevel.ERROR
}
}
internal fun RevenueCatLogLevel.asLogLevel(): LogLevel {
return when (this) {
RevenueCatLogLevel.VERBOSE -> LogLevel.VERBOSE
RevenueCatLogLevel.DEBUG -> LogLevel.DEBUG
RevenueCatLogLevel.INFO -> LogLevel.INFO
RevenueCatLogLevel.WARN -> LogLevel.WARN
RevenueCatLogLevel.ERROR -> LogLevel.ERROR
}
}
internal fun RevenueCatEntitlementInfos.asEntitlementInfos(): EntitlementInfos {
return EntitlementInfos(all = this.all.mapValues { entry ->
entry.value.asEntitlementInfo()
})
}
internal fun RevenueCatEntitlementInfo.asEntitlementInfo(): EntitlementInfo {
return EntitlementInfo(
identifier = this.identifier,
isActive = this.isActive,
willRenew = this.willRenew,
latestPurchaseDate = this.latestPurchaseDate.time,
originalPurchaseDate = this.originalPurchaseDate.time,
expirationDate = this.expirationDate?.time,
productIdentifier = this.productIdentifier,
productPlanIdentifier = this.productPlanIdentifier,
isSandbox = this.isSandbox,
unsubscribeDetectedAt = this.unsubscribeDetectedAt?.time,
billingIssueDetectedAt = this.billingIssueDetectedAt?.time
)
}
internal fun RevenueCatCustomerInfo.asCustomerInfo(): CustomerInfo {
return CustomerInfo(
originalAppUserId = this.originalAppUserId,
entitlements = entitlements.asEntitlementInfos()
)
}
Purchases implementation in iOS (iosMain sourceSet)
@OptIn(ExperimentalForeignApi::class)
internal class PurchasesImpl : Purchases {
override var logLevel: LogLevel
get() = RCPurchases.logLevel().asLogLevel()
set(value) {
RCPurchases.setLogLevel(value.asRevenueCatLogLevel())
}
override fun configure(apiKey: String) {
RCPurchases.configureWithAPIKey(apiKey)
}
override fun login(appUserId: String, onResult: (Result<LogInResult>) -> Unit) {
RCPurchases.sharedPurchases()
.logIn(appUserId, completionHandler = { rcCustomerInfo, created, nsError ->
if (rcCustomerInfo != null) onResult(
Result.success(
LogInResult(
customerInfo = rcCustomerInfo.asCustomerInfo(),
created = created
)
)
)
else
onResult(Result.failure(Exception(nsError?.localizedFailureReason)))
})
}
override fun logOut(onResult: (Result<CustomerInfo>) -> Unit) {
RCPurchases.sharedPurchases().logOutWithCompletion { rcCustomerInfo, nsError ->
if (rcCustomerInfo != null) onResult(
Result.success(rcCustomerInfo.asCustomerInfo())
)
else
onResult(Result.failure(Exception(nsError?.localizedFailureReason)))
}
}
}
Purchases Mapper functions for iOS
These mapper functions will map RevenueCat iOS data classes to our custom data class that we created earlier.
@OptIn(ExperimentalForeignApi::class)
internal fun LogLevel.asRevenueCatLogLevel(): Long {
return when (this) {
LogLevel.VERBOSE -> RCLogLevelVerbose
LogLevel.DEBUG -> RCLogLevelDebug
LogLevel.INFO -> RCLogLevelInfo
LogLevel.WARN -> RCLogLevelWarn
LogLevel.ERROR -> RCLogLevelError
}
}
@OptIn(ExperimentalForeignApi::class)
internal fun Long.asLogLevel(): LogLevel {
return when (this) {
RCLogLevelVerbose -> LogLevel.VERBOSE
RCLogLevelDebug -> LogLevel.DEBUG
RCLogLevelInfo -> LogLevel.INFO
RCLogLevelWarn -> LogLevel.WARN
RCLogLevelError -> LogLevel.ERROR
else -> LogLevel.DEBUG
}
}
@OptIn(ExperimentalForeignApi::class)
internal fun RCEntitlementInfos.asEntitlementInfos(): EntitlementInfos {
val entitlementInfos: Map<String, EntitlementInfo> = this.all().filter { entry ->
entry.key is String && entry.value is RCEntitlementInfo
}.map { entry ->
val key = entry.key as String
val value = entry.value as RCEntitlementInfo
key to value.asEntitlementInfo()
}.toMap()
return EntitlementInfos(all = entitlementInfos)
}
@OptIn(ExperimentalForeignApi::class)
internal fun RCEntitlementInfo.asEntitlementInfo(): EntitlementInfo {
return EntitlementInfo(
identifier = this.identifier(),
isActive = this.isActive(),
willRenew = this.willRenew(),
latestPurchaseDate = this.latestPurchaseDate()?.timeIntervalSince1970?.toLong() ?: 0L,
originalPurchaseDate = this.originalPurchaseDate()?.timeIntervalSince1970?.toLong() ?: 0L,
expirationDate = this.expirationDate()?.timeIntervalSince1970?.toLong() ?: 0L,
productIdentifier = this.productIdentifier(),
productPlanIdentifier = this.productPlanIdentifier(),
isSandbox = this.isSandbox(),
unsubscribeDetectedAt = this.unsubscribeDetectedAt()?.timeIntervalSince1970?.toLong() ?: 0L,
billingIssueDetectedAt = this.billingIssueDetectedAt()?.timeIntervalSince1970?.toLong()
?: 0L
)
}
@OptIn(ExperimentalForeignApi::class)
public fun RCCustomerInfo.asCustomerInfo(): CustomerInfo {
return CustomerInfo(
originalAppUserId = originalAppUserId(),
entitlements = entitlements().asEntitlementInfos()
)
}
Finally, we bring all implementation classes together using either just ‘expect/actual’ or you can provide it with the Koin DI framework, as this is my personal preference. I’ve written more detailed blog post about the usage of Koin in Kotlin Multiplatform that you can check out: https://medium.com/proandroiddev/achieving-platform-specific-implementations-with-koin-in-kmm-5cb029ba4f3b.
Third Step — Purchases UI — Paywall Composable
Now, we’ll move on to covering the UI part, which we can utilize as a Composable function within our composable.
Purchases UI in commonMain — Paywall composable
@Composable
public expect fun Paywall(
shouldDisplayDismissButton: Boolean = true,
onDismiss: () -> Unit,
listener: PaywallListener?
)
public interface PaywallListener {
public fun onPurchaseStarted() {}
public fun onPurchaseCompleted(customerInfo: CustomerInfo?) {}
public fun onPurchaseError(error: String?) {}
public fun onPurchaseCancelled() {}
public fun onRestoreStarted() {}
public fun onRestoreCompleted(customerInfo: CustomerInfo?) {}
public fun onRestoreError(error: String?) {}
}
Paywall composable implementation in Android (androidMain sourceSet)
import com.revenuecat.purchases.ui.revenuecatui.Paywall as RevenueCatPaywall
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
@Composable
public actual fun Paywall(
shouldDisplayDismissButton: Boolean,
onDismiss: () -> Unit,
listener: PaywallListener?
) {
RevenueCatPaywall(
options = PaywallOptions.Builder(
dismissRequest = onDismiss
)
.setListener(listener?.asRevenueCatPaywallListener())
.setShouldDisplayDismissButton(shouldDisplayDismissButton)
.build()
)
}
PaywallListener Mapper for Android (androidMain)
import com.revenuecat.purchases.ui.revenuecatui.PaywallListener as RevenueCatPaywallListener
@OptIn(ExperimentalPreviewRevenueCatUIPurchasesAPI::class)
internal fun PaywallListener.asRevenueCatPaywallListener(): RevenueCatPaywallListener {
return object:RevenueCatPaywallListener{
override fun onPurchaseCancelled() {
this@asRevenueCatPaywallListener.onPurchaseCancelled()
}
override fun onPurchaseCompleted(
customerInfo: CustomerInfo,
storeTransaction: StoreTransaction
) {
this@asRevenueCatPaywallListener.onPurchaseCompleted(customerInfo.asCustomerInfo())
}
override fun onPurchaseError(error: PurchasesError) {
this@asRevenueCatPaywallListener.onPurchaseError(error.message)
}
override fun onPurchaseStarted(rcPackage: Package) {
this@asRevenueCatPaywallListener.onPurchaseStarted()
}
override fun onRestoreCompleted(customerInfo: CustomerInfo) {
this@asRevenueCatPaywallListener.onRestoreCompleted(customerInfo.asCustomerInfo())
}
override fun onRestoreError(error: PurchasesError) {
this@asRevenueCatPaywallListener.onRestoreError(error.message)
}
override fun onRestoreStarted() {
this@asRevenueCatPaywallListener.onRestoreStarted()
}
}
}
Paywall composable implementation in iOS (iosMain sourceSet)
@OptIn(ExperimentalForeignApi::class)
@Composable
public actual fun Paywall(
shouldDisplayDismissButton: Boolean,
onDismiss: () -> Unit, listener: PaywallListener?
) {
val rootViewController = UIApplication.sharedApplication.keyWindow?.rootViewController
val controller = RCPaywallViewController(null, shouldDisplayDismissButton)
controller.setDelegate(listener?.asRCPaywallViewControllerDelegate(onDismiss))
if (controller.isBeingPresented().not())
rootViewController?.presentViewController(controller, true, completion = {
if (controller.isBeingPresented().not()) onDismiss()
})
}
PaywallListener Mapper for iOS (iosMain)
@OptIn(ExperimentalForeignApi::class)
@Suppress("CONFLICTING_OVERLOADS")
internal fun PaywallListener.asRCPaywallViewControllerDelegate(onDismissed: () -> Unit): RCPaywallViewControllerDelegateProtocol {
return object : RCPaywallViewControllerDelegateProtocol, NSObject() {
override fun paywallViewController(
controller: RCPaywallViewController,
didFailPurchasingWithError: NSError
) {
this@asRCPaywallViewControllerDelegate.onPurchaseError(didFailPurchasingWithError.localizedFailureReason)
}
override fun paywallViewController(
controller: RCPaywallViewController,
didFailRestoringWithError: NSError
) {
this@asRCPaywallViewControllerDelegate.onRestoreError(didFailRestoringWithError.localizedFailureReason)
}
override fun paywallViewController(
controller: RCPaywallViewController,
didFinishRestoringWithCustomerInfo: RCCustomerInfo
) {
/*
TODO for some reason here can't get any value of didFinishRestoringWithCustomerInfo,
so just pass null if it is case, if not just map it.
*/
this@asRCPaywallViewControllerDelegate.onRestoreCompleted(
null
// didFinishRestoringWithCustomerInfo.asCustomerInfo()
)
}
override fun paywallViewController(
controller: RCPaywallViewController,
didFinishPurchasingWithCustomerInfo: RCCustomerInfo
) {
this@asRCPaywallViewControllerDelegate.onPurchaseCompleted(null)
}
override fun paywallViewController(
controller: RCPaywallViewController,
didFinishPurchasingWithCustomerInfo: RCCustomerInfo,
transaction: RCStoreTransaction?
) {
this@asRCPaywallViewControllerDelegate.onPurchaseCompleted(null)
}
override fun paywallViewControllerDidCancelPurchase(controller: RCPaywallViewController) {
this@asRCPaywallViewControllerDelegate.onPurchaseCancelled()
}
override fun paywallViewControllerDidStartPurchase(controller: RCPaywallViewController) {
this@asRCPaywallViewControllerDelegate.onPurchaseStarted()
}
override fun paywallViewControllerWasDismissed(controller: RCPaywallViewController) {
onDismissed()
}
}
}
Thanks for reading until the end! I wish you a lot of success in earning money from your in-app purchases or subscriptions :D. For those who made it this far, I want to let you in on something special — I’ve introduced a Lifetime Premium Subscription in FindTravelNow, available for a limited time at just $5 (think of it as a buy-me-coffee thing :D). This lifetime subscription is meant for demo and test purposes, and I plan to keep it available for one month. However, if you act quickly, you can secure a lifetime premium subscription at this exceptionally low price. Don’t miss out on this opportunity!
Additionally, there’s one more thing — I’ve also developed the KMPRevenueCat wrapper library . With this, you can directly use the functionalities above without having to implement all of these steps. It’s designed to simplify the integration process.
[Read on Medium]