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.