Hey devs!
I recently shared my experience building an app with Kotlin Multiplatform (KMP), focusing on the parts that still require native code. You can check out the original post here: link.
A big part of the discussion was around Billing, which, despite KMP’s capabilities, still requires native implementations. Here’s how I tackled payment processing in a KMP project with native support.
🛠️ Architecture Breakdown
I set up a BillingDelegate interface in the shared KMP module:
interface BillingDelegate {
fun requestPricingUpdate()
fun subscribe(period: Subscription.Period)
fun isBillingSupported(): Boolean
fun openPromoRedeem()
interface StateRepository {
fun update(pricingResult: List<Subscription>)
fun getStream(): Flow<BillingState>
fun onPurchaseEvent(state: PurchaseState)
fun onError()
}
}
Sealed Classes for State Management:
sealed class BillingState {
object Loading : BillingState()
object Error : BillingState()
object PurchaseCompleted : BillingState()
object PurchaseCanceled : BillingState()
data class Data(
val subscriptions: List<Subscription>,
val offers: Map<Subscription.Period, Offer>
) : BillingState()
}
sealed interface PurchaseState {
object Canceled : PurchaseState
data class Completed(val transactionId: String? = null) : PurchaseState
}
Android-Specific Implementation:
@Single
class GooglePlayBillingDelegate(
private val activityProvider: ActivityProvider,
private val context: Context,
private val billingStateRepository: BillingDelegate.StateRepository,
private val pricingMapper: PricingMapper,
private val periodMapper: PeriodMapper,
private val userRepository: UserRepository,
private val analytics: AnalyticsManager,
) : BillingDelegate {
private val purchasesUpdatedListener =
PurchasesUpdatedListener { billingResult, purchases ->
analytics.log("$billingResult", LogLevel.DEBUG)
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
billingStateRepository.onPurchaseEvent(PurchaseState.Completed())
} else {
billingStateRepository.onPurchaseEvent(PurchaseState.Canceled)
}
}
...
}
When I need to fetch product details, I request them through the BillingDelegate, which forwards the call to the native Android implementation:
override fun requestPricingUpdate() {
bgScope.launch {
ensureConnection()
val result = billingClient.getProductDetails(...)
with(result.productDetailsList?.firstOrNull()) {
productDetails = this
val subscriptions = pricingMapper.map(this)
billingStateRepository.update(subscriptions)
}
}
}
You could notice I also have a method for promo code redemption in interface. This is currently just a placeholder on Android, but it’s necessary on iOS because Apple has a dedicated flow for redeeming promo codes.
Shared State Repository (KMP):
The magic happens in the shared module with the StateRepository, which is 100% common code:
@Single
class BillingDelegateInMemoryStateRepository(
private val userRepository: UserRepository,
) : BillingDelegate.StateRepository {
private val billingState = MutableStateFlow<BillingState>(BillingState.Loading)
...
}
Flow of Operations Recap: