DEV Community

Cover image for Android In-App Updates with Play Core: Complete Guide
SuriDevs
SuriDevs

Posted on • Originally published at suridevs.com

Android In-App Updates with Play Core: Complete Guide

I shipped a critical bug fix. Two weeks later, 40% of users were still on the broken version. Auto-updates off. They didn't know an update existed.

In-app updates solved this. The prompt shows inside the app — no Play Store hunting required. This guide walks through a production-ready implementation: a sealed-class state model, a StateFlow-based wrapper around Play Core's callback API, a Hilt-injected ViewModel, and the Compose snackbar that asks for the restart. Code samples are real — pulled straight out of a shipping app, not pseudo-code.

What this post covers:

  • Flexible vs immediate updates — and when to use which
  • Wiring Play Core into your Gradle build
  • Modeling the update lifecycle with a sealed class
  • A StateFlow wrapper around the Play Core callback API
  • Hilt ViewModel + Activity setup
  • The restart snackbar (Indefinite duration matters)
  • Testing without debug build limitations
  • Gotchas that cost me one-star reviews

The full Play Core in-app updates documentation covers the API surface; this post is about the architecture that makes it work in a real app — including the parts the docs gloss over.

Flexible vs Immediate

There are two in-app update flows, and the choice is yours per call (not per app). Use flexible for everything except security and data-integrity bugs.

Flexible Immediate
User experience App keeps working while update downloads in the background Full-screen blocking dialog — user can't dismiss or use the app
Download behavior Downloads in background, snackbar prompts restart when done Downloads in foreground, installs immediately
When to use ~95% of updates — feature releases, normal bug fixes, version bumps Security fixes, data corruption bugs, breaking API changes
Dismissal Returns RESULT_CANCELED — let the user keep using the app Treat as refusal of a critical update; this post calls finish()
updatePriority() range Typically priority 0–3 (server-driven) Typically priority 4–5 (server-driven)

The Play Core API exposes updatePriority() so your server can decide per-release which flow to use without an app update. If you've ever bundled an MVVM-style sealed-class state model into Compose before (see MVVM with Jetpack Compose: authentication guide), the pattern below will feel familiar — same idea, applied to the update lifecycle.

Setup

Add the Play Core KTX library — it handles the coroutines conversion for you:

// libs.versions.toml
[versions]
inAppUpdate = "2.1.0"

[libraries]
play-app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "inAppUpdate" }
Enter fullscreen mode Exit fullscreen mode
// build.gradle.kts
implementation(libs.play.app.update.ktx)
Enter fullscreen mode Exit fullscreen mode

The KTX variant is what you want — the non-KTX version exposes raw Task callbacks, which you'd just end up wrapping anyway. The official Play Core release notes list versions; 2.1.0 is current as of this writing.

The Update State

I use a sealed class for all the states. The compiler yells at you if you miss one — which is exactly what you want:

sealed class UpdateState {
    data object Idle : UpdateState()
    data object Checking : UpdateState()
    data object Downloaded : UpdateState()
    data object Installing : UpdateState()
    data object Completed : UpdateState()
    data object Failed : UpdateState()
    data object ImmediateFailed : UpdateState()
    data object Cancelled : UpdateState()
    data object NoUpdate : UpdateState()
    data object DownloadingFlexible : UpdateState()
    data class Downloading(val bytesDownloaded: Long, val totalBytes: Long) : UpdateState()
    data class UpdateAvailable(val isImmediate: Boolean, val stalenessDays: Int?, val priority: Int) : UpdateState()
}
Enter fullscreen mode Exit fullscreen mode

ImmediateFailed is separate from Failed — if the user refuses a critical update, you might close the app. General failure is just network issues.

The Update Manager Wrapper

The Play Core API is callback-based and scattered. I wrapped it in a class that exposes a single StateFlow — much easier to work with:

// NOTE: not Hilt-injectable on its own — depends on ComponentActivity,
// which Hilt does not bind by default. Instantiated manually in
// UpdateViewModel.init(activity) below.
class UpdateManagerWrapper(
    context: Context,
    private val activity: ComponentActivity
) {
    private val updateManager = AppUpdateManagerFactory.create(context)

    private val _installStatus = MutableStateFlow<UpdateState>(UpdateState.Idle)
    val installStatus: StateFlow<UpdateState> = _installStatus

    private lateinit var updateLauncher: ActivityResultLauncher<IntentSenderRequest>
    private var installStateListener: InstallStateUpdatedListener? = null
    private var listenerRegistered = false
    private var lastUpdateType: Int? = null

    init {
        // registerForActivityResult must run before the activity reaches
        // STARTED. That's why init() (and therefore this constructor) must
        // be called from MainActivity.onCreate, not onResume or later —
        // see the Activity setup section.
        registerUpdateLauncher()
    }

    private fun registerUpdateLauncher() {
        updateLauncher = activity.registerForActivityResult(
            ActivityResultContracts.StartIntentSenderForResult()
        ) { result ->
            when (result.resultCode) {
                Activity.RESULT_OK -> {
                    if (lastUpdateType == AppUpdateType.IMMEDIATE) {
                        _installStatus.value = UpdateState.Installing
                    } else if (lastUpdateType == AppUpdateType.FLEXIBLE) {
                        _installStatus.value = UpdateState.DownloadingFlexible
                    }
                }
                Activity.RESULT_CANCELED -> {
                    if (lastUpdateType == AppUpdateType.IMMEDIATE) {
                        _installStatus.value = UpdateState.ImmediateFailed
                    } else if (lastUpdateType == AppUpdateType.FLEXIBLE) {
                        _installStatus.value = UpdateState.Cancelled
                    }
                }
                ActivityResult.RESULT_IN_APP_UPDATE_FAILED -> {
                    _installStatus.value = UpdateState.Failed
                }
            }
        }
    }

    fun checkForUpdates() {
        _installStatus.value = UpdateState.Checking

        updateManager.appUpdateInfo.addOnSuccessListener { info ->
            val staleness = info.clientVersionStalenessDays()
            val priority = info.updatePriority()
            val isUpdateAvailable = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE

            if (isUpdateAvailable) {
                when {
                    info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> {
                        _installStatus.value = UpdateState.UpdateAvailable(false, staleness, priority)
                        startUpdate(info, AppUpdateType.FLEXIBLE)
                        observeFlexibleUpdates()
                    }
                    info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) -> {
                        _installStatus.value = UpdateState.UpdateAvailable(true, staleness, priority)
                        startUpdate(info, AppUpdateType.IMMEDIATE)
                    }
                    else -> {
                        _installStatus.value = UpdateState.Idle
                    }
                }
            } else {
                _installStatus.value = UpdateState.NoUpdate
            }
        }.addOnFailureListener {
            _installStatus.value = UpdateState.Failed
        }
    }

    private fun startUpdate(info: AppUpdateInfo, type: Int) {
        lastUpdateType = type
        val options = AppUpdateOptions.newBuilder(type)
            .setAllowAssetPackDeletion(true)
            .build()
        try {
            updateManager.startUpdateFlowForResult(info, updateLauncher, options)
        } catch (e: IntentSender.SendIntentException) {
            _installStatus.value = UpdateState.Failed
        } catch (e: Exception) {
            _installStatus.value = UpdateState.Failed
        }
    }

    private fun observeFlexibleUpdates() {
        if (listenerRegistered) return

        installStateListener = InstallStateUpdatedListener { state ->
            when (state.installStatus()) {
                InstallStatus.DOWNLOADED -> _installStatus.value = UpdateState.Downloaded
                InstallStatus.INSTALLING -> _installStatus.value = UpdateState.Installing
                InstallStatus.INSTALLED -> _installStatus.value = UpdateState.Completed
                InstallStatus.DOWNLOADING -> {
                    _installStatus.value = UpdateState.Downloading(
                        bytesDownloaded = state.bytesDownloaded(),
                        totalBytes = state.totalBytesToDownload()
                    )
                }
                else -> {}
            }
        }
        installStateListener?.let {
            updateManager.registerListener(it)
            listenerRegistered = true
        }
    }

    fun checkForDownloadedUpdateOnResume() {
        updateManager.appUpdateInfo.addOnSuccessListener { info ->
            if (info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
                updateManager.startUpdateFlowForResult(
                    info, updateLauncher,
                    AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build()
                )
            }
            if (info.installStatus() == InstallStatus.DOWNLOADED) {
                _installStatus.value = UpdateState.Downloaded
            }
        }
    }

    fun completeUpdate() {
        updateManager.completeUpdate()
    }

    fun unregisterListener() {
        installStateListener?.let {
            updateManager.unregisterListener(it)
            listenerRegistered = false
            installStateListener = null
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The listenerRegistered flag prevents duplicate listeners — I've seen apps register the same listener multiple times and wonder why callbacks fire three times.

ViewModel

Nothing fancy here — just connects the wrapper to Compose. SharedFlow for one-off events like "show snackbar now":

@HiltViewModel
class UpdateViewModel @Inject constructor(
    @ApplicationContext private val context: Context
) : ViewModel() {

    private var updateManagerWrapper: UpdateManagerWrapper? = null

    private val _updateState = MutableStateFlow<UpdateState>(UpdateState.Idle)
    val updateState: StateFlow<UpdateState> = _updateState

    private val _eventFlow = MutableSharedFlow<UpdateState>()
    val eventFlow = _eventFlow.asSharedFlow()

    fun init(activity: ComponentActivity) {
        if (updateManagerWrapper == null) {
            updateManagerWrapper = UpdateManagerWrapper(context, activity).also {
                observe(it)
            }
        }
    }

    private fun observe(wrapper: UpdateManagerWrapper) {
        viewModelScope.launch {
            wrapper.installStatus.collect { state ->
                _updateState.value = state
                _eventFlow.emit(state)
            }
        }
    }

    fun checkForUpdates() = updateManagerWrapper?.checkForUpdates()
    fun completeUpdate() = updateManagerWrapper?.completeUpdate()
    fun checkDownloadedOnResume() = updateManagerWrapper?.checkForDownloadedUpdateOnResume()
    fun unregisterListener() = updateManagerWrapper?.unregisterListener()
}
Enter fullscreen mode Exit fullscreen mode

Activity Setup

Call init() and checkForUpdates() early. If user refuses an immediate update, I just close the app — harsh but necessary for critical fixes:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    private val updateViewModel: UpdateViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        updateViewModel.init(this)
        updateViewModel.checkForUpdates()

        setContent {
            // Use the SharedFlow (eventFlow) for one-shot side effects like
            // finish(). StateFlow + LaunchedEffect(updateState) would re-fire
            // on every recomposition that re-emits the same state, which is
            // fine for finish() (idempotent) but a classic double-navigation
            // bug for anything else. Use the right tool from the start.
            LaunchedEffect(Unit) {
                updateViewModel.eventFlow.collect { state ->
                    if (state is UpdateState.ImmediateFailed) {
                        finish() // User refused critical update
                    }
                }
            }

            AppContent(
                updateViewModel = updateViewModel,
                onCompleteUpdate = { updateViewModel.completeUpdate() }
            )
        }
    }

    override fun onResume() {
        super.onResume()
        updateViewModel.checkDownloadedOnResume()
    }

    override fun onDestroy() {
        super.onDestroy()
        updateViewModel.unregisterListener()
    }
}
Enter fullscreen mode Exit fullscreen mode

checkDownloadedOnResume() catches downloads that finished while app was backgrounded.

Restart Snackbar

When the download finishes, show a snackbar that sticks around until they tap it:

@Composable
fun MainScreen(
    updateViewModel: UpdateViewModel,
    onCompleteUpdate: () -> Unit
) {
    val snackBarHostState = remember { SnackbarHostState() }

    LaunchedEffect(Unit) {
        updateViewModel.eventFlow.collect { state ->
            if (state is UpdateState.Downloaded) {
                // Dismiss any existing snackbar first — if Downloaded
                // fires twice (e.g., user backgrounds and returns while
                // a download was still completing), we'd otherwise queue
                // a second indefinite snackbar behind the first.
                snackBarHostState.currentSnackbarData?.dismiss()

                // Collect directly inside LaunchedEffect's scope —
                // no need for an inner scope.launch here, which would
                // detach the snackbar from the collector's lifecycle.
                val result = snackBarHostState.showSnackbar(
                    message = "An update has just been downloaded.",
                    actionLabel = "RESTART",
                    duration = SnackbarDuration.Indefinite
                )
                if (result == SnackbarResult.ActionPerformed) {
                    onCompleteUpdate()
                }
            }
        }
    }

    Scaffold(snackbarHost = { SnackbarHost(snackBarHostState) }) { padding ->
        // Screen content
    }
}
Enter fullscreen mode Exit fullscreen mode

SnackbarDuration.Indefinite keeps it visible until they act. Important stuff shouldn't disappear. If you're styling the snackbar to match Material 3 Expressive's motion language, the patterns in Google's Material 3 Expressive in Android 16 apply directly here.

Testing

Can't test with debug builds — Play Store won't return update info for unknown apps. Use Google Play's internal testing track: upload version 1 (e.g., versionCode 10), install it on the device from the test track link, then upload version 2 (versionCode 11) to the same track. When you reopen the installed version, the Play Core API returns an available update and the full flow works end-to-end. Internal testing typically propagates in 5–10 minutes — much faster than production track.

Things I learned the hard way

Users can swipe away the snackbar. Gone. You won't get another chance until they reopen the app. I re-show it when they navigate between screens now.

Don't use immediate updates for small bug fixes. I did this once and got 1-star reviews. Save it for security issues.

Track stalenessDays in your analytics. If most users are 30+ days behind, your update prompts aren't working.

In-app updates aren't magic, but they're better than hoping users randomly check the Play Store. If you're also rethinking how your app handles version-skew at the data layer, the migration patterns in Room database for Android with Kotlin are worth a read — schema changes are the other half of the "users on old versions" problem.

Frequently asked questions

What's the difference between flexible and immediate in-app updates?

Flexible updates download in the background while the user keeps using the app, then prompt for a restart via snackbar — use this 95% of the time. Immediate updates show a full-screen blocking dialog and the user can't proceed until the update installs — reserve this for security fixes or data corruption bugs where running the old version is unsafe. The choice is yours per call, not per app, so you can mix both based on update priority.

Can I test Android in-app updates with debug builds?

No. The Play Store doesn't return update info for apps it doesn't know about, so debug builds always get UpdateAvailability.UPDATE_NOT_AVAILABLE. To test, use Google Play's internal testing track: upload version 1 (e.g., versionCode 10) and install it on the device, then upload version 2 (versionCode 11) to the same track. When you reopen the installed version, the Play Core API returns an available update and the full flow works end-to-end.

What happens if a user dismisses the in-app update prompt?

For flexible updates, the launcher returns RESULT_CANCELED and you should move on silently — the user kept using the app. For immediate updates, dismissal is treated as a refusal of a critical update; in this post we map it to ImmediateFailed and close the app via finish(), but you could also fall back to a flexible update or show a recovery screen. Don't re-prompt aggressively in the same session — that's a one-star-review pattern.

How often should my Android app check for in-app updates?

Once per cold start is the sweet spot — call checkForUpdates() in MainActivity.onCreate() before showing your first screen. Avoid polling on every screen or on every onResume, because the Play Core API hits the network and the launcher result handling gets confusing if multiple checks overlap. For long-lived sessions, also call checkForDownloadedUpdateOnResume() in onResume() so you catch flexible downloads that completed while the app was backgrounded.


Originally published at suridevs.com.

Top comments (0)