Google Play Core: In-App Updates & Reviews
Introduction
Google Play provides developers with powerful APIs for managing app distribution and user engagement without requiring a full reinstall. The Play Core library gives you three critical capabilities: prompting users to update your app (either silently in the background or with an interruption-free flexible flow), collecting in-app reviews through the Play Store's native dialog, and delivering dynamic feature modules that users can install on demand. These APIs replace older approaches like manually parsing version codes or directing users to the Play Store web page, and they integrate cleanly with modern Android project structures. In this tutorial, we'll walk through each of these features with production-ready code.
Project Setup and Dependencies
Before using any Play Core feature, you need to add the library to your app module. The core artifact includes all the managers and listeners you'll need for updates, reviews, and dynamic features. You should pin the version number rather than using a dynamic + notation, because the Play Core library follows strict SemVer and a newer minor version can introduce behavioral changes in the review dialog or update flow.
dependencies {
implementation("com.google.android.play:core:1.10.3")
implementation("com.google.android.play:core-ktx:1.8.1")
}
The core artifact is the Java-compatible baseline, while core-ktx adds Kotlin coroutine extensions and property-based APIs that make callback-heavy code significantly cleaner. Both versions must be compatible with each other—using mismatched major versions will cause runtime crashes because the Kotlin extension classes expect specific Java interfaces. Make sure you are targeting at least API level 21, since the review flow and dynamic feature systems require capabilities that predate Android 5.0.
Flexible In-App Updates
Flexible updates let your app detect a new version and begin downloading it in the background while the user continues working. The system shows a persistent notification that the user can act on later. This approach respects the user's context, but it requires more careful lifecycle management than immediate updates because the download can be paused, resumed, or cancelled across multiple app launches.
class MainActivity : AppCompatActivity() {
private lateinit var appUpdateManager: AppUpdateManager
private val flexibleUpdateCallback = object : StateUpdateListener {
override fun onUpdateStatusUpdate(status: Int) {
when (status) {
InstallStatus.DOWNLOADED -> {
// Notify the user that the update is ready to install.
Snackbar.make(
binding.root,
"Update downloaded. Tap to restart.",
Snackbar.LENGTH_INDEFINITE
).setAction("Install") {
appUpdateManager.completeUpdate()
}.show()
}
else -> {
// Track other statuses such as DOWNLOADING.
}
}
}
override fun onError(error: Throwable) {
Log.e("MainActivity", "Flexible update error: ${error.message}")
}
}
override fun onResume() {
super.onResume()
appUpdateManager = AppUpdateManagerFactory.create(this)
val listeningFuture = appUpdateManager.startUpdateFlowForResult(
appUpdateManager.appUpdateInfo,
AppUpdateType.FLEXIBLE,
this,
RC_FLEXIBLE_UPDATE
)
listeningFuture.addOnSuccessListener {
appUpdateManager.registerListener(flexibleUpdateCallback)
}
listeningFuture.addListener({
appUpdateManager.unregisterListener(flexibleUpdateCallback)
}, ContextCompat.getMainExecutor(this))
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == RC_FLEXIBLE_UPDATE) {
if (resultCode != RESULT_OK) {
Log.w("MainActivity", "Flexible update flow failed: $resultCode")
}
}
}
override fun onDestroy() {
super.onDestroy()
appUpdateManager.unregisterListener(flexibleUpdateCallback)
}
companion object {
private const val RC_FLEXIBLE_UPDATE = 1001
}
}
The key design decision here is registering and unregistering the StateUpdateListener inside onResume rather than in onCreate. This ensures that if the user leaves the app while an update is downloading and returns later, the app re-checks the update state and receives any missing status callbacks. Notice that addOnSuccessListener is used instead of a raw callback because startUpdateFlowForResult returns a ListenableFuture in the current API. When the status reaches InstallStatus.DOWNLOADED, calling completeUpdate() triggers the restart; skipping this call leaves the update installed but unused until the next normal app launch.
Immediate In-App Updates
Immediate updates pause the user's interaction and present a full-screen update screen. They are appropriate when the update addresses a critical security issue or a blocker bug. Because the Play Store handles the entire UI, your code is simpler, but you lose control over the presentation timing.
class SettingsActivity : AppCompatActivity() {
private lateinit var appUpdateManager: AppUpdateManager
override fun onResume() {
super.onResume()
appUpdateManager = AppUpdateManagerFactory.create(this)
appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo ->
if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
&& appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
) {
appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
AppUpdateType.IMMEDIATE,
this,
RC_IMMEDIATE_UPDATE
)
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == RC_IMMEDIATE_UPDATE) {
if (resultCode == RESULT_OK) {
// Update completed; restart the app to load new code.
finishAndRemoveTask()
} else if (resultCode != RESULT_CANCELED) {
// The update flow failed unexpectedly.
Log.e("SettingsActivity", "Immediate update failed with code $resultCode")
}
}
}
override fun onPause() {
super.onPause()
// IMMEDIATE updates cannot be safely aborted mid-flow after the user dismisses the screen.
}
}
The isUpdateTypeAllowed() check is essential because Google's policy restricts immediate updates to specific scenarios, primarily security fixes. If the update is not allowed as immediate, the API call will fail and return an error code. Unlike flexible updates, there is no StateUpdateListener to register—the system manages the entire flow from your activity's perspective. Calling finishAndRemoveTask() after a successful immediate update ensures the old process is fully replaced and the user doesn't return to a stale activity stack.
In-App Review Flow
The in-app review API lets you request a rating at the optimal moment—such as after a user completes a purchase or finishes a level—rather than relying on the system's infrequent review prompts. The API enforces strict limits: Google allows approximately two review requests per 365-day period per account, so you should only call it when the context is genuinely positive.
class PurchaseSuccessFragment : Fragment() {
private lateinit var reviewManager: ReviewManager
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
reviewManager = ReviewManagerFactory.create(requireContext())
val requestFlowTask = reviewManager.requestReviewFlow()
requestFlowTask.addOnCompleteListener { task ->
if (task.isSuccessful) {
reviewManager.launchReviewFlow(requireContext(), task.result)
.addOnCompleteListener {
// The dialog has been shown and dismissed; no callback indicates success or failure.
}
} else {
Log.e("PurchaseFragment", "Review flow request failed", task.exception)
}
}
}
override fun onDestroyView() {
super.onDestroyView()
// No explicit cleanup needed for ReviewManager; it holds no long-lived references.
}
}
The requestReviewFlow() method returns a Task<ReviewInfo> that you must pass directly to launchReviewFlow(). You cannot cache or reuse the ReviewInfo object across sessions—the Play Store validates it at launch time and will reject stale tokens. Importantly, launchReviewFlow() does not return a promise for when the dialog closes; it fires and forgets. This is by design, because the system may show the dialog on a different task and the exact dismissal point is unpredictable. Always wrap the call in a success path in your app so that a network failure or missing reviewer eligibility doesn't crash the purchase completion flow.
On-Demand Dynamic Feature Modules
Dynamic feature modules let you split your APK into a base module and one or more on-demand modules. The Play Store downloads the requested module only when the user interacts with the related feature, reducing the initial download size and installation footprint. The base app requests the module through SplitInstallManager, and your code waits for the installation to complete before launching the target activity.
// In your base module build.gradle
android {
defaultConfig {
// No extra configuration needed for the base module itself.
}
dynamicFeatures = [":feature_maps"]
}
// In the dynamic feature module's build.gradle
plugins {
id("com.android.dynamic-feature")
}
android {
namespace "com.example.app.feature.maps"
compileSdk 34
defaultConfig {
minSdk 21
targetSdk 34
}
dependencies {
implementation project(":app")
}
}
<!-- In the feature module's AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".MapsActivity" android:exported="false" />
</application>
</manifest>
The Gradle configuration above creates a dynamic feature named feature_maps that depends on the base app module. The com.android.dynamic-feature plugin generates the necessary manifest entries and build tasks so the base app can request installation at runtime. In the base module, you would request the feature like this:
class MapsButtonFragment : Fragment() {
private val splitInstallManager = SplitInstallManagerFactory.create(requireContext())
fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<Button>(R.id.btnOpenMaps).setOnClickListener {
val request = SplitInstallRequest.newBuilder()
.addModule("feature_maps")
.build()
splitInstallManager.startInstall(request)
.addOnSuccessListener { sessionId ->
// Module accepted; wait for the status callback.
}
.addOnFailureListener { e ->
Log.e("MapsFragment", "Failed to request module: ${e.message}")
}
}
}
}
class MapsButtonFragment : Fragment() {
private val splitInstallManager = SplitInstallManagerFactory.create(requireContext())
private val sessionListener = InstallStateUpdatedListener { state ->
if (state.status().sessionStatus() == InstallStatus.INSTALLED) {
splitInstallManager.unregisterListener(this)
navigateToMaps()
} else if (state.status().errorCode() != InstallStatus.StatusCodes.SUCCESS) {
Log.e("MapsFragment", "Install failed: ${state.status().errorCode()}")
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
splitInstallManager.registerListener(sessionListener)
view.findViewById<Button>(R.id.btnOpenMaps).setOnClickListener {
val request = SplitInstallRequest.newBuilder()
.addModule("feature_maps")
.build()
splitInstallManager.startInstall(request)
}
}
private fun navigateToMaps() {
val intent = Intent(requireContext(), MapsActivity::class.java)
startActivity(intent)
}
override fun onDestroyView() {
super.onDestroyView()
splitInstallManager.unregisterListener(sessionListener)
}
}
The listener pattern here is important because startInstall() returns a session ID but does not block. The InstallStateUpdatedListener receives callbacks as the module downloads and installs, and you can inspect the InstallStatus at each stage. You must unregister the listener in onDestroyView() to prevent memory leaks and spurious navigation attempts after the fragment is gone. The module name "feature_maps" must match exactly what is declared in the dynamicFeatures list of the base module's build.gradle.
Checking Update Availability Efficiently
Calling appUpdateManager.appUpdateInfo triggers a network request to Google Play, which can be slow or fail when the user has no connectivity. A best practice is to cache the result and only refresh when the app resumes or when the user explicitly checks for updates. The UpdateAvailability enum provides three states: UPDATE_AVAILABLE, UPDATE_NOT_AVAILABLE, and UNKNOWN. The UNKNOWN state typically means the Play Store connection failed, so you should treat it the same as UPDATE_NOT_AVAILABLE for most flows.
class UpdateChecker {
suspend fun checkUpdateInfo(
context: Context,
maxCacheAgeMs: Long = 5 * 60 * 1000L
): Pair<AppUpdateInfo, Long> {
val appUpdateManager = AppUpdateManagerFactory.create(context)
val future = appUpdateManager.appUpdateInfo
val appUpdateInfo = withContext(Dispatchers.IO) {
Futures.getDone(future)
}
return appUpdateInfo to SystemClock.elapsedRealtime()
}
}
This helper isolates the async call from the main thread and returns both the AppUpdateInfo and the timestamp of when it was fetched. By wrapping the ListenableFuture with Futures.getDone() inside a withContext(Dispatchers.IO) coroutine, you avoid blocking the UI thread while keeping the code concise. The caller can compare the timestamp against the current time and decide whether to use the cached value or fetch a fresh one, depending on whether a previous call was made within maxCacheAgeMs.
Common Pitfalls
One of the most frequent mistakes with in-app updates is calling startUpdateFlowForResult without handling the result in onActivityResult. If the user dismisses the update dialog, the activity receives RESULT_CANCELED, and ignoring it leaves the app in an unknown state where the update was requested but never tracked. Always branch on the result code and log failures. Another common issue is forgetting to call completeUpdate() after a flexible update finishes downloading; the update is installed on disk but never activated, so the user remains on the old version indefinitely.
With the review API, developers often try to show the dialog immediately after every positive interaction. Google enforces a rate limit of two requests per 365-day period per user account, and excess requests are silently ignored—the dialog simply does not appear. Instead, track whether a request has already been made during the current eligibility window and only prompt once per cycle. For dynamic features, a subtle pitfall is mismatching the module name string between the Gradle configuration and the SplitInstallRequest. The name must exactly match the <dist:module> name in the base manifest, and any typo causes an immediate install failure with no network error, because the Play Store treats the request as a reference to a nonexistent module.
Conclusion
Google Play Core gives you a unified toolkit for keeping apps current, gathering feedback, and shrinking initial download sizes. Flexible updates balance user experience with timely patching, immediate updates provide a hard gate for critical fixes, the review API turns happy moments into ratings, and dynamic feature modules let you ship a smaller base APK while still offering rich functionality on demand. The common thread across all three features is careful lifecycle management: register listeners when the UI becomes visible, unregister them when it disappears, and always handle the async callbacks because the Play Store operates on its own schedule, not yours.