Skip to content

Commit 0c2dcbd

Browse files
committed
First draft of review prompt
1 parent b7148cc commit 0c2dcbd

File tree

9 files changed

+274
-0
lines changed

9 files changed

+274
-0
lines changed
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
---
2+
title: An Android in-app rating prompt helper to rapidly improve Play Store rating (from 2.0 - 4.2 in 8 days!)
3+
image: /assets/images/banners/rating-browser.png
4+
tags:
5+
- Android
6+
- Play Store
7+
- Kotlin
8+
---
9+
10+
I recently worked on an app that had been rated between 1 and 2 stars for months, despite no major issues. The solution? In-app rating prompts!
11+
12+
All code in this article [is available as a GitHub Gist](https://gist.github.com/JakeSteam/c09c7bd980095a8a26649419d49d393e).
13+
14+
## Play Store Rating
15+
16+
Like a lot of people, I rely on an app's Play Store rating as a rough indicator of quality / trustworthiness. Typically, anything below 4 stars needs a closer look, but above is probably fine. Despite this, the app I spend most of my time on was rated 2.0! Time to fix that.
17+
18+
### Why was the rating so low?
19+
20+
Historically, the app had a number of pretty severe problems. This included features not working, instability, unresponsive UI, etc. All of this resulted in an abysmal **rating of 1.4 in February 2024**.
21+
22+
Since the app typically received just 2 - 5 reviews a week, almost all were from unhappy customers!
23+
24+
By steadily working through hundreds of raised bugs, aggressively improving stability by fixing _every_ identified crash (currently 99.97% crash-free users!), and constantly improving overall quality, this slowly started improving. 9 months later, the app had steadily increased to a **rating of 2.0 in November 2024**, primarily by reducing the quantity of 1-star reviews.
25+
26+
On Google Play Console, under Monitor and Improve -> Ratings and Reviews -> Ratings, the number of ratings and rating distribution can be seen.
27+
28+
[![](/assets/images/2024/rating-perdayearly.png)](/assets/images/2024/rating-perdayearly.png)
29+
30+
Whilst this was a big improvement (the minimum is 1 star, so the number of non-1-star reviews was actually 4x higher!), it was nowhere near my acceptable level: 4.0+.
31+
32+
### What happened to the rating?
33+
34+
Reducing the number of 1-star reviews helped, but you know what would help even more? A flood of 5-star reviews! Continuing to receive feedback from unhappy customers is expected, and appreciated, so long as the thousands of satisfied customers are heard too.
35+
36+
Looking at a chart of number of ratings over the past 3 months explains what happened. Instead of an occasional rating, literally hundreds of 4 and 5-star reviews flooded in, solving the problem within a couple of weeks:
37+
38+
[![](/assets/images/2024/rating-perday.png)](/assets/images/2024/rating-perday.png)
39+
40+
With a regular release schedule, the per-version average rating can also be used to check which is responsible for the flood of reviews. It might be the version with at least 30x as many reviews as any other!
41+
42+
[![](/assets/images/2024/rating-perversion.png)](/assets/images/2024/rating-perversion.png)
43+
44+
### How was the rating improved?
45+
46+
With [Google's In-App Review Prompt library](https://developer.android.com/guide/playcore/in-app-review)!
47+
48+
By prompting _happy_ users with a very low-friction way to express their satisfaction, gathering high volumes of positive reviews was surprisingly painless. Google's diagram describes the flow well:
49+
50+
[![](/assets/images/2024/rating-googleflow.jpg)](/assets/images/2024/rating-googleflow.jpg)
51+
52+
The app itself has _no direct control or observability_ of this rating prompt. Instead, it asks Google's library to show the prompt if possible, and receives a callback when the prompt is finished. There are many, many, many reasons the callback might be called, all intentionally hidden from the app:
53+
54+
1. Rating prompt not shown because app was not installed from the store.
55+
2. Rating prompt not shown because the user has already rated the app.
56+
3. Rating prompt shown, user dismissed.
57+
4. Rating prompt shown, user rated and submitted.
58+
59+
All of this is to ensure apps can't reward (or punish!) the user based on their rating, or whether they rated at all. This is a good thing, and also means the app doesn't need to worry about all the various outcomes!
60+
61+
Instead, the app just needs to _ask to show_ the prompt, and _receive the callback_ when it's finished. This [isn't too complicated](https://developer.android.com/guide/playcore/in-app-review/kotlin-java), but I created a `ReviewPromptHandler` wrapper to drastically simplify usage.
62+
63+
I defined a few specific triggers where the prompt would be shown if possible, specifically at moments where customers are engaged and have positive sentiment (e.g. clicking "Look at upgrade" after winning an auction).
64+
65+
## Review Prompt Handler
66+
67+
So, why add complexity to a fairly simple to use library? Well, there are a few requirements in my use case to keep customers, Google, and other developers in the codebase happy!
68+
69+
### Requirements
70+
71+
1. **Simple to use**: Any ViewModel that wants to display a review prompt shouldn't need to keep track of the request status, handle errors etc. It should just be able to request the prompt's appearance, and optionally pre-load.
72+
2. **Remotely configurable triggers**: I want to be able to remotely control where this prompt appears. For example, I may want to disable it appearing after a successful bud due to some unrelated technical issue.
73+
3. **Triggered on button click or without specific interaction**: The review prompt should be triggerable by a variety of trigger types.
74+
4. **Able to remotely disable**: In case there's some catastrophic issue in Google's library, I want the ability to ensure it isn't relied upon at all if there's no enabled triggers.
75+
5. **Requests aren't spammed**: Whilst Google [doesn't explicitly state quotas](https://developer.android.com/guide/playcore/in-app-review#quotas), they do say:
76+
1. "_To provide a great user experience, Google Play enforces a time-bound quota on how often a user can be shown the review dialog._"
77+
2. "_Because the quota is subject to change, it's important to apply your own logic and target the best possible moment to request a review._"
78+
79+
Okay, pretty sensible requirements. How can they be all be implemented?
80+
81+
### Handler flow
82+
83+
This is a quite technical diagram (it's taken from my PR for the feature!), however it does show the main paths through the handler and prompt.
84+
85+
To clarify the 3 coloured sections:
86+
87+
- **Yellow**: The rest of the app, usually the calling `ViewModel`. It knows almost nothing, and just calls functions.
88+
- **Green**: The `ReviewPromptHandler.kt` described in the next section. This abstracts all the complexity way, and is the only class that interfaces with the in-app review prompt library.
89+
- **Blue**: Google's in-app review prompt library, where all decision-making is intentionally obfuscated from the calling codebase.
90+
91+
[![](/assets/images/2024/rating-flow.png)](/assets/images/2024/rating-flow.png)
92+
93+
As shown, the review prompt handler is going to have 2 callable functions (ignoring any initialisation), where `ReviewPromptTrigger` is a simple enum:
94+
95+
```
96+
fun prepareReviewPrompt(trigger: ReviewPromptTrigger)
97+
fun showReviewPrompt(trigger: ReviewPromptTrigger, callback: () -> Unit = {})
98+
```
99+
100+
### Handler code
101+
102+
The full code [is available as a GitHub Gist](https://gist.github.com/JakeSteam/c09c7bd980095a8a26649419d49d393e), read on for an explanation.
103+
104+
#### Initialisation
105+
106+
Unfortunately, the library requires a `Context` to initialise (I used `Application`), and an `Activity` to display the prompt (I used my `MainActivity`).
107+
108+
This means a bit of non-ideal boilerplate and references to `Activity`:
109+
110+
```
111+
@Singleton
112+
class ReviewPromptHandler @Inject constructor(
113+
application: Application,
114+
private val remoteConfigManager: RemoteConfigManager
115+
) {
116+
private val reviewManager = ReviewManagerFactory.create(application)
117+
private var activity: Activity? = null
118+
...
119+
fun setActivity(activity: Activity) {
120+
this.activity = activity
121+
}
122+
```
123+
124+
#### Remote triggers
125+
126+
As mentioned, I want to be able to remotely configure my triggers. To implement this, I have a local enum of possible trigger points:
127+
128+
```
129+
enum class ReviewPromptTrigger(val remoteName: String) {
130+
BID_MADE("BID_MADE"),
131+
BUN_PURCHASED("BUN_PURCHASED"),
132+
TICKET_PURCHASED("TICKET_PURCHASED")
133+
}
134+
```
135+
136+
Then, I'm using Firebase Remote Config (any other remote value fetcher is fine) with a comma-separated `review_prompt_triggers` (e.g. `BID_MADE,TICKET_PURCHASED`). By checking the passed `ReviewPromptTrigger` is in the list of enabled triggers, remote control over the prompt is supported.
137+
138+
Additionally, I only want to prompt for the same trigger _once per session_, so I have a list of triggers that have fired.
139+
140+
```
141+
private val triggersThisSession = mutableListOf<ReviewPromptTrigger>()
142+
143+
private fun shouldShow(trigger: ReviewPromptTrigger): Boolean {
144+
if (triggersThisSession.contains(trigger)) {
145+
return false
146+
}
147+
return remoteConfigManager.getString(review_prompt_triggers)
148+
.split(",")
149+
.map { it.trim() }
150+
.contains(trigger.remoteName)
151+
}
152+
```
153+
154+
#### Pre-caching review prompt
155+
156+
Google's advice on when to prepare a review prompt object is a little vague. Essentially you should request it before you ask the user, but it expires eventually:
157+
158+
> Note: The `ReviewInfo` object is only valid for a limited amount of time. Your app should request a `ReviewInfo` object ahead of time (pre-cache) but only once you are certain that your app will launch the in-app review flow.
159+
160+
In my scenario, I pre-cache when the checkout flow starts, since this will typically result in a successful checkout (where the prompt will be used).
161+
162+
Using our `shouldShow` function from earlier, we request a review flow object and store the result in memory:
163+
164+
```
165+
fun prepareReviewPrompt(trigger: ReviewPromptTrigger) {
166+
preparedReviewPrompt = null
167+
if (!shouldShow(trigger)) {
168+
return
169+
}
170+
171+
reviewManager.requestReviewFlow().addOnCompleteListener { request ->
172+
if (request.isSuccessful) {
173+
preparedReviewPrompt = request.result
174+
}
175+
}
176+
}
177+
```
178+
179+
#### Displaying review prompt
180+
181+
Finally, we can put this all together and actually show a prompt! I also decided to support the scenario where the caller didn't have an opportunity to call `prepareReviewPrompt`.
182+
183+
This means there's 2 flows (one with pre-caching (`launchReviewPrompt`), one without (`prepareAndLaunchReviewPrompt`)). If there's no pre-caching, we fetch the review prompt object now instead, then display it once fetched. The trigger should also be added to the in-memory blacklist to avoid excessive requests.
184+
185+
We also _must_ call the passed in `callback` no matter what happens, otherwise the user will get stuck on their current screen!
186+
187+
```
188+
fun showReviewPrompt(trigger: ReviewPromptTrigger, callback: () -> Unit = {}) {
189+
val activity = activity
190+
if (!shouldShow(trigger) || activity == null) {
191+
callback()
192+
return
193+
}
194+
195+
triggersThisSession.add(trigger)
196+
preparedReviewPrompt?.let {
197+
launchReviewPrompt(activity, it, callback)
198+
} ?: prepareAndLaunchReviewPrompt(activity, callback)
199+
}
200+
201+
private fun prepareAndLaunchReviewPrompt(activity: Activity, callback: () -> Unit) {
202+
reviewManager.requestReviewFlow().addOnCompleteListener { request ->
203+
if (request.isSuccessful) {
204+
launchReviewPrompt(activity, request.result, callback)
205+
} else {
206+
Log.i("ReviewPromptHandler", "Failed to prepareAndLaunchReviewPrompt")
207+
callback()
208+
}
209+
}
210+
}
211+
212+
private fun launchReviewPrompt(activity: Activity, reviewInfo: ReviewInfo, callback: () -> Unit) {
213+
reviewManager.launchReviewFlow(activity, reviewInfo).addOnCompleteListener {
214+
if (!it.isSuccessful) {
215+
Log.i("ReviewPromptHandler", "Failed to launchReviewPrompt")
216+
}
217+
callback()
218+
}
219+
}
220+
```
221+
222+
#### Calling the handler
223+
224+
Finally, our handler is ready to use!
225+
226+
Assuming `ReviewPromptHandler` has been injected or initialised, the `TicketPurchaseViewModel` prepares the prompt when checkout flow starts:
227+
228+
```
229+
reviewPromptHandler.prepareReviewPrompt(ReviewPromptTrigger.TICKET_PURCHASED)
230+
```
231+
232+
Then, when a prompt should be shown if possible (e.g. on button click), the `Fragment` passes in the normal post-button press action into the `ViewModel`:
233+
234+
```
235+
val navToBookings: () -> Unit = {
236+
findNavController().navigate(TicketFragmentDirections.toBookings())
237+
}
238+
...
239+
when (event) {
240+
is TicketConfirmationEvents.OnLookAtMyTicket -> {
241+
viewModel.onLookAtMyTicket(navToBookings)
242+
}
243+
```
244+
245+
Where the `ViewModel`'s `onLookAtMyTicket` just calls `ReviewPromptHandler` with the correct `ReviewPromptTrigger`:
246+
247+
```
248+
fun onLookAtMyTicket(navigation: () -> Unit) {
249+
reviewPromptHandler.showReviewPrompt(ReviewPromptTrigger.TICKET_PURCHASED, navigation)
250+
}
251+
```
252+
253+
### How to test
254+
255+
Similar to testing whilst [implementing Google's force upgrade library](/googles-force-update-android-app-library/), this can't be tested easily on your local machine, and must be obtained via the Google Play Store.
256+
257+
Thankfully, it's far easier to test than force upgrade! Note that due to the "black box" of the library, you might not see the prompt despite following all the steps. Additionally, once you've seen _one_ prompt, you likely won't see any others.
258+
259+
1. Prepare a build, and upload it to Google Play Console internal app sharing.
260+
2. Uninstall your app from your device.
261+
3. Add your device's primary email address to the "In-app review testing" list on Google Play Console.
262+
4. Open Google Play Console's internal app sharing link on your device.
263+
5. Install the app from this link.
264+
6. Click your triggers, and make sure your `callback` actually happens (far more important than the review prompt appearing).
265+
266+
You'll see slightly different messages when testing via internal app sharing vs a production app:
267+
268+
| Internal app sharing | Production |
269+
| :-------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------: |
270+
| [![](/assets/images/2024/rating-internal.png)](/assets/images/2024/rating-internal.png) | [![](/assets/images/2024/rating-prod.png)](/assets/images/2024/rating-prod.png) |
271+
272+
_Note: The oddly zoomed-in app icon happens on my device for all prod apps, presumably it's a Google / Samsung issue!_
273+
274+
## Conclusion

assets/images/2024/rating-flow.png

248 KB
Loading
111 KB
Loading
67 KB
Loading
22.4 KB
Loading
8.58 KB
Loading
20.3 KB
Loading

assets/images/2024/rating-prod.png

74.2 KB
Loading
35.8 KB
Loading

0 commit comments

Comments
 (0)