Logo
Android In-App Updates: Get Users Off That Buggy Old Version

Android In-App Updates: Get Users Off That Buggy Old Version

By Sagar Maiyad  Dec 09, 2025

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.

Flexible vs Immediate

Flexible: Downloads in background, user keeps using app, snackbar prompts restart when done. Use this 95% of the time.

Immediate: Full-screen takeover. User can't proceed until they update. Reserve for security issues or data corruption bugs.

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 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 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:

class UpdateManagerWrapper @Inject constructor(
    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 {
        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)
                    }
                    info.installStatus() == InstallStatus.DOWNLOADED -> {
                        _installStatus.value = UpdateState.Downloaded
                    }
                    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 {
            val updateState by updateViewModel.updateState.collectAsStateWithLifecycle()

            LaunchedEffect(updateState) {
                when (updateState) {
                    is UpdateState.Downloading,
                    UpdateState.NoUpdate,
                    UpdateState.Cancelled,
                    UpdateState.Failed -> {
                        // Update check done, proceed with app
                    }
                    is UpdateState.ImmediateFailed -> {
                        finish() // User refused critical update
                    }
                    else -> Unit
                }
            }

            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() }
    val scope = rememberCoroutineScope()

    LaunchedEffect(Unit) {
        updateViewModel.eventFlow.collect { state ->
            when (state) {
                is UpdateState.Downloaded -> {
                    scope.launch {
                        val result = snackBarHostState.showSnackbar(
                            message = "An update has just been downloaded.",
                            actionLabel = "RESTART",
                            duration = SnackbarDuration.Indefinite
                        )
                        when (result) {
                            SnackbarResult.ActionPerformed -> {
                                onCompleteUpdate()
                            }
                            else -> {}
                        }
                    }
                }
                else -> Unit
            }
        }
    }

    Scaffold(snackbarHost = { SnackbarHost(snackBarHostState) }) { padding ->
        // Screen content
    }
}

SnackbarDuration.Indefinite keeps it visible until they act. Important stuff shouldn't disappear.

Testing

Can't test with debug builds - Play Store won't return update info for unknown apps. Use internal testing track: upload version 1, install it, upload version 2, check for updates.

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.

Android Play Core In-App Updates Kotlin Jetpack Compose

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android Team Lead specializing in Kotlin, Jetpack Compose, Flutter, and Node.js development. Building practical Android apps with 2M+ downloads and sharing real-world development insights.

View All Posts →

Latest Post

Latest Tags