Last month I had a task at my work — to implement in-app review feature for the Android and iOS. Quite a common task which was implemented separately in both platforms (added and called in-app-review api into the Android module and SKStoreReviewController into iOS). But this task gave the birth to the idea to try to place all the logic (at least as much as we can into the KMP module). Finally, I finished this work and happy to share the results with you.
The main result — I created a special library for it (kmp-in-app-review). And now let me explain how does it work.
Main class in the library (but probably, it’s an interface — InAppReviewDelegate. It has 3 methods:
- fun requestInAppReview(): Flow<ReviewCode>
- fun requestInMarketReview(): Flow<ReviewCode>
- fun init()
Let’s start from the first two methods — as you can understand from their names they request in-app review or open in-market review page and return the Flow that allows to listen the results of the review process. Third method, init, is added if the delegate implementation needs some additional initialisation (I’ll write about this case below).
InAppReviewDelegate is located inside commonMain source set and has platform-specific implementations. For the Android it has two implementations — AppGalleryInAppReviewManager and GooglePlayInAppReviewManager (from the names you can see that they relate to App Gallery and Google Play markets).
Here’s the implementation of GooglePlayInAppReviewManager:
class GooglePlayInAppReviewManager(private val params: GooglePlayInAppReviewInitParams) : InAppReviewDelegate { | |
override fun requestInAppReview(): Flow<ReviewCode> = flow { | |
val activity = params.activity | |
val manager = ReviewManagerFactory.create(activity) | |
val reviewInfo = manager.requestReviewFlow().await() | |
manager.launchReviewFlow(activity, reviewInfo).await() | |
emit(ReviewCode.NO_ERROR) | |
}.catch { e -> | |
if (e is ReviewException) { | |
emit(ReviewCode.fromCode(e.errorCode)) | |
} else { | |
throw e | |
} | |
} | |
override fun requestInMarketReview() = flow { | |
val activity = params.activity | |
val packageName = activity.packageName | |
val marketAppIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).apply { | |
flags += Intent.FLAG_ACTIVITY_NO_HISTORY or | |
Intent.FLAG_ACTIVITY_NEW_DOCUMENT or | |
Intent.FLAG_ACTIVITY_MULTIPLE_TASK | |
} | |
val marketInBrowserIntent = Intent( | |
Intent.ACTION_VIEW, | |
Uri.parse("http://play.google.com/store/apps/details?id=$packageName") | |
) | |
runCatching { | |
activity.startActivity(marketAppIntent) | |
}.getOrElse { | |
activity.startActivity(marketInBrowserIntent) | |
} | |
emit(ReviewCode.NO_ERROR) | |
} | |
} |
Let me explain the logic here:
- To create the object of this class we need to pass the instance of GooglePlayInAppReviewInitParams. But what is it? Now it’s just a wrapper over an activity — class GooglePlayInAppReviewInitParams(val activity: Activity). But in case library would be extended and we’ll need to pass more params I decided to create a wrapper and not to pass just an activity. Also the constructor param is needed to allow the library have methods with empty parameters, since not all implementations need these additional params.
- Now let’s speak about the requestInAppReview method — it launches in-app review api. If everything worked ok, ReviewCode.NO_ERROR will be emitted. But also there’s an exception handling mechanism — if the exception is caught it will be transformed into ReviewCode while other exceptions will be thrown downstream. It was done on purpose to provide more control to the application side.
- The last, requestInAppReview, just opens the app’s page in Google Play. It always emits ReviewCode.NO_ERROR.
Now let’s go to the AppGalleryInAppReviewManager implementation.
class AppGalleryInAppReviewManager( | |
private val params: AppGalleryInAppReviewInitParams | |
) : InAppReviewDelegate { | |
private val resultFlow = MutableSharedFlow<ReviewCode>() | |
private var activityResult: ActivityResultLauncher<Intent>? = null | |
private val reviewCodeMapper by lazy(::AppGalleryReviewCodeMapper) | |
override fun init() { | |
activityResult = | |
params.activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> | |
val resultCode = result.resultCode | |
GlobalScope.launch { resultFlow.emit(reviewCodeMapper(resultCode)) } | |
} | |
} | |
override fun requestInAppReview(): Flow<ReviewCode> { | |
val intent = Intent("com.huawei.appmarket.intent.action.guidecomment") | |
.setPackage("com.huawei.appmarket") | |
activityResult?.launch(intent) | |
return resultFlow | |
} | |
override fun requestInMarketReview() = requestInAppReview() | |
} |
You can see here the structure common to the Google Play’s — we also pass the params into constructor:
class AppGalleryInAppReviewInitParams(val activity: ComponentActivity)
Here we need the ComponentActivity since we register activity result listeners. Since there’s no in-app review api from HMS services requestInAppReview and requestInMarketReview both have the same implementation — open the app’s rating screen in market and waiting for the result. Here we use additional method init — it was added because the result listeners should be added before the fragment or activity is created. Since there are multiple result code there’s AppGalleryReviewCodeMapper inside that maps int result code into ReviewCode enum.
The last implementation is for App Store:
class AppStoreInAppReviewManager(private val params: AppStoreInAppReviewInitParams) : InAppReviewDelegate { | |
override fun requestInAppReview(): Flow<ReviewCode> = flow { | |
if (systemVersionMoreOrEqualThan("14.0")) { | |
val scene = UIApplication.sharedApplication.connectedScenes.map { it as UIWindowScene } | |
.first { it.activationState == UISceneActivationStateForegroundActive } | |
SKStoreReviewController.requestReviewInScene(scene) | |
} else { | |
SKStoreReviewController.requestReview() | |
} | |
emit(ReviewCode.NO_ERROR) | |
} | |
override fun requestInMarketReview() = flow { | |
val url = NSURL(string = "https://apps.apple.com/app/${params.appStoreId}?action=write-review") | |
UIApplication.sharedApplication.openURL(url) | |
emit(ReviewCode.NO_ERROR) | |
} | |
} |
Job Offers
AppStore params contains AppStoreId of the app. Here’s what the user will see in case of in-app review
Now let’s discuss how to integrate this library into the project:
First — add the dependency into commonMain
implementation("com.mikhailovskii.kmp:in-app-review-kmp:$version")
Second — in the KMP module you should create an instance of InAppReviewDelegate implementation. That’s the way how I did it in sample project:
private val defaultReviewManager by lazy {
getDefaultReviewManager(getDefaultParams())
}
getDefaultReviewManager is the expect/actual method from the library that provides GooglePlayInAppReviewManager for the Android and AppStoreInAppReviewManager for iOS.
getDefaultParams — is the expect/actual method but from the sample project. It creates the instances of GooglePlayInAppReviewInitParams for the Android and AppStoreInAppReviewInitParams for the iOS.
And now you can easily use it
fun init() { | |
defaultReviewManager.init() | |
} | |
fun requestInAppReview() { | |
GlobalScope.launch { | |
defaultReviewManager.requestInAppReview().collect { | |
println("Result code=$it") | |
} | |
} | |
} | |
fun requestInMarketReview() { | |
defaultReviewManager.requestInMarketReview() | |
} |
Here’s the full code of sample project.
In the end I’ll raise the question and answer by myself — what should I do if I need to support some other market? The main idea of the library is to provide the interface and show the way how it can be implemented for some markets. So, you can easily create your own implementation based on the delegate’s interface and use it combining with the library’s implementations. But also don’t hesitate to create your PRs, I’ll be really happy to see them 🙂
Thank you for reading! Feel free to ask questions and leave the feedback in comments or Linkedin.
This article is previously published on proandroiddev.com