Play Core Library Integration
Introduction
Google Play's Play Core library gives you programmatic access to several powerful features without leaving the Android ecosystem. Instead of writing your own plumbing for feature downloads, update prompts, or installation states, you delegate those concerns to a small, officially maintained SDK. This tutorial walks you through the three most commonly adopted capabilities—dynamic feature modules, on-demand delivery, and in-app updates—and shows you how to wire them up, use them safely across your activity lifecycle, and validate that everything behaves correctly before you ship.
What the Play Core Library Is and Why It Matters
The Play Core library is a single Android dependency that exposes APIs backed by the Google Play Store. When you call into it, the underlying operations are executed by the Play Store app itself, which means your application never has to implement its own download manager, permission flow, or compatibility checks for different Android versions. The library is split into roughly four functional areas: dynamic feature delivery, in-app updates, instant apps, and a small set of utility helpers. For the purposes of this guide we focus on the first two, because they represent the bulk of the integration work most teams encounter.
Including the library is straightforward, but the real value comes from understanding its state machine semantics. Almost every call is asynchronous, and every async call surfaces callbacks or flows rather than blocking results. That design keeps your UI thread safe, but it also means your code has to handle transient states—like a download that is paused, delayed, or partially complete—explicitly rather than assuming success.
// app/build.gradle.kts
dependencies {
val playCoreVersion = "1.10.3"
implementation("com.google.android.play:core:$playCoreVersion")
testImplementation("com.google.android.play:core-testing:$playCoreVersion")
}
The dependency declaration above uses the canonical Maven group and artifact name. The core artifact brings the production APIs, while core-testing is optional but highly recommended because it provides a test device rule that allows in-app update and dynamic feature calls to run on emulators and physical devices without needing a real Play Store connection for every test. Note that both artifacts share the same version string so the testing utilities match the production APIs exactly.
Structuring Your Base App and Dynamic Feature Modules
Dynamic feature modules let you split your APK into separate packages that Play can deliver independently. The base module contains your entry point and core experience, while feature modules contain less-critical or heavier content such as onboarding screens, advanced editing tools, or ad-supported experiences. From a build perspective, each dynamic feature module declares itself as a library that is explicitly excluded from the base package.
// dynamic-feature/build.gradle
plugins {
id "com.android.feature"
id "org.jetbrains.kotlin.android"
}
android {
namespace "com.example.app.dynamicfeature"
compileSdk 34
defaultConfig {
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation project(":app")
implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.6.1"
}
This Groovy snippet defines a minimal dynamic feature module using the com.android.feature plugin, which is the correct plugin type for Play core dynamic features. Unlike a regular library module, this plugin signals to the Android Gradle plugin that the output is intended to be packaged as a split APK. The packaging block removes duplicate META-INF entries that would otherwise cause runtime conflicts when the Play store merges the base and feature splits. The dependency on the base module is intentional here; it gives the feature module access to shared interfaces and view models without pulling in the full application class.
<!-- app/src/main/AndroidManifest.xml (base module) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution"
package="com.example.app">
<dist:module dist:instant="false" dist:title="@string/title_core">
<dist:delivery>
<dist:on-demand />
</dist:delivery>
<dist:fusing dist:include="true" />
</dist:module>
<application
android:name=".CoreApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:theme="@style/Theme.App"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
The manifest fragment above registers the core feature as an on-demand module. The dist:on-demand element tells Play that this module should not be downloaded at install time, while dist:fusing dist:include="true" ensures the module's resources are merged into the base package at runtime if the device does not support dynamic delivery. The fusing flag is critical for backward compatibility; without it, older devices would crash when trying to reference classes from a module that never got installed. The manifest structure is deliberately minimal because the heavy lifting happens in code, not configuration.
Requesting and Monitoring On-Demand Module Installation
Once your dynamic feature is declared, the next step is to request installation when the user navigates to the associated screen. The Play Core library exposes DynamicFeaturesManager, which can install one or more modules and stream state updates through InstallStateUpdatedListener. In Kotlin coroutine-friendly code, you typically wrap that listener in a channel or use the convenience flow helper provided by the library.
// app/src/main/java/com/example/app/DynamicFeatureLauncher.kt
package com.example.app
import android.content.Context
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.dynamicfeatures.DynamicFeaturesManager
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.InstallStatus
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class DynamicFeatureLauncher(context: Context) {
private val manager: DynamicFeaturesManager =
DynamicFeaturesManager.getInstance(context)
private val appUpdateManager: AppUpdateManager =
AppUpdateManagerFactory.create(context)
private val _status = MutableStateFlow(DynamicFeatureStatus.Idle)
val status: StateFlow<DynamicFeatureStatus> = _status
private val listener = InstallStateUpdatedListener { state ->
if (state.installStatus() == InstallStatus.DOWNLOADED) {
_status.value = DynamicFeatureStatus.Downloaded
} else if (state.installStatus() == InstallStatus.INSTALLING) {
_status.value = DynamicFeatureStatus.Installing(state.bytesDownloaded(), state.totalBytesToDownload())
}
}
fun startInstallation(moduleName: String) {
_status.value = DynamicFeatureStatus.Installing(0L, 0L)
manager.installmanager().startInstall(
listOf(moduleName)
) { installException ->
if (installException != null) {
_status.value = DynamicFeatureStatus.Failed(installException.message.orEmpty())
}
}
appUpdateManager.registerListener(listener)
}
fun completeInstallation() {
appUpdateManager.unregisterListener(listener)
_status.value = DynamicFeatureStatus.Completed
}
sealed class DynamicFeatureStatus {
object Idle : DynamicFeatureStatus()
data class Installing(val bytesDownloaded: Long, val totalBytes: Long) : DynamicFeatureStatus()
object Downloaded : DynamicFeatureStatus()
object Completed : DynamicFeatureStatus()
data class Failed(val message: String) : DynamicFeatureStatus()
}
}
This class wraps the two Play Core managers into a single coroutine-friendly launcher. The InstallStateUpdatedListener callback fires on every state transition, so you map only the states your UI needs and discard the noise. Notice that startInstallation registers the listener immediately after triggering the install request, which prevents missing any early callbacks during asynchronous execution. The sealed class hierarchy makes the possible statuses explicit, which helps consumers avoid unhandled branches when they observe the flow.
// app/src/main/java/com/example/app/DynamicFeatureHostActivity.kt
package com.example.app
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class DynamicFeatureHostActivity : AppCompatActivity() {
private lateinit var launcher: DynamicFeatureLauncher
private lateinit var statusText: TextView
private lateinit var openButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_dynamic_feature_host)
statusText = findViewById(R.id.statusText)
openButton = findViewById(R.id.openButton)
openButton.isEnabled = false
launcher = DynamicFeatureLauncher(this)
lifecycleScope.launch {
launcher.status.collectLatest { status ->
when (status) {
is DynamicFeatureLauncher.DynamicFeatureStatus.Installing -> {
statusText.text = "Downloading ${status.bytesDownloaded} / ${status.totalBytes} bytes"
}
DynamicFeatureLauncher.DynamicFeatureStatus.Downloaded -> {
statusText.text = "Download complete"
openButton.isEnabled = true
}
DynamicFeatureLauncher.DynamicFeatureStatus.Completed -> {
statusText.text = "Feature ready"
openButton.setOnClickListener { loadFeatureModule() }
}
is DynamicFeatureLauncher.DynamicFeatureStatus.Failed -> {
statusText.text = "Failed: ${status.message}"
}
else -> {}
}
}
}
openButton.setOnClickListener { launcher.startInstallation("core") }
}
private fun loadFeatureModule() {
launcher.completeInstallation()
startActivity(Bundle().apply { })
}
override fun onDestroy() {
super.onDestroy()
launcher.completeInstallation()
}
}
The host activity binds the launcher to the UI and observes the sealed status flow. When the status reaches Downloaded, the button becomes enabled because the module is fully installed and ready to launch. The lifecycleScope.launch block ensures the collector is cancelled automatically when the activity leaves the started state, which prevents memory leaks and stops callbacks from firing into a destroyed view. The onDestroy override calls completeInstallation as a safety net, unregisters listeners, and resets the status so the next activity restart starts from a clean state rather than reusing a stale Completed value.
Implementing In-App Update Flows
In-app updates let you prompt users to upgrade when your app detects that a newer version is available. The Play Core library distinguishes between flexible updates, which run in the background and let the user continue using your app, and immediate updates, which block interaction until the install completes. Choosing the right type depends on whether the new version contains a critical bug fix or an optional enhancement.
// app/src/main/java/com/example/app/AppUpdateChecker.kt
package com.example.app
import android.app.Activity
import android.content.Context
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.appupdate.AppUpdateResult
import com.google.android.play.core.install.InstallState
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.ActivityAvailability
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AppUpdateChecker(activity: Activity) {
private val appUpdateManager: AppUpdateManager =
AppUpdateManagerFactory.create(activity.applicationContext)
private val _availability = MutableStateFlow<UpdateResult>(UpdateResult.Unknown)
val availability: StateFlow<UpdateResult> = _availability.asStateFlow()
private val stateListener = InstallStateUpdatedListener { state ->
when (state.installStatus()) {
InstallStatus.DOWNLOADED -> _availability.value = UpdateResult.Downloaded
InstallStatus.INSTALLING -> _availability.value = UpdateResult.Installing
InstallStatus.INSTALLED -> _availability.value = UpdateResult.Completed
}
}
suspend fun checkAvailability() {
val infoTask = appUpdateManager.appUpdateInfo
val info = infoTask.await()
when (info.updateAvailability()) {
UpdateAvailability.UPDATE_AVAILABLE -> {
_availability.value = UpdateResult.Available
}
UpdateAvailability.UPDATE_RUNTIME_VERSION_MISMATCH -> {
_availability.value = UpdateResult.Available
}
else -> {
_availability.value = UpdateResult.NotAvailable
}
}
}
fun startFlexibleUpdate() {
_availability.value = UpdateResult.StartingFlexible
appUpdateManager.startUpdateFlow(
AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build()
).addOnSuccessListener { _availability.value = UpdateResult.InProgress }
.addOnFailureListener { _availability.value = UpdateResult.Failed(it.message.orEmpty()) }
appUpdateManager.registerListener(stateListener)
}
fun completeUpdateIfNeeded() {
appUpdateManager.unregisterListener(stateListener)
if (_availability.value == UpdateResult.Downloaded) {
appUpdateManager.completeUpdate()
}
}
fun isUpdateFlowAvailable(): Boolean {
return ActivityAvailability.isAppUpdateAvailableForFlow(
appUpdateManager.appUpdateInfo
)
}
sealed class UpdateResult {
object Unknown : UpdateResult()
object Available : UpdateResult()
object StartingFlexible : UpdateResult()
object InProgress : UpdateResult()
object Downloaded : UpdateResult()
object Installing : UpdateResult()
object Completed : UpdateResult()
object NotAvailable : UpdateResult()
data class Failed(val message: String) : UpdateResult()
}
}
This checker encapsulates the entire update lifecycle behind a sealed result type. The checkAvailability method polls the AppUpdateInfo task to learn whether an update is pending, while startFlexibleUpdate initiates a background-friendly flow that does not interrupt the user. The listener is registered before the flow starts and unregistered inside completeUpdateIfNeeded, which guards against registering it twice if the method is called from multiple lifecycle points. Because AppUpdateManager.startUpdateFlow returns a completable future rather than a traditional callback, the code attaches success and failure listeners to translate outcomes into the sealed result.
// app/src/main/java/com/example/app/MainActivity.kt
package com.example.app
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var updateChecker: AppUpdateChecker
private lateinit var updateText: TextView
private lateinit var updateButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
updateText = findViewById(R.id.updateText)
updateButton = findViewById(R.id.updateButton)
updateButton.isEnabled = false
updateChecker = AppUpdateChecker(this)
lifecycleScope.launch {
updateChecker.availability.collect { result ->
updateButton.isEnabled = when (result) {
is AppUpdateChecker.UpdateResult.Available -> true
is AppUpdateChecker.UpdateResult.Downloaded -> true
else -> false
}
updateText.text = when (result) {
is AppUpdateChecker.UpdateResult.Available -> "Update available"
is AppUpdateChecker.UpdateResult.StartingFlexible -> "Starting update"
is AppUpdateChecker.UpdateResult.InProgress -> "Update in progress"
is AppUpdateChecker.UpdateResult.Downloaded -> "Update downloaded"
is AppUpdateChecker.UpdateResult.Completed -> "Update completed"
is AppUpdateChecker.UpdateResult.Failed -> "Update failed: ${result.message}"
else -> ""
}
}
}
updateButton.setOnClickListener {
updateChecker.startFlexibleUpdate()
}
}
override fun onResume() {
super.onResume()
lifecycleScope.launch {
updateChecker.checkAvailability()
}
}
override fun onDestroy() {
super.onDestroy()
updateChecker.completeUpdateIfNeeded()
}
}
The activity orchestrates the update checker by collecting its availability flow and driving the button state accordingly. It re-checks availability in onResume so that updates triggered from another session are detected as soon as the user returns. The onDestroy call to completeUpdateIfNeeded ensures that a downloaded update is finalized before the process is torn down, preventing the Play Store from retrying the installation on the next launch. Using lifecycleScope keeps the coroutine tied to the activity lifetime, so configuration changes do not leak or double-fire callbacks.
Configuring Build Variants and Release Signatures
Dynamic delivery and in-app updates require your app to be signed with a release key, and the Play Store enforces stricter constraints on unsigned or debug builds. During local development you can simulate these features, but the realistic end-to-end behavior only appears once your app is aligned with the signing configuration and the feature module is correctly packaged for distribution.
// dynamic-feature/build.gradle
android {
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
defaultConfig {
vectorDrawables.useSupportLibrary = true
}
}
This minimal build configuration disables shrinking for the feature module during early development to keep iteration fast, while still applying standard ProGuard rules for the release variant. The vector drawable support flag is included because dynamic feature modules sometimes bundle asset-only packages that rely on the legacy support library for backward compatibility. Keeping minifyEnabled off initially avoids confusing runtime crashes caused by missing class rules, and you can enable it later once the integration is stable.
<!-- app/src/main/res/layout/activity_dynamic_feature_host.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Ready"
android:textSize="16sp" />
<Button
android:id="@+id/openButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Open Feature" />
</LinearLayout>
The layout fragment above provides a simple host screen that displays install status and a button the user can tap to trigger the dynamic feature request. It intentionally avoids any complex nesting because dynamic delivery failures are easier to diagnose when the UI tree is shallow. The statusText view is bound directly in the activity, and the button is disabled until the underlying flow reports a stable terminal state. This pattern keeps the relationship between UI state and Play Core state transparent and easy to unit test.
Testing Integration Locally and in CI
Testing Play Core features locally is nontrivial because many APIs require a real Play Store environment. However, the play-core-testing artifact provides PlayCoreTestDeviceRule, which intercepts Play Store calls and simulates realistic responses. You can combine this rule with JUnit 4 tests to verify installation flows, update prompts, and error handling without publishing to the Play Console.
// app/src/test/java/com/example/app/AppUpdateCheckerTest.kt
package com.example.app
import com.google.android.play.core.testing.PlayCoreTestDeviceRule
import org.junit.Rule
import org.junit.Test
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlin.test.assertEquals
class AppUpdateCheckerTest {
@get:Rule
val playCoreTestDeviceRule = PlayCoreTestDeviceRule()
@Test
fun `checking availability returns not available when no update exists`() = runTest {
val checker = AppUpdateChecker(TestActivity())
checker.checkAvailability()
assertEquals(
AppUpdateChecker.UpdateResult.NotAvailable,
checker.availability.value
)
}
}
This test class demonstrates the simplest verification pattern: create a checker with a lightweight test activity and assert that the availability flow returns the expected initial state. The PlayCoreTestDeviceRule replaces the real Play Store backend with a mock, which means the test runs in seconds and does not require network access. Using runTest keeps the coroutine timing deterministic, which is essential because Play Core callbacks can arrive on background threads.
// app/src/test/java/com/example/app/DynamicFeatureLauncherTest.kt
package com.example.app
import com.google.android.play.core.testing.PlayCoreTestDeviceRule
import org.junit.Rule
import org.junit.Test
import kotlinx.coroutines.test.runTest
import kotlin.test.assertEquals
class DynamicFeatureLauncherTest {
@get:Rule
val playCoreTestDeviceRule = PlayCoreTestDeviceRule()
@Test
fun `starting installation transitions to downloading state`() = runTest {
val launcher = DynamicFeatureLauncher(TestContext())
launcher.startInstallation("core")
assertEquals(
DynamicFeatureLauncher.DynamicFeatureStatus.Installing(0L, 0L),
launcher.status.value
)
}
}
The second test verifies that requesting a dynamic module installation immediately flips the status flow into an installing state. Because the test device rule intercepts the actual Play Store call, the transition happens synchronously within the mocked environment, allowing you to assert the exact state object including the byte counters. In a real CI pipeline, you would pair this with an instrumentation test that exercises the activity flow end-to-end, but unit tests like these are valuable for guarding against regressions in your state mapping logic.
Handling Edge Cases and Lifecycle Boundaries
Even well-written integrations fail when lifecycle events are ignored. An update that is downloaded while the activity is stopped will remain pending until the app returns to the foreground, and a dynamic module install that completes while the launcher activity is destroyed will never call completeUpdateIfNeeded. The library intentionally does not auto-complete installs because it cannot guess your business logic, so you must orchestrate completion explicitly.
// app/src/main/java/com/example/app/LifecycleAwareUpdateHelper.kt
package com.example.app
import android.app.Application
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
class LifecycleAwareUpdateHelper(
private val application: Application,
private val checker: AppUpdateChecker
) : LifecycleEventObserver {
init {
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when (event) {
Lifecycle.Event.ON_START -> {
// Check for updates when the app becomes visible
}
Lifecycle.Event.ON_STOP -> {
// No-op; do not complete installs in background
}
else -> {}
}
}
}
This helper attaches to the app-level lifecycle so it can react when the entire application moves to the foreground or background. The current skeleton intentionally leaves the ON_START branch empty to show where you would insert a re-check call. In production code you would call checker.checkAvailability() here and potentially prompt the user again if a previous download is still pending. The ProcessLifecycleOwner approach is preferable to per-activity listeners when you want a single centralized policy rather than scattering update checks across multiple screens.
Common Pitfalls
One of the most frequent mistakes is assuming that startUpdateFlow completes synchronously. It returns a future that represents the user's interaction, and if you treat it as finished the moment you call it, you will miss the download-complete callback and fail to call completeUpdate(). Another common issue is unregistering the install listener too early; if you unregister before the app exits the foreground, you may drop the DOWNLOADED callback and leave the update hanging indefinitely. A third pitfall is neglecting the dist:fusing flag in the base manifest, which causes crashes on devices that do not support dynamic delivery because the resource merger never merges the feature module into the base package. Finally, developers often forget to sign their debug builds with a release-compatible key before testing in-app updates on physical devices, which causes the Play Store to reject the update request silently.
Conclusion
Integrating the Play Core library requires careful attention to asynchronous state machines, lifecycle boundaries, and build-time module configuration. By separating dynamic feature requests from update flows, wrapping both in sealed result types, and validating behavior with the official testing artifact, you build a resilient foundation that scales from local emulator checks to production Play Store distribution. The patterns shown here—observer-driven status flows, explicit listener registration and unregistration, and deferred update completion—are repeatable across all Play Core features. Once these pieces are in place, adding new feature modules or tightening your update cadence becomes a matter of configuration rather than architecture.