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" }
// build.gradle.kts
implementation(libs.play.app.update.ktx)
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()
}
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
}
}
}
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()
}
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()
}
}
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
}
}
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)