
Coroutine-first in-app review orchestration adding testable rules, persistence, concurrency safety, cooldown and once-per-version policies, StateFlow/SharedFlow observability, single-flight rejection, optional Compose integration.
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.
0.1.x
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.
minSdk 23
AppStore.requestReview(in:) bridgereview-compose requires app projects to compile with compileSdk 36
Activity is required when calling the Android compatibility API
tryShow(activity)
The Android API from 0.1.x remains unchanged. A classic Android app does not need to enable KMP.
val orchestrator = ReviewOrchestrator.create(context)
// Call once per cold start:
lifecycleScope.launch {
orchestrator.onAppStart()
}After meaningful user actions:
lifecycleScope.launch {
orchestrator.onSuccessMoment()
}Examples include completing a task, exporting data, saving a trip, or finishing a purchase.
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.
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(): BooleanReviewFlowEvent.RequestCompleted means the platform request API completed; it never claims that a dialog was visible.
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.
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.
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")
}
}
}
}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:
ReviewEffect: trigger comes from state/events (for example, a one-shot ViewModel event).sample-app demonstrates both patterns side by side (simple and effect buttons).ReviewInfo ... isNoOp=true) even though the flow itself succeeds.ReviewEvent.Shown means launchReviewFlow(...) completed successfully, not necessarily that a dialog was visible.state and events for diagnostics instead of asserting visible UI behavior.Default rules:
Custom:
val orchestrator = ReviewOrchestrator.create(
context = context,
rules = ReviewRules(
minAppStarts = 5,
minSuccessMoments = 3,
cooldown = 14.days,
),
)Behavior:
null app version bypasses once-per-version blocking.SkipReason.InFlight; they are not queued.Idle.SharedFlow events have replay 0 and extra buffer capacity 16.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() and onSuccessMoment()
tryShow(activity)ReviewClient implementationsReviewState.Ready(ReviewInfo)ReviewState, ReviewEvent, and SkipReason typesThe 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.
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.
Common orchestration can be tested with:
ReviewPresenter
ReviewStateStore
AppVersionProvider
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.
| 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 |
Run the full release verification:
./gradlew verifyMavenCentralReleaseBuild all platform publications locally:
./gradlew :reviewflow-core:publishAllPublicationsToProjectLocalRepository \
:review-compose:publishAllPublicationsToProjectLocalRepositoryRelease both modules:
./gradlew releaseToMavenCentralPortalFollow the complete procedure in RELEASING.md.
Apache 2.0
ReviewFlow development is supported by creative workline GmbH.
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.
0.1.x
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.
minSdk 23
AppStore.requestReview(in:) bridgereview-compose requires app projects to compile with compileSdk 36
Activity is required when calling the Android compatibility API
tryShow(activity)
The Android API from 0.1.x remains unchanged. A classic Android app does not need to enable KMP.
val orchestrator = ReviewOrchestrator.create(context)
// Call once per cold start:
lifecycleScope.launch {
orchestrator.onAppStart()
}After meaningful user actions:
lifecycleScope.launch {
orchestrator.onSuccessMoment()
}Examples include completing a task, exporting data, saving a trip, or finishing a purchase.
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.
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(): BooleanReviewFlowEvent.RequestCompleted means the platform request API completed; it never claims that a dialog was visible.
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.
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.
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")
}
}
}
}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:
ReviewEffect: trigger comes from state/events (for example, a one-shot ViewModel event).sample-app demonstrates both patterns side by side (simple and effect buttons).ReviewInfo ... isNoOp=true) even though the flow itself succeeds.ReviewEvent.Shown means launchReviewFlow(...) completed successfully, not necessarily that a dialog was visible.state and events for diagnostics instead of asserting visible UI behavior.Default rules:
Custom:
val orchestrator = ReviewOrchestrator.create(
context = context,
rules = ReviewRules(
minAppStarts = 5,
minSuccessMoments = 3,
cooldown = 14.days,
),
)Behavior:
null app version bypasses once-per-version blocking.SkipReason.InFlight; they are not queued.Idle.SharedFlow events have replay 0 and extra buffer capacity 16.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() and onSuccessMoment()
tryShow(activity)ReviewClient implementationsReviewState.Ready(ReviewInfo)ReviewState, ReviewEvent, and SkipReason typesThe 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.
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.
Common orchestration can be tested with:
ReviewPresenter
ReviewStateStore
AppVersionProvider
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.
| 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 |
Run the full release verification:
./gradlew verifyMavenCentralReleaseBuild all platform publications locally:
./gradlew :reviewflow-core:publishAllPublicationsToProjectLocalRepository \
:review-compose:publishAllPublicationsToProjectLocalRepositoryRelease both modules:
./gradlew releaseToMavenCentralPortalFollow the complete procedure in RELEASING.md.
Apache 2.0
ReviewFlow development is supported by creative workline GmbH.