Skip to content or footer

In-App Updates in Android, Forced or Flexible

Laptop with code
Author

Oya Canlı

Date

4 September 2026

You work hard fixing bugs, building cool features and polishing your app’s design — only to find out that some users never see any of it because auto-updates are disabled on their phones. That’s frustrating, to say the least, but it can also pose an important business problem.

Why could this be an issue?

  • Security Vulnerabilities: You discover a critical security vulnerability and need every user on the latest patch as quickly as possible.
  • Backend Technical Debt: Your backend team occasionally updates database schemas or API contracts. While they try to maintain backward compatibility, supporting legacy clients over time bloats the codebase and makes it increasingly difficult to clean up deprecated endpoints.
  • Business problem: Your product manager tells you: “We’re switching from Tool X (a paid third-party service) to Tool Y by next month. After that deadline, keeping Tool X active will cost us extra money. Make sure everybody gets the update by that time”
  • App Deprecation: You are sunsetting the current application because a brand-new replacement app is ready.

Is there a solution?

Yes. Google provides an easy-to-implement In-App Updates API, which allows you to detect when an update is available and prompt users to install it. Even if users have auto-updates turned off or ignore manual checks, you can display an in-app prompt to let them update without even leaving the application.

When to implement it?

The earlier, the better! You won’t always know in advance when you’ll need this feature. And by the time a crisis hits, it’s usually too late — because users who have auto-updates disabled won’t receive the app build containing your newly added in-app update logic anyway.

That’s why I recommend implementing this feature as early as possible in your project’s lifecycle. Bring it up and advocate for it even if no one asks for it. Product managers and clients may not be aware of the In-App Updates API, or they might underestimate its importance until a high-urgency scenario arises. Thinking ahead and implementing it early will save the day later on.

Even if you maintain an existing app with many users stuck on old versions, it is still worth implementing. While you won’t see an immediate shift overnight, your updated user pool will grow steadily over time (after all, people eventually upgrade their devices or reinstall apps).

How to implement it?

Using Google’s in-app update APIs, we can detect an available update, show a native prompt, and install it seamlessly without exiting the app. We can also check for certain criteria before prompting the user and choose between a flexible or an immediate update flow.

Here is how we detect if there is an update available:

val appUpdateManager = AppUpdateManagerFactory.create(context)

// Returns an intent object that you use to check for an update.
val appUpdateInfoTask = appUpdateManager.appUpdateInfo

// Checks that the platform will allow the specified type of update.
appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
    if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
        && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
    ) {
        // Request the update:
        appUpdateManager.startUpdateFlowForResult(
            appUpdateInfo,
            AppUpdateType.FLEXIBLE,
            activity,
            AppUpdateCoordinator.IN_APP_UPDATE_REQUEST
        )
    }
}

Notice that we are passing an AppUpdateType. With a flexible update, once the user approves it, they can continue using the app as usual while the update package downloads in the background. You can even display a progress bar during this process. Once the download is complete, you simply prompt the user to complete the installation.

On the other hand, with an immediate update, the user sees a full-screen native overlay and cannot interact with the app until the update is installed and the app restarts.

Update Strategies

The snippet above is a simplified implementation from the documentation, but in practice, prompting users for an update every time they open the app leads to a poor user experience. To decide on the right approach, here are a few questions you should ask yourself (or your client):

  • Do we want to prompt the user for every available update, or only for critical ones?
  • Should updates be mandatory, or should we recommend them while leaving the choice to the user? (Or a mix of both depending on the scenario?)
  • When making a recommendation, should we show the prompt every time the app opens, or limit it to once or twice per release?

The answers will depend on your application’s specific needs. Below is a strategy we’ve used across multiple apps, along with its implementation. Feel free to adapt these strategies to fit your own requirements.

Here is the strategy we’ll follow in this example:

  • Important Updates: If an update is important and needs to reach users quickly, we recommend updating right away while still letting them opt out. If they decline, we don’t prompt them again for that specific version.
  • Standard Updates: If an update isn’t urgent, we give the system time to handle it via automatic updates. However, if the update becomes stale — meaning the user likely has auto-updates disabled — we prompt them to update. If they decline, we don’t ask again until the next release.
  • Breaking/Mandatory Updates: If there’s a breaking change and older versions will no longer work (or if a forced update is necessary for other reasons), we enforce the update. In this scenario, declining the prompt prevents the user from continuing to use the app. This approach is intrusive, so it should be used sparingly, but it’s essential to have ready when needed.

Let’s see how we can implement this and how we can decide when to show which prompt, based on the strategy above. When you detect that there is an available update using Google’s in-app update API, the appUpdateInfo object provided in the callback gives us a few parameters:

appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> 
     // how many days passed since the update became available
     val staleness = appUpdateInfo.clientVersionStalenessDays()
     // and integer that could be used to refer to importance of the update
     val updatePriority = appUpdateInfo.updatePriority()
     ...
}

appUpdateInfo.clientVersionStalenessDays() tells you how many days have passed since the update became available on the user’s device. Keep in mind that if you use staged rollouts, the update gradually reaches different subsets of users. Additionally, the Google Play Store on a user’s device doesn’t check for updates continuously in real-time, but rather at regular intervals. When Google Play detects an available update, it schedules an installation (provided auto-updates are enabled). It typically attempts the update when the device is idle, connected to Wi-Fi, and sufficiently charged — which is why rollouts take time. Therefore, if a non-urgent update was published just today, you can give the system time to handle it automatically without disturbing the user. However, if you notice the staleness is 7 days, for instance, the user likely has auto-updates turned off.

appUpdateInfo.updatePriority() is another parameter you could potentially use to decide if an update warrants a prompt. It’s an integer ranging from 1 to 5 that categorises the priority of the release. However, I have some reservations about using it. While it’s easy to read this value from the callback, setting it isn’t as straightforward. If you submit releases manually via the Google Play Console, there is no UI option to assign a priority to an update. It can only be set using the Google Play Developer API. If you use automated CI/CD pipelines to deploy to Google Play, those pipeline actions interact with the Google Play Developer API under the hood, allowing you to pass this parameter. However, if you forget to set the priority during release deployment, you cannot modify it afterwards. That’s why it is not my favourite approach, but here is how you can pass it in an Azure DevOps task, as an example, in case you would like to use it:

- task: GooglePlayRelease@4
  displayName: "Release app bundle to alpha track"
  inputs:
    serviceConnection: 'Google Play Connection'
    applicationId: 'yourApplicationId'
    action: 'SingleBundle'
    bundleFile: '**/app-prod-release.aab'
    track: 'alpha'
    changeUpdatePriority: true
    updatePriority: '${{parameters.UpdatePriority}}'

An alternative approach to flagging important releases is using a remote config parameter. In remote config, you can define a numeric parameter (let’s call itlatestCriticalUpdate) containing the version code of your latest critical release. When an update is detected, if the user's current version code is lower than latestCriticalUpdate, you know it's time to prompt them for an update. This approach offers a few key advantages over Play Store priority parameter. First, it allows you to retroactively flag a release as critical. Second, it handles version skipping gracefully. For example, if a user skips a critical release (v101) and later opens the app after a non-critical release (v102) is out, a simple priority check on the latest version would miss the fact that they are missing critical code. Comparing their installed version against latestCriticalUpdate covers this scenario completely. For these reasons, I have preferred to use remote config, instead of relying on update priority parameter.

Another detail to check before showing a prompt is whether you’ve already suggested an update for this specific release:

appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> 
     val updateAlreadySuggestedFor = usageRecordsDataStore.getUpdatePopupShownFor()
     val updateSuggestionAlreadyShown = updateAlreadySuggestedFor == appUpdateInfo.availableVersionCode()
     ...
}

In this snippet, getUpdatePopupShownFor() returns the version code of the update for which we previously displayed a prompt. Every time you present an update prompt, you should save the current version code to this field. That way, when you detect that a prompt has already been shown for the current version, you can skip showing it again.

Putting all of this together, we can refine the initial snippet from the documentation as follows:

// Checks that the platform will allow the specified type of update.
appUpdateInfoTask.addOnSccessListener { appUpdateInfo ->
    if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
        && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
    ) {
        coroutineScope.launch(coroutineDispatcher) {
             // Check if we have already suggested update to user for this update
             val updateAlreadySuggestedFor = usageRecords.getUpdatePopupShownFor()
             val updateSuggestionAlreadyShown = updateAlreadySuggestedFor == appUpdateInfo.availableVersionCode()
             when {
                updateSuggestionAlreadyShown -> setUpdateState(UpdateState.AlreadyShown)
                // If update is stale or if it is critical, show update pop up
                (appUpdateInfo.clientVersionStalenessDays() ?: 0) > STALENESS_DAY_LIMIT ||
                      remoteConfigProvider.checkIfCritical() -> {
                         // show updates and listen for progress
                         registerUpdateStateListener()
                         setUpdateState(UpdateState.ShowUpdates)
                }
                else -> setUpdateState(UpdateState.NoUpdates)
             }
        }
    }
}

Once you determine that an update prompt should be shown based on your criteria, you can launch the native update prompt. This is how we can launch the native flexible update flow:

// Request the update:
appUpdateManager.startUpdateFlowForResult(
    appUpdateInfo,
    AppUpdateType.FLEXIBLE,
    activity,
    AppUpdateCoordinator.IN_APP_UPDATE_REQUEST
)

However, in order to avoid bloating the Activity with update logic (which will grow as we add more features), I encapsulated everything within a singleton helper class called AppUpdateCoordinator. It exposes a flow named updateState that the UI observes. This structure keeps the UI clean and makes the implementation easy to reuse across different projects.

Once you start the process, you would also like to observe the progress of it. Let’s assume user approves the update. For flexible update case, the app will start to download the update package in the background. API lets us attach a listener with which we can observe the number of bytes downloaded so far. Using this, we can show a progress bar to the user. Similarly, once the download is complete, we would like to show a snackbar to prompt the user to complete the install when they are ready. In order to translate all these different states of the process and carry it to my UI, I have used this sealed class:

/**
 * An enum class for managing in-app update state
 */
sealed class UpdateState {

    /** Initial state, checking for updates */
    data object Checking : UpdateState()

    /**
     * Checked, no updates found, or no need to show it
     */
    data object NoUpdates : UpdateState()

    /**
     * Checked, updates found, should show the update pop up
     */
    data object ShowUpdates : UpdateState()

    /**
     * There is an update and currently update flow is launched
     */
    data object UpdateFlowLaunched : UpdateState()

    /**
     * Update is being downloaded. That doesn't mean it is being installed.
     * In a flexible update scenario, the update is downloaded in the background
     * while user can still interact with the app.
     * @param percentage the percentage of the download shown in a snackbar
     */
    data class Downloading(val percentage: MutableState<Float>) : UpdateState()

    /**
     * Update is downloaded and ready to be installed
     */
    data object Downloaded : UpdateState()

    /**
     * Update is being installed. User can't interact with the app during this time.
     */
    data object Installing : UpdateState()

    /**
     * When update flow is launched, user still has the option to refuse it
     * Then we fall here
     */
    data object UserCancelled : UpdateState()

    data class Error(val message: String?) : UpdateState()
}

To handle progress events, we attach this listener to AppUpdateManager and map the incoming status updates directly to our UI state:

private val updateStateListener = InstallStateUpdatedListener { installState ->
    when (installState.installStatus()) {
        InstallStatus.DOWNLOADED -> setUpdateState(UpdateState.Downloaded)
        InstallStatus.DOWNLOADING -> {
            val totalBytes = installState.totalBytesToDownload()
            val percentage = if (totalBytes > 0) {
                installState.bytesDownloaded().toFloat() / totalBytes
            } else {
                0f
            }
            updateState.value.let { currentState ->
                if (currentState is UpdateState.Downloading) {
                    currentState.percentage.value = percentage
                } else {
                    setUpdateState(
                        UpdateState.Downloading(mutableFloatStateOf(percentage))
                    )
                }
            }
        }
        InstallStatus.INSTALLED, InstallStatus.INSTALLING -> setUpdateState(UpdateState.Installing)
        InstallStatus.FAILED -> setUpdateState(UpdateState.Error("error during installation"))
        InstallStatus.CANCELED -> setUpdateState(UpdateState.UserCancelled)
        else -> return@InstallStateUpdatedListener
    }
}

On the UI side, I observe updateState via the MainViewModel attached to MainScreen. If the update state indicates that a prompt should be shown, the UI launches an activity for result. Later, if the state emits UpdateState.Downloading, it renders progress inside a snack bar. Once the state transitions to UpdateState.Downloaded, it displays a snack bar featuring an action to complete the installation.

To promote reusability across screens, I encapsulated this entire behavior into a separate Composable function:

@Composable
fun AppUpdateHandler(
    appUpdateState: UpdateState,
    snackbarHostState: SnackbarHostState,
    onUpdateCanceled: () -> Unit,
    onStartUpdate: (ManagedActivityResultLauncher<IntentSenderRequest, ActivityResult>) -> Unit
) {
    val appUpdateLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartIntentSenderForResult()
    ) { result ->
        if (result.resultCode == Activity.RESULT_CANCELED) {
            onUpdateCanceled()
        }
    }

    LaunchedEffect(appUpdateState) {
        when(appUpdateState) {
            is UpdateState.ShowUpdates -> onStartUpdate(appUpdateLauncher)
            is UpdateState.Downloading, is UpdateState.Downloaded -> {
                snackbarHostState.showSnackbar(
                    message = "", // Because I used custom snackhost with fixed texts this is irrelevant, but you can do as you wish
                    duration = SnackbarDuration.Indefinite,
                )
            }
            else -> { /* No action needed */}
        }
    }
}

// How it is used inside the MainScreen:
AppUpdateHandler(
   appUpdateState = appUpdateState,
   snackbarHostState = snackbarHostState,
   onUpdateCanceled = mainViewModel::markUpdatePopUpShown,
   onStartUpdate = mainViewModel::startFlexibleUpdateFlow
)

Once the installation is complete, the app automatically relaunches with the new version. So far, so good. But what if the user refuses the update, accepts it but never completes it, or quits the app midway through?

If the user declines a flexible update, that’s no problem — we record that the prompt was shown and that the user canceled. We then stop observing the update service and refrain from showing the prompt again until a new update is released.

However, if the user accepts the prompt and starts the download, but closes the app before it completes, we need to handle that scenario as well. In this case, the next time the app opens and checks for updates, appUpdateInfo.updateAvailability() returns UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS. We must account for this in our success listener so the download can resume or complete smoothly.

Here is the complete AppUpdateCoordinator helper class handling all of these edge cases:

/**
 * A helper class for managing in-app updates
 * We are not showing in-app updates each time or as soon as it is available.
 * If it is not an urgent update we let the user time to get the update by auto updates
 * when their device is idle. We show update recommendations only if:
 * -User's version is stale (more then 7 days passed since the update was available on play store)
 * -If the update is a critical update, then we show update recommendation right away. This is
 * managed by remote config and we use it only if we need it.
 */
@Singleton
class AppUpdateCoordinator @Inject constructor(
    private val appUpdateManager: AppUpdateManager,
    private val remoteConfigProvider: RemoteConfigProvider,
    private val usageRecords: UsageRecordsDataStore,
    @IODispatcher coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {

    private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher)

    private val appUpdateInfoTask = appUpdateManager.appUpdateInfo

    val updateState: StateFlow<UpdateState>
        field: MutableStateFlow<UpdateState> = MutableStateFlow(UpdateState.Checking)

    var suggestedUpdateVersion: Int? = null

    /**
     * Check if there are available updates using Google's In-App Update API
     * If there are, check from firebase if there are any critical updates
     */
    init {
        appUpdateInfoTask.addOnSuccessListener { appUpdateInfo ->
            when {
                appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
                        appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> {
                    coroutineScope.launch(coroutineDispatcher) {
                        // Check if we have already suggested update to user for this update
                        val updateAlreadySuggestedFor = usageRecords.getUpdatePopupShownFor()
                        val updateSuggestionAlreadyShown =
                            updateAlreadySuggestedFor == appUpdateInfo.availableVersionCode()
                        when {
                            updateSuggestionAlreadyShown -> setUpdateState(UpdateState.NoUpdates)
                            // If update is a critical update or stale, show update pop up
                            (appUpdateInfo.clientVersionStalenessDays()
                                ?: 0) > STALENESS_DAY_LIMIT ||
                                    remoteConfigProvider.checkIfCritical() -> {
                                registerUpdateStateListener()
                                setUpdateState(UpdateState.ShowUpdates)
                            }
                            else -> setUpdateState(UpdateState.NoUpdates)
                        }
                    }
                }
                // If user quit the app during download, this is the state we get next time it is opened
                appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> {
                    // set the current state and set the listener to listen for state updates
                    when (appUpdateInfo.installStatus()) {
                        InstallStatus.DOWNLOADED -> setUpdateState(UpdateState.Downloaded)
                        InstallStatus.DOWNLOADING -> {
                            val totalBytes = appUpdateInfo.totalBytesToDownload()
                            val percentage = if (totalBytes > 0) {
                                appUpdateInfo.bytesDownloaded().toFloat() / totalBytes
                            } else {
                                0f
                            }
                            updateState.value.let { currentState ->
                                if (currentState is UpdateState.Downloading) {
                                    currentState.percentage.value = percentage
                                } else {
                                    setUpdateState(
                                        UpdateState.Downloading(mutableFloatStateOf(percentage))
                                    )
                                }
                            }
                        }
                        InstallStatus.INSTALLED, InstallStatus.INSTALLING -> setUpdateState(UpdateState.Installing)
                        else -> setUpdateState(UpdateState.ShowUpdates)
                    }
                    registerUpdateStateListener()
                }

                else -> setUpdateState(UpdateState.NoUpdates)
            }
        }

        appUpdateInfoTask.addOnFailureListener {
            Timber.e("update info task failed")
            setUpdateState(UpdateState.Error(it.message))
        }
    }

    private val updateStateListener = InstallStateUpdatedListener { installState ->
        when (installState.installStatus()) {
            InstallStatus.DOWNLOADED -> setUpdateState(UpdateState.Downloaded)
            InstallStatus.DOWNLOADING -> {
                val totalBytes = installState.totalBytesToDownload()
                val percentage = if (totalBytes > 0) {
                    installState.bytesDownloaded().toFloat() / totalBytes
                } else {
                    0f
                }
                updateState.value.let { currentState ->
                    if (currentState is UpdateState.Downloading) {
                        currentState.percentage.value = percentage
                    } else {
                        setUpdateState(
                            UpdateState.Downloading(mutableFloatStateOf(percentage))
                        )
                    }
                }
            }
            InstallStatus.INSTALLED, InstallStatus.INSTALLING -> setUpdateState(UpdateState.Installing)
            InstallStatus.FAILED -> setUpdateState(UpdateState.Error("error during installation"))
            InstallStatus.CANCELED -> setUpdateState(UpdateState.UserCancelled)
            else -> return@InstallStateUpdatedListener
        }
    }

    fun setUpdateState(newState: UpdateState) {
        when (newState) {
            is UpdateState.Installing, UpdateState.UserCancelled,
            is UpdateState.Error, UpdateState.NoUpdates -> unregisterUpdateStateListener()
            else -> {}
        }
        updateState.value = newState
    }

    fun completeInstallation() {
        appUpdateManager.completeUpdate()
    }

    fun startFlexibleUpdateFlow(activityResultLauncher: ActivityResultLauncher<IntentSenderRequest>) {
        // We recommend user to update their app without leaving the app
        // In flexible update flow user can continue using the app while the update is downloaded
        // Once the download is complete, user will be prompted to install the update by a snack
        appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo ->
            appUpdateManager.startUpdateFlowForResult(
                appUpdateInfo,
                activityResultLauncher,
                AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE)
                    .setAllowAssetPackDeletion(true)
                    .build()
            )
            suggestedUpdateVersion = appUpdateInfo.availableVersionCode()
            setUpdateState(UpdateState.UpdateFlowLaunched)
        }
    }

    suspend fun isNoMoreSupported() = remoteConfigProvider.isNoMoreSupported()

    fun startImmediateUpdateFlow(context: Context) {
        // In immediate update flow, user will be prompted to update the app immediately
        // User can't use the app until the update is completed
        // We use this for forced updates
        appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo ->
            if (
                (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
                        appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
                        ) ||
                // If user quit the app during download, this is the state we get next time it is opened
                appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
            ) {
                appUpdateManager.startUpdateFlowForResult(
                    appUpdateInfo,
                    AppUpdateType.IMMEDIATE,
                    context as Activity,
                    AppUpdateCoordinator.IN_APP_UPDATE_REQUEST,
                )
            } else {
                // The trigger to show force
                // ys is coming from remote config
                // We can expect that the update is available by that time for the user
                // But if that is not the case for some reason, we can't really start update flow
                // So this is a fallback to open google play store
                openAppInGooglePlay(context)
            }
        }.addOnFailureListener {
            Timber.e(it, "Immediate update flow failed")
        }
    }

    private fun registerUpdateStateListener() {
        appUpdateManager.registerListener(updateStateListener)
    }

    private fun unregisterUpdateStateListener() {
        appUpdateManager.unregisterListener(updateStateListener)
    }

    private fun openAppInGooglePlay(context: Context) {
        try {
            // try to open link in Google Play app
            val uriString =
                "https://play.google.com/store/apps/details?id=yourUniquePackageId"
            context.startActivity(Intent(Intent.ACTION_VIEW, uriString.toUri()))
        } catch (ex: ActivityNotFoundException) {
            Timber.e(ex, "activity not found to open play store link")
        }
    }

    companion object {
        const val IN_APP_UPDATE_REQUEST = 73120 // a random number

        private const val STALENESS_DAY_LIMIT = 7 
    }
}

And to complete the picture, here are the helper functions I have in the viewModel:

// In-App Updates
val updateState = appUpdateCoordinator.updateState

fun setUpdateState(newState: UpdateState) {
    appUpdateCoordinator.setUpdateState(newState)
}

/**
 * This will install the update and relaunch the app after install
 */
fun completeInstallation() {
    appUpdateCoordinator.completeInstallation()
}

/**
 * We show the update popup only once per app version
 */
fun markUpdatePopUpShown() {
    setUpdateState(UpdateState.UserCancelled)
    appUpdateCoordinator.setUpdatePopupShownFor()
}

fun startFlexibleUpdateFlow(activityResultLauncher: ActivityResultLauncher<IntentSenderRequest>) {
    appUpdateCoordinator.startFlexibleUpdateFlow(activityResultLauncher)
}

fun startImmediateUpdateFlow(context: Context) {
    appUpdateCoordinator.startImmediateUpdateFlow(context)
}

Forced Updates

Once you implement the flexible update strategy described above, you will find that most of your users stay relatively up-to-date, leaving far fewer people stuck on legacy releases. However, because optional updates still leave the final decision to the user, some will inevitably stay behind — and eventually, you may need to drop support for older versions altogether.

To enforce an update, we need to:

  • Detect when an update must be mandatory.
  • Display an overlay that prevents the user from continuing to use the app until they update.

Once again, I recommend using remote config to determine when to require an update. By defining a numeric minSupportedVersion parameter in remote config, you can compare it against the app's currentVersion. If currentVersion falls below minSupportedVersion, the app presents the forced update overlay.

The design of ForceUpdateOverlay is up to you, but it should be full-screen, placed preferably at the root screen of your navigation, and contain a button that launches the native immediate update flow. Ideally, you should also briefly explain to the user why they are seeing this overlay (e.g., they need to update for security improvements or deprecated API support).

Most importantly, this overlay must be non-dismissible. For this use case, I don’t recommend using flexible updates since the user cannot use the app in the meantime anyway. Launching the immediate update flow directly provides a much cleaner experience. You can find the relevant implementation code within the AppUpdateCoordinator class above.

How to Test?
Testing is often the biggest pain point of implementing in-app updates. Normally, the In-App Updates API only functions with live updates published on the Play Store. However, you can test the full flow using internal app sharing of Google Play Store in the Google Play Console.

If you haven’t used it before, you can find this under the Testing menu in the left pane of the Play Console. Note that Internal Testing and Internal App Sharing are two different features — we are specifically using Internal App Sharing here. It provides a convenient way to upload an APK or AAB for quick testing without waiting for Play Store reviews or worrying about production signing configurations.

Testing force update scenario:
Here is how to test the forced update flow step-by-step:

  1. Build and upload the outdated version: Set your app’s version code to a value lower than minSupportedVersion. Generate an APK or AAB, upload it to Internal App Sharing, and copy the generated link.
  2. Build and upload the updated version: Bump the version code to a value higher than minSupportedVersion. Generate a new APK/AAB, upload it to Internal App Sharing, and copy this second link.
  3. Install the older version: On your test Android device, open the first link. This redirects you to the Google Play Store page for the older build. Install it. When you launch the app, you will see your forced update overlay, but do not click update yet — simply close the app.
  4. Register the new update: Open the second link on your device. The Play Store will show that an update is available. Do not install it from the Play Store (we want to test the in-app update experience).
  5. Verify and trigger the update: Reopen the installed older app. The forced update overlay should still be visible. Tap the update button, and it should trigger the native immediate update flow seamlessly.

Testing flexible update scenario:
Testing flexible updates follows a similar pattern:

  1. Build and upload the baseline build: Generate an APK or AAB with a version code lower than lastCriticalUpdate (but higher than minSupportedVersion). Upload it to Internal App Sharing and copy the link.
  2. Build and upload the target release: Increase the version code so it is greater than or equal to lastCriticalUpdate. Generate a new build, upload it to Internal App Sharing, and copy this second link.
  3. Install the baseline version: On your test device, open the first link and install the app from the Play Store. Launch it once to verify that no update prompt appears, then close it.
  4. Register the new update: Open the second link on your device so the Play Store detects the newer version. Do not click update in the Play Store.
  5. Test the flexible flow: Relaunch the installed app. It should now display the recommended update prompt. Upon accepting, a Snackbar will display download progress while allowing you to keep navigating the app. Once downloaded, the Snackbar will show an action to complete installation, which restarts the app into the updated build.

You can repeat this testing procedure for failure or edge-case scenarios — such as declining the prompt, or initiating the download and killing the app midway before launching it again.

If you implement both flexible and forced update strategies, you can test the entire lifecycle in one go by building three separate APKs. For example, if minSupportedVersion is set to 100 and latestCriticalUpdate is 120, you can generate three builds:

  • v90: Lower than minSupportedVersion (tests forced updates).
  • v110: Higher than minSupportedVersion, but lower than latestCriticalUpdate (tests flexible updates).
  • v120 or higher: Reaches or exceeds latestCriticalUpdate (serves as the target update build).

Another testing tip: if you are iterating over the design of your snack bars, or your progress bar, you don’t need to go over this whole testing process for each iteration. You can simulate that update state by emitting fake values, just for the sake of UI testing. For instance, you can make a temporary local set up like:

val progressPercentage = mutableFloatStateOf(0.3f)
coroutineScope.launch {
  delay(3000)
  setUpdateState(UpdateState.Downloading(progressPercentage))
  delay(2000)
  progressPercentage.value = 0.6f
  delay(2000)
  progressPercentage.value = 0.95f
  delay(1000)
  setUpdateState(UpdateState.Downloaded)
}

Testing this feature is undeniably time-consuming, as every iteration requires generating and uploading new APKs. Hopefully, the implementation patterns and helper classes provided in this article will save you time and significantly reduce your testing cycles.

Thanks for reading and happy coding!

(Thanks to my colleague Thijs Damen for reviewing)

Let’s Build Better Mobile Apps Together

Let’s discuss your mobile challenges