Last Week

Last week didn’t go as planned, primarily due to falling quite ill shortly after friends left. This significantly impacted my ability to work. The main technical goal for the week was to implement the connection between the UI (specifically, the ViewModel) and the backend SDK for RevenueCat. While RevenueCat had already been integrated into the data layer of the application, bringing its functionality up to the ViewModel level proved challenging. I spent a significant amount of time trying to get it to work but was ultimately unsuccessful before the deadline for recording. Despite the setback, I believe I have identified the core issue but require more time to thoroughly investigate and resolve it.

What does it mean in English?

Think of building an app like building a house. Different parts of the house have different jobs – the foundation is the base (data layer), the walls and rooms are the structure (UI), and you need pipes and wires to connect things (ViewModel talking to SDK). Last week, the “pipes and wires” connecting the living space (UI) to the billing system (RevenueCat SDK) weren’t working. Even though the billing system was installed in the foundation, getting it to communicate correctly with the rooms upstairs was the challenge. I got sick, which made it hard to focus on fixing the plumbing, and even when I could work, it didn’t connect properly. I think I know why the connection is faulty, but I need more time to figure out exactly how to fix it.

Nerdy Details

The core issue involves integrating the RevenueCat SDK functionality, which resides in the shared Kotlin Multiplatform (KMP) data layer, into the shared ViewModel. This is a common task in KMP development but requires careful handling of asynchronous operations and state management across platforms.

Kotlin Multiplatform Architecture:

In a typical KMP application targeting Android and iOS (and potentially others), you structure your code into:

  1. Shared Module: Contains the business logic, data models, data sources (like network or database), and the ViewModel. This is where you aim to write as much code as possible.
  2. Platform Modules (e.g., androidApp, iosApp): Contain platform-specific code, the UI layer (e.g., Jetpack Compose on Android, SwiftUI on iOS), and platform-specific dependencies or configurations.

The Shared Module communicates with the platform-specific UI, often via patterns like MVI (Model-View-Intent) or MVVM (Model-View-ViewModel), using Kotlin’s coroutines for asynchronous operations and StateFlow or SharedFlow to expose state and events to the UI.

Integrating RevenueCat in KMP:

RevenueCat provides a KMP SDK. The standard approach is to initialize and configure the Purchases instance in the shared data layer or a dedicated PurchasesManager class within the shared module. This manager would handle the lower-level SDK calls.

Exposing RevenueCat data (like available products, subscription status, entitlements) and actions (like initiating a purchase) to the ViewModel requires bridging the SDK’s callbacks or suspend functions with the ViewModel’s state/event streams.

Let’s consider a simplified example of fetching offerings and initiating a purchase from a KMP ViewModel using coroutines and StateFlow, following modern KMP patterns.

Example: Simplified RevenueCat Integration

Assume you have a RevenueCatRepository (or PurchasesManager) in your shared data layer:

// shared/src/commonMain/kotlin/com/yourapp/data/RevenueCatRepository.kt
package com.yourapp.data

import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchaseResult
import com.revenuecat.purchases.awaitCustomerInfo
import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.errors.PurchasesException

class RevenueCatRepository {

    // Function to fetch offerings asynchronously
    suspend fun getOfferings(): Result<Offerings, PurchasesException> {
        return try {
            val offerings = Purchases.sharedInstance.awaitOfferings()
            Result.Success(offerings)
        } catch (e: PurchasesException) {
            Result.Error(e)
        }
    }

    // Function to fetch customer info asynchronously
    suspend fun getCustomerInfo(): Result<CustomerInfo, PurchasesException> {
        return try {
            val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
            Result.Success(customerInfo)
        } catch (e: PurchasesException) {
            Result.Error(e)
        }
    }

    // Function to initiate a purchase asynchronously
    // In a real app, you'd likely pass the Offering or Package
    suspend fun purchasePackage(packageId: String): Result<PurchaseResult, PurchasesException> {
        // Simplified: You'd need to get the actual Package object from Offerings first
        // This is a placeholder demonstrating the suspend function call
        val packageToPurchase = null // Get the package from Offerings
        if (packageToPurchase == null) {
             return Result.Error(PurchasesException("Package not found for ID: $packageId"))
        }
        return try {
            val purchaseResult = Purchases.sharedInstance.awaitPurchase(packageToPurchase)
            Result.Success(purchaseResult)
        } catch (e: PurchasesException) {
            Result.Error(e)
        }
    }
}

// Simple sealed class to wrap results
sealed class Result<out Success, out Error> {
    data class Success<out T>(val value: T) : Result<T, Nothing>()
    data class Error<out E>(val error: E) : Result<Nothing, E>()
}

Now, the ViewModel in the shared module uses this repository:

// shared/src/commonMain/kotlin/com/yourapp/presentation/SubscriptionViewModel.kt
package com.yourapp.presentation

import com.yourapp.data.RevenueCatRepository
import com.yourapp.data.Result
import dev.icerock.moko.mvvm.viewmodel.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

// Represent the state of the Subscription UI
data class SubscriptionUiState(
    val offerings: List<OfferingUi> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isSubscriber: Boolean = false
)

data class OfferingUi(
    val id: String,
    val serverDescription: String,
    val packages: List<PackageUi>
)

data class PackageUi(
    val id: String,
    val identifier: String,
    val localizedPrice: String // e.g., "$4.99"
    // Add other relevant package details
)


class SubscriptionViewModel(
    private val revenueCatRepository: RevenueCatRepository // Dependency Injection
) : ViewModel() {

    private val _uiState = MutableStateFlow(SubscriptionUiState(isLoading = true))
    val uiState: StateFlow<SubscriptionUiState> = _uiState.asStateFlow()

    init {
        fetchOfferings()
        checkSubscriptionStatus()
    }

    private fun fetchOfferings() {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
            when (val result = revenueCatRepository.getOfferings()) {
                is Result.Success -> {
                    // Map RevenueCat Offerings model to your UI state model
                    val offeringUis = result.value.current?.availablePackages?.map { packagee ->
                         OfferingUi(
                             id = result.value.current!!.identifier,
                             serverDescription = result.value.current!!.serverDescription,
                             packages = listOf(PackageUi(
                                 id = packagee.identifier,
                                 identifier = packagee.storeProduct.identifier,
                                 localizedPrice = packagee.storeProduct.price.formatted
                             )) // Simplify for example, real offerings have multiple packages
                         )
                    }?.toList() ?: emptyList()


                    _uiState.value = _uiState.value.copy(
                        offerings = offeringUis,
                        isLoading = false
                    )
                }
                is Result.Error -> {
                    _uiState.value = _uiState.value.copy(
                        errorMessage = "Failed to load subscriptions: ${result.error.message}",
                        isLoading = false
                    )
                    // Log the error for debugging
                    println("RevenueCat Error: ${result.error.message}")
                    result.error.printStackTrace()
                }
            }
        }
    }

    private fun checkSubscriptionStatus() {
         viewModelScope.launch {
             when (val result = revenueCatRepository.getCustomerInfo()) {
                 is Result.Success -> {
                     // Check entitlements to determine subscription status
                     val isSubscriber = result.value.entitlements.all.isNotEmpty() // Simplified check
                     _uiState.value = _uiState.value.copy(isSubscriber = isSubscriber)
                 }
                 is Result.Error -> {
                      // Handle error - maybe unable to check status, assume not subscriber or show error
                      println("RevenueCat Error checking status: ${result.error.message}")
                 }
             }
         }
    }


    // Function for UI to call to initiate a purchase
    fun purchaseSubscription(packageId: String) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) // Show loading indicator on purchase
            // In a real scenario, you'd find the actual Package object using the packageId
            // For this example, we'll call the repository function which needs modification
            println("Attempting to purchase package with ID: $packageId - Note: Repository purchase function needs actual Package object.")

            // --- Placeholder for calling repository purchase function ---
            // val result = revenueCatRepository.purchasePackage(packageId)
            // when (result) { ... handle success/error ... }
            // --- End Placeholder ---

             // Simulate success/failure for placeholder
             _uiState.value = _uiState.value.copy(
                 isLoading = false,
                 // Assume success updates status, failure sets error
                 errorMessage = "Purchase attempt finished (simulated). Check logs."
             )
             // After successful purchase, re-check subscription status
             checkSubscriptionStatus()
        }
    }

    // moko-mvvm viewModelScope provides a coroutine scope tied to the ViewModel lifecycle
    // For projects not using moko-mvvm, you'd manage a CoroutineScope manually or use a library like koin-core
}

Common Pitfalls:

  1. Threading/Coroutines: Not handling asynchronous calls correctly can lead to crashes or incorrect state updates. Ensure all SDK calls are made on appropriate background threads/dispatchers and results are processed correctly within coroutine scopes. awaitOfferings(), awaitCustomerInfo(), etc., from the RevenueCat KMP SDK are suspend functions, making integration with Kotlin coroutines straightforward.
  2. State Management: Properly exposing the ViewModel’s state using StateFlow or similar constructs is crucial for the UI to react to changes (like loaded offerings, purchase success/failure, subscription status).
  3. Platform Differences: While KMP aims for shared code, initial setup and certain platform-specific behaviors might still require platform-specific configuration or calls within the respective platform modules (e.g., configuring the Purchases instance with the API key on each platform’s entry point). Ensure the SDK is initialized correctly before the ViewModel attempts to use it.
  4. Error Handling: Robust error handling for network issues, purchase failures, etc., is critical and needs to be reflected in the UI state.

Based on the description, the problem could stem from correctly bridging the asynchronous RevenueCat SDK calls from the data layer/repository up to the ViewModel’s observable state, or potentially issues with the initial SDK configuration that manifest when trying to use the API from the shared module. Debugging involves checking coroutine execution, state updates, and detailed error logs from the RevenueCat SDK.

Next Week

The plan for next week is to dedicate more time to diving deep into the RevenueCat integration issue within the ViewModel. Having identified the potential problem areas, I will focus on debugging the interaction between the shared data layer containing the SDK calls and the shared ViewModel’s state management to ensure the subscription features function correctly across platforms.