Integrating a Seamless In-App Review Rating Experience in MVI


Last year, Android introduced a new in-app review API that would allow users to review and rate an app within the app itself. Meaning, developers no longer have to worry about keeping track of how many times the “Rate Us” dialog has been displayed to users and whether the users clicked to go Play Store App.

The intention of this API is to remove overhead on developers and keep users in the app with respect to their privacy. However, from a developer standpoint, there are a couple of prerequisites to keep in mind while integrating the in-app review API.

  1. Your app’s applicationId must at least be available in the internal track on Google Play, otherwise the in-app review will never appear.

  2. Displaying a review bottomsheet is not guaranteed. Play Core handles the logic of how many times the bottomsheet will be displayed to a user, whether the user has interacted with the bottomsheet, and whether the user has already submitted a review. The API only exposes one listener to notify whether we should proceed with the regular flow in the app. Thus, all this handling done by the API causes end-to-end testing to become difficult.

In this tutorial, I’ll walk you through the integration of the in-app review API in MVI fashion.

Implementation

Prerequisites of this tutorial:

  • RxJava
  • Dagger
  • MVI (model-view-intent)

Let’s dive into details:

Step 1:

Add the following dependencies into the build.gradle file of your desired feature:

1// This dependency is downloaded from Google’s Maven repository.
2// So, make sure you also include that repository in your project's build.gradle file.
3implementation 'com.google.android.play:core:1.10.2'
4
5// For Kotlin users also import the Kotlin extensions library for Play Core:
6implementation 'com.google.android.play:core-ktx:1.8.1'

Step 2:

Create an instance of ReviewManager inside the desired feature’s dagger module:

1@Provides
2@Singleton
3fun providesReviewManager(): ReviewManager {
4   return ReviewManagerFactory.create(context)
5}

Step 3:

Inject the instance into the feature’s viewmodel:

1class ActivityViewModel @Inject constructor(
2   private val navigator: Navigator,
3   private val reviewManager: ReviewManager
4) : ViewModel() {

Step 4:

In this step we introduce the logic of triggering the in-app review. Due to the quota limitations, Play Console advises not to have any call-to-action option that triggers the API. So the best way to trigger it will be after completing a couple of levels in a game, and/or after the user becomes familiar with the app and has used it for quite a while.

In my example, I don’t have too much complicated logic implemented. Rather, I’m just triggering the API after the 5th,10th, and 20th session. Session numbers are saved in shared preferences.

 1Single.create<ReviewInfo> { emitter ->
 2   val session = sharedPreferences.getInt(SESSION, 1)
 3   if (session == 5 || session == 10 || session == 20) {
 4       val reviewInfo = reviewManager.requestReviewFlow()
 5       reviewInfo.addOnCompleteListener { task ->
 6           if (task.isSuccessful) {
 7               emitter.onSuccess(task.result)
 8           } else {
 9               emitter.onError(Throwable(task.exception))
10           }
11
12       }
13   } else {
14       emitter.onError(Throwable("no time to request in-app review"))
15   }
16}.map<ActivityIntent> {
17   ActivityIntent.InAppReviewRequested(it)
18}.onErrorReturn { error ->
19   //If this throws an error, do you need to display it to a user? Not really.
20   // We can just log an error.
21   ActivityIntent.NoOp
22}.toObservable()

If the session is 5th, 10th or 20th, request a review flow from ReviewManager (line 4). Add a complete listener to the request. If the request is successful, we can go ahead and launch the review flow on UI. If not, we emit the error, but do nothing with it (when mapping, the error is converted into NoOp intent that doesn’t change or update the viewstate).

Step 5:

In the reducer, pass the reviewInfo object which is used to launch the flow, to the state:

1is ActivityIntent.InAppReviewRequested -> {
2   previous.copy(reviewInfo = intent.reviewInfo)
3}

Step 6: In the subscriber, listen to the changes and if reviewInfo object exists inside the viewstate, launch the flow:

1viewState.reviewInfo?.run {
2   val review = reviewManager.launchReviewFlow(this@MainActivity, this)
3   review.addOnCompleteListener {
4       intentsSubject.onNext(ActivityIntent.InAppReviewCompleted)
5   }
6}

After the review flow is completed(note: even at this point, it is not guaranteed whether the bottomsheet will appear or not), trigger the InAppReviewCompleted intent to continue on with the regular flow. In our viewmodel, where intents are bounded, handle the InAppReviewCompleted :

1it.ofType(ActivityIntent.InAppReviewCompleted::class.java)
2   .flatMapCompletable {
3       //after receiving a signal that review flow ended, we proceed with regular flow
4       navigator.navigateTo(NavigatorPath.Feed)
5   }.toObservable<ActivityIntent>()

Testing

Manual Testing

For manual testing, make sure an account has not reviewed the app yet. If so - delete the review and add the account email into the internal test tracking group. Then, proceed to distribute the internal test build to your test group. There is a great walkthrough of in-app review manual testing by John Codeos.

Unit Testing

I’ve noticed in many blog posts about in-app review API, the unit testing is not fully covered and only briefly mentioned. It will not be the case with this post 😉.

For mocking in unit tests, I use Mockk library.

1implementation 'io.mockk:mockk:1.10.2'

Inside the ViewModelTest, create a mock instance of FakeReviewManager:

1private val reviewManager: FakeReviewManager = mockk(relaxed = true)

Then mock the instance of the task and reviewInfo:

1val mockReviewInfo = mockk<ReviewInfo>(relaxed = true)
2val mockReviewInstance = mockk<Task<ReviewInfo>>(relaxed = true)

Then mock the successful behavior of reviewInfo and task instances:

 1//given
 2every { mockReviewInstance.isSuccessful } returns true
 3every { mockReviewInstance.result } returns mockReviewInfo
 4every { reviewManager.requestReviewFlow() } returns mockReviewInstance
 5
 6val slot = slot<OnCompleteListener<ReviewInfo>>()
 7every { mockReviewInstance.addOnCompleteListener(capture(slot)) } answers {
 8   slot.captured.onComplete(mockReviewInstance)
 9   mockReviewInstance
10}

Set up a list of expected viewstates:

1//given
2val expectedViewStates = arrayOf(
3   ViewState(),
4   ViewState().copy(reviewInfo = mockReviewInfo)
5)

Then check values of actual and expected view states:

1//when
2val resultsObserver = activityViewModel.bind(intentSubject).test()
3
4//then
5resultsObserver.assertValues(*expectedViewStates)

For failed state, mock the failed behavior by tweaking the return:

1//given
2every { mockReviewInstance.isSuccessful } returns false
3every { mockReviewInstance.result } returns mockReviewInfo
4every { mockReviewInstance.exception } returns Exception(Unsuccessful_Task)
5every { reviewManager.requestReviewFlow() } returns mockReviewInstance

The full integration PR can be found here with unit tests in place.

I hope this post was helpful. If you have questions or something is unclear - please leave a comment!


Huge thank you to Eugenio Lopez for reviewing and editing this blog post 🙏.


❤️ Was this post helpful?

If you liked the article, give a shoutout to @aida_isay on Instagram, Threads, or Twitter and help share this article.

Thank you for your support 🙌