Collecting Reviews in a Compose Multiplatform App

26.09.2025

Shipping a great app is only half the battle, earning high quality reviews is just as crucial for App Store Optimization (ASO). Ratings and reviews shape first impressions, drive conversion, and influence store ranking. For a product like Kittysplit, where trust and simplicity are everything, timely and authentic user feedback helps future users decide to install, and helps us steer the roadmap.

But asking for reviews is a delicate craft. Prompt too early and you interrupt the flow, prompt too often and you annoy users, prompt at the wrong moment and you get low‑quality or frustrated feedback. In this post, we’ll share how we integrated Google Play’s ReviewManager and Apple’s SKStoreReviewController into our Compose Multiplatform app, and how we built a cross‑platform strategy to decide when to ask. We’ll cover the technical glue, the platform nuances, and the heuristics we use to surface the prompt at moments when users are most likely to feel satisfied, not interrupted.

Implementation

We’ll start with the implementation. We define a shared ReviewService API in common code and provide platform-specific implementations that wrap Android’s ReviewManager and iOS’s SKStoreReviewController. Using Kotlin Mutliplatform’s expect/actual mechanism in combination with Koin, we’re providing the correct implementation for each platform. This keeps the call site in our Compose Multiplatform UI identical across platforms, while letting each target handle its own prompt presentation in a native, compliant way.

We’re using a freshly created Compose Multiplatform project as the base for this post. You can find a little more detailed description of how to create a new CMP project in our previous blog post here. You can find all the code on GitHub.

At first, we need to add a few dependencies to our libs.versions.toml. If you don’t know what that is, you can read up on it here. It’s a relatively new way of managing dependencies in gradle.

[versions]
# ... other verions

android-play-review = "2.0.2"
koin = "4.1.1"
kotlinx-coroutines = "1.10.2"

[libraries]
# ... other libraries

android-play-review = { module = "com.google.android.play:review", version.ref = "android-play-review" }
android-play-review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "android-play-review" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" }

./gradle/libs.versions.toml

Next, we’re going to declare these dependencies in our compose app’s build.gradle.kts:

kotlin {
    // ... other settings

    sourceSets {
        androidMain.dependencies {
            // ... other android dependencies

            implementation(libs.android.play.review)
            implementation(libs.android.play.review.ktx)
            implementation(libs.kotlinx.coroutines.play.services)
        }
        commonMain.dependencies {
            // ... other common dependencies

            implementation(libs.koin.core)
            implementation(libs.koin.compose)
            implementation(libs.kotlinx.coroutines.core)
        }
    }
}

./composeApp/build.gradle.kts

Now we need to define an interface in commonMain that we’ll implement later in androidMain and iosMain respectively:

interface ActivityContainer

data object EmptyActivityContainer : ActivityContainer

sealed class ReviewResult() {
    data object Requested : ReviewResult()
    data object Failed : ReviewResult()
    data class RequestError(val code: Int) : ReviewResult()
}

interface ReviewService {
    suspend fun requestInAppReview(
        activityContainer: ActivityContainer = EmptyActivityContainer
    ): ReviewResult
}

./composeApp/src/commonMain/kotlin/com/kittysplit/reviewservicecmp/ReviewService.kt

Because Apple’s SKStoreReviewController and Google Play’s ReviewManager don’t expose whether a dialog was actually shown or acted upon, our API only reports Requested or an error. This keeps our contract honest and portable across both stores’ opaque review flows. Don’t mind the ActivityContainer interface and EmptyActivityContainer object, we’re going to explain that soon.

Android

Let’s look at the implementation for Android:

import android.app.Activity
import android.content.Context
import com.google.android.play.core.review.ReviewException
import com.google.android.play.core.review.ReviewManagerFactory
import kotlinx.coroutines.tasks.await

data class AndroidContextHolder(val activity: Activity) : ActivityContainer

fun ActivityContainer.activity(): Activity? = (this as? AndroidContextHolder)?.activity

class AndroidReviewService(val context: Context) : ReviewService {
    private val reviewManager = ReviewManagerFactory.create(context)

    override suspend fun requestInAppReview(activityContainer: ActivityContainer): ReviewResult = try {
        val activity = activityContainer.activity()
        if (activity != null) {
            val reviewInfo = reviewManager.requestReviewFlow().await()
            reviewManager.launchReviewFlow(activity, reviewInfo).await()
            ReviewResult.Requested
        } else {
            ReviewResult.Failed
        }
    } catch (error: Exception) {
        if (error is ReviewException) {
            ReviewResult.RequestError(error.errorCode)
        } else {
            ReviewResult.Failed
        }
    }
}

./composeApp/src/androidMain/kotlin/com/kittysplit/reviewservicecmp/AndroidReviewService.kt

We implemented the Android side of ReviewService using Play Core’s in‑app review flow. AndroidReviewService creates a ReviewManager, requests a ReviewInfo using requestReviewFlow().await(), and then launches the prompt with reviewManager.launchReviewFlow(...).await(). Because launchReviewFlow requires an Activity, not a generic Context, we created a small container object to be able to hand over the Activity in the Android implementation. For iOS, we’re going to use the EmptyActivityContainer, since we don’t need to pass anything there.

If something goes wrong on Android, the ReviewManager might throw a ReviewException. In case we encounter it, we’re extracting the errorCode and return it as ReviewResult.RequestError. This allows you to handle these errors separately from the generic ReviewResult.Failed. For possible values of the errorCode see the documentation here.

iOS

Next, we’re going to implement the AppleReviewService in iosMain:

import platform.StoreKit.SKStoreReviewController
import platform.UIKit.UIApplication
import platform.UIKit.UISceneActivationStateForegroundActive
import platform.UIKit.UIWindowScene

class AppleReviewService : ReviewService {
    override suspend fun requestInAppReview(activityContainer: ActivityContainer): ReviewResult = try {
        val scene = UIApplication.sharedApplication.connectedScenes
            .filterIsInstance<UIWindowScene>()
            .firstOrNull { it.activationState == UISceneActivationStateForegroundActive }

        if (scene != null) {
            SKStoreReviewController.requestReviewInScene(scene)
        } else {
            SKStoreReviewController.requestReview()
        }
        ReviewResult.Requested
    } catch (_: Exception) {
        ReviewResult.Failed
    }
}

./composeApp/src/iosMain/kotlin/com/kittysplit/reviewservicecmp/AppleReviewService.kt

We implemented the iOS side with StoreKit’s in‑app review API. AppleReviewService looks up the currently active UIWindowScene by scanning UIApplication.sharedApplication.connectedScenes and picking the one in UISceneActivationStateForegroundActive state. If an active scene exists, we call SKStoreReviewController.requestReviewInScene(scene). Otherwise we fall back to SKStoreReviewController.requestReview(). Both calls are “fire‑and‑forget” (no success callback), so we return ReviewResult.Requested after invoking them. Any exception during lookup or invocation maps to ReviewResult.Failed. This approach ensures the prompt is tied to the foreground scene in multi‑window setups while remaining compatible with older, scene‑agnostic code paths.

Koin

That’s it. All we need to do now is wire it all together to be able to use it in our common app code. We’re going to use Koin for that, but you can go with any other DI framework.

import org.koin.core.module.Module

expect fun reviewModule(): Module

./composeApp/src/commonMain/kotlin/com/kittysplit/reviewservicecmp/ReviewModule.kt

import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module

actual fun reviewModule(): Module = module {
    singleOf(::AndroidReviewService)
}

./composeApp/src/androidMain/kotlin/com/kittysplit/reviewservicecmp/ReviewModule.android.kt

import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module

actual fun reviewModule(): Module = module {
    singleOf(::AppleReviewService)
}

./composeApp/src/iosMain/kotlin/com/kittysplit/reviewservicecmp/ReviewModule.ios.kt

We’re not going in detail here, but it should be straight forward. We’re defining a reviewModule() as an expect function and provide the platform-specific implementations in the actual counterparts.

With both implementations in place, you can inject ReviewService into any ViewModel and trigger review requests at the right moments in your user flows. Pick thoughtful triggers, wire them to requestInAppReview, and you’re ready to ask for reviews without disrupting your users.

Review triggers

We gate review prompts behind lightweight heuristics so they appear at moments of success, not interruption. We track when the app was first opened, total opens, when we last requested a review, and how many expenses a user has added. We use two trigger events: when a user adds an expense and when they settle up. We prioritized the settle-up event because it aligns with a clear success moment. Our heuristics are:

  • Last review request ≥ x days ago
  • First app open ≥ x days ago
  • App opened ≥ x times
  • Current Kitty has ≥ x expenses

We apply these on both platforms, but we’re more conservative on iOS, prompting less frequently and only after stronger engagement because the app is only three weeks old, and because Apple only displays the review prompt maximum 3 times a year.

Collecting reviews is both a technical integration and a product judgment call. With a shared ReviewService and platform-native implementations for Android’s ReviewManager and iOS’s SKStoreReviewController, you now have a clean way to prompt your users for reviews from your common code. Pair that with thoughtful heuristics that favor genuine success moments, and you’ll nudge happy users to share feedback without breaking their flow, ultimately improving ASO, conversion, and the product itself.

If you try this approach in your own Compose Multiplatform app, we’d love to hear what heuristics work best for you. Feels free to contact us on social networks (X, Bluesky, Instagram) or write an email.