android-review-flow
Coroutine-first In-App Review orchestration for Android and iOS, with optional Jetpack Compose integration.
Submit the platform review request at the right moment — safely, deterministically, and without duplicating eligibility rules.
ReviewFlow adds testable rules, persistence, concurrency safety, and observability on top of Play Core and StoreKit.
Features
- Kotlin Multiplatform rules and state machine
- Android and iOS persistence
- Coroutine-first API
- StateFlow for state and non-replaying SharedFlow events
- Cooldown and usage rules
- Once-per-version support
- Immediate single-flight rejection for concurrent requests
- Android source/binary compatibility with
0.1.x - Optional Android Compose integration
- No analytics, tracking, or phone-home behavior
Installation
Add Maven Central:
repositories {
google()
mavenCentral()
}
Classic Android:
implementation("com.zleptnig:reviewflow-core:0.2.0")
Jetpack Compose:
implementation("com.zleptnig:reviewflow-compose:0.2.0")
Kotlin Multiplatform:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("com.zleptnig:reviewflow-core:0.2.0")
}
}
}
The KMP root publication resolves the matching Android or iOS artifact automatically.
reviewflow-compose already depends on reviewflow-core, so Android Compose projects normally
only need the Compose dependency.
Requirements
- Android
minSdk 23 - iOS 16 or newer for the documented
AppStore.requestReview(in:)bridge review-composerequires app projects to compile withcompileSdk 36- A foreground
Activityis required when calling the Android compatibility APItryShow(activity) - Local source builds use JDK 17 and the provided Gradle wrapper
Android Quick Start
The Android API from 0.1.x remains unchanged. A classic Android app does not need to enable KMP.
1. Create the orchestrator
val orchestrator = ReviewOrchestrator.create(context)
// Call once per cold start:
lifecycleScope.launch {
orchestrator.onAppStart()
}
2. Register success moments
After meaningful user actions:
lifecycleScope.launch {
orchestrator.onSuccessMoment()
}
Examples include completing a task, exporting data, saving a trip, or finishing a purchase.
3. Try the review request
lifecycleScope.launch {
val completed = orchestrator.tryShow(activity)
}
true means the Play Core flow completed. Google Play may still suppress the dialog.
false means a rule rejected the request, another request was already in flight, or the platform
request failed.
Multiplatform API
ReviewFlow is the platform-neutral entry point:
class ReviewFlow(
presenter: ReviewPresenter,
store: ReviewStateStore,
versionProvider: AppVersionProvider,
rules: ReviewRules = ReviewRules(),
clock: Clock = SystemClock,
)
It exposes:
val state: StateFlow<ReviewFlowState>
val events: SharedFlow<ReviewFlowEvent>
suspend fun onAppStart()
suspend fun onSuccessMoment()
suspend fun tryRequest(): Boolean
ReviewFlowEvent.RequestCompleted means the platform request API completed; it never claims that a dialog was visible.
Android factory
Provide the foreground activity when using the new common API:
val reviewFlow = AndroidReviewFlow.create(
context = applicationContext,
activityProvider = currentActivityHolder::resumedActivity,
)
AndroidReviewFlow retains the provider for the lifetime of the flow. An application-scoped flow
must therefore use a lifecycle-aware holder backed by a WeakReference; do not capture an
Activity directly with { this }.
If the provider returns null, tryRequest() returns false, emits RequestUnavailable, and
consumes neither the cooldown nor the once-per-version request.
iOS StoreKit bridge
StoreKit's modern request API is Swift-only. Export ReviewFlow from your consuming KMP framework, then install the small Swift bridge:
import StoreKit
import UIKit
import YourSharedFramework
@available(iOS 16.0, *)
final class StoreKitReviewRequest: @preconcurrency IosReviewRequest {
@MainActor
func requestReview() -> Bool {
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive })
else {
return false
}
AppStore.requestReview(in: scene)
return true
}
}
@available(iOS 16.0, *)
func makeReviewFlow() -> ReviewFlow {
IosReviewFlow.shared.create(request: StoreKitReviewRequest())
}
The complete type-checked bridge is available in samples/ios/StoreKitReviewRequest.swift.
A runnable native SwiftUI demo is available in samples/ios. The Kotlin adapter invokes it on the main dispatcher.
ReviewFlow does not fall back to deprecated SKStoreReviewController.
The iOS factory uses NSUserDefaults for counters and cooldown state. The once-per-version value comes from CFBundleShortVersionString, falling back to CFBundleVersion.
Compose Usage
Simple case (direct button action)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val orchestrator = rememberReviewOrchestrator()
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
orchestrator.onSuccessMoment()
orchestrator.tryShow(this@MainActivity)
}
},
) {
Text("Rate app")
}
}
}
}
Advanced case (event-/ViewModel-driven trigger)
val orchestrator = rememberReviewOrchestrator()
var trigger by remember { mutableStateOf(false) }
Button(onClick = { trigger = true }) {
Text("Rate app")
}
ReviewEffect(
orchestrator = orchestrator,
trigger = trigger,
onConsumed = { trigger = false },
)
ReviewEffect targets a different use case.
When to use which:
- Direct button action: user explicitly taps "Rate app" and you want the shortest flow.
ReviewEffect: trigger comes from state/events (for example, a one-shot ViewModel event).- The
sample-appdemonstrates both patterns side by side (simpleandeffectbuttons).
Runtime Behavior (Important)
- Google Play decides whether a review dialog is shown.
- A request can complete as a no-op (
ReviewInfo ... isNoOp=true) even though the flow itself succeeds. ReviewEvent.ShownmeanslaunchReviewFlow(...)completed successfully, not necessarily that a dialog was visible.- Prefer observing
stateandeventsfor diagnostics instead of asserting visible UI behavior.
Rules
Default rules:
- minimum app starts: 3
- minimum success moments: 1
- cooldown: 30 days
- once per app version
Custom:
val orchestrator = ReviewOrchestrator.create(
context = context,
rules = ReviewRules(
minAppStarts = 5,
minSuccessMoments = 3,
cooldown = 14.days,
),
)
Behavior:
- Rule order is once-per-version, app starts, success moments, then cooldown.
- The cooldown begins before the platform request starts, so rapid retries still count.
- A request exactly at the cooldown boundary is allowed.
- A
nullapp version bypasses once-per-version blocking. - Concurrent requests are rejected immediately with
SkipReason.InFlight; they are not queued. - Cancellation is rethrown and resets state to
Idle. - Platform failures do not mark a version as completed but still consume the cooldown.
- Unavailable presentation contexts consume neither the cooldown nor the once-per-version request.
- On Android, Play Core may complete as a no-op even though the request itself succeeded.
SharedFlowevents have replay0and extra buffer capacity16.
Android 0.1.x Compatibility
Existing Android code can upgrade by changing only the version:
implementation("com.zleptnig:reviewflow-core:0.2.0")
The following remain available:
ReviewOrchestrator.create(context)onAppStart()andonSuccessMoment()tryShow(activity)- custom
ReviewClientimplementations ReviewState.Ready(ReviewInfo)- all existing
ReviewState,ReviewEvent, andSkipReasontypes
The legacy ReviewEvent.Shown name is retained for compatibility. It means the Play Core flow completed, not that a dialog was shown. New multiplatform integrations should observe ReviewFlowEvent.RequestCompleted.
Android continues to use the existing DataStore name and keys, so counters, cooldowns, and once-per-version state survive the upgrade.
Observing State and Events
Use state for the current status and events for one-off diagnostics:
lifecycleScope.launch {
orchestrator.state.collect { state ->
when (state) {
is ReviewState.Ready -> {}
is ReviewState.Showing -> {}
is ReviewState.Done -> {}
is ReviewState.Error -> {}
else -> {}
}
}
}
lifecycleScope.launch {
orchestrator.events.collect { event ->
// Diagnostics or application-owned analytics.
}
}
The multiplatform ReviewFlow exposes StateFlow<ReviewFlowState> and
SharedFlow<ReviewFlowEvent> with the same state-versus-event distinction.
Testing
Common orchestration can be tested with:
- fake
ReviewPresenter - in-memory
ReviewStateStore - fake
AppVersionProvider - fake
Clock
Android compatibility tests can continue using a fake ReviewClient. Do not test dialog appearance; Google Play and StoreKit control whether a dialog is visible.
Modules
| Module | Description |
|---|---|
review-core (:reviewflow-core) |
KMP orchestration, rules, persistence, Android Play Core, and iOS adapters |
review-compose |
Android Compose helpers around the compatibility orchestrator |
sample-app |
Android integration and release-gating smoke tests |
Publishing (Maintainers)
Run the full release verification:
./gradlew verifyMavenCentralRelease
Build all platform publications locally:
./gradlew :reviewflow-core:publishAllPublicationsToProjectLocalRepository \
:review-compose:publishAllPublicationsToProjectLocalRepository
Release both modules:
./gradlew releaseToMavenCentralPortal
Follow the complete procedure in RELEASING.md.
License
Apache 2.0
ReviewFlow development is supported by creative workline GmbH.
