Dealing with Android’s peculiar bugs as an app developer

When you use external dependencies in your apps, you expect them to work correctly. But it’s not always the case. Even with such seemingly well-maintained and tested external dependencies as Android itself.
I maintain an open-source library which abstracts away PackageInstaller API. For you to understand what comes next, I should explain how to work with PackageInstaller first. My previous article already covers it, but I’ll provide a quick recap here.
How PackageInstaller works
PackageInstaller is an API which allows to install APK files. It’s not so important here to know how exactly it should be used, but the key points are:
- First, we create an install Session from the PackageInstaller.
- Then we write our APK files into it.
- Finally, we commit the session. To receive session status updates, we need to provide an IntentSender to which install events will be sent by the system.
I will focus on the last step a bit. We can create a BroadcastReceiver which will handle install events, such as launching a confirmation window for a user, or notifying about final success or failure. Then we create an IntentSender for it, which we can use to commit the session.
A BroadcastReceiver can look like this:
class PackageInstallerStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// Here we get an Intent for an Activity containing
// install confirmation window and start it.
val confirmationIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirmationIntent != null) {
context.startActivity(confirmationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
PackageInstaller.STATUS_SUCCESS -> {
// Notify on success.
}
else -> {
// Notify on failure.
}
}
}
}
So, it seems there should be no room for unexpected behavior, right? We just handle events from OS and call it a day. Well…
Inconsistency and fragmentation
One day I received the following issue report:

On Android 8+, Google added an explicit install permission for apps. So, when a user tries to install an app from another third-party app for the first time, Android redirects them to a special settings screen where they must allow installs for that particular installer app. Sounds reasonable.
However, I couldn’t reproduce the above issue on my devices, both physical and virtual. After further discussion, it turned out that the reporter encounters the issue on a set-top box. I’ve gone to test the library on an Android SDK emulator with Android TV 9 image, and voilà! It was exactly how they described it.
This is how it looks:

Typically, Android returns you to an install confirmation window after you granted install permission. But on Android TV, this confirmation doesn’t reappear. Fragmentation yet again.
Bugs in Android
Let’s move a bit to another topic before I tell you how I fixed the issue.
In reality, as you could’ve guessed already, BroadcastReceiver alone is not sufficient for reliable session status reports. For example, with some broken APK files, there would be a “There was a problem parsing the package” error on some Android versions. And this error won’t be reported to the IntentSender we provide when we commit a session!

How can we get out of this situation?
Wrapper Activity to the rescue
I solved it by starting my own Activity when user’s confirmation is required, and I pass it the confirmation Intent that I got in BroadcastReceiver. Then, in my Activity, I start this Intent and wait for result. I examine PackageInstaller.Session’s state directly and act based on that. This can be done with startActivityForResult() method in Activity.
As a side note, you may ask, why don’t I use Activity Result API from Jetpack? Well, because I specifically avoid androidx.activity dependency in the library and because I need to also finish started activity with request code (using Activity#finishActivity(int) method), and this is not possible with Activity Result API.
So, instead of how it was in the first section, now it looks like this in BroadcastReceiver:
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val confirmationIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
val wrapperIntent = Intent(context, InstallConfirmationActivity::class.java)
.putExtra(Intent.EXTRA_INTENT, confirmationIntent)
.putExtra(PackageInstaller.EXTRA_SESSION_ID, sessionId)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(wrapperIntent)
}
// ...
}
And in the newly created InstallConfirmationActivity we have this:
class InstallConfirmationActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
launchInstallActivity()
}
}
private fun launchInstallActivity() {
val extras = intent.extras ?: return
val confirmationIntent = extras.getParcelable<Intent>(Intent.EXTRA_INTENT)
if (confirmationIntent != null) {
startActivityForResult(confirmationIntent, requestCode)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
// How to handle session's state here?
}
}
How do we handle result when we return from the system’s confirmation Activity?
There’s a workaround. We can query session’s progress, and if it did change, that means the installation continues normally. Otherwise, if we didn’t get result in our BroadcastReceiver, something went wrong.
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
val extras = intent.extras ?: return
val sessionId = extras.getInt(PackageInstaller.EXTRA_SESSION_ID)
val sessionInfo = packageManager.packageInstaller.getSessionInfo(sessionId)
// Hacky workaround: progress not going higher after commit means session is dead.
val isSessionAlive = sessionInfo != null && sessionInfo.progress >= getProgressThresholdValue()
if (!isSessionAlive) {
handler.postDelayed(
Runnable {
// Here we notify that session failed ("Session {id} is dead").
// Why delay? We can’t be sure if BroadcastReceiver really
// won’t promptly deliver some result.
},
1000
)
} else {
// Everything went OK. Probably.
finish()
}
}
It worked great. But this solution didn’t take into account some other edge cases, which are in fact bugs in different Android versions. And one of these cases got reported in that GitHub comment.
Workarounds journey
Let me remind you what the initial issue was. We don’t get a confirmation window after user grants install permission. It’s not guaranteed that this issue presents itself only on Android TV, we should think of it as a general possibility. So let’s somehow check whether the confirmation was or wasn’t present.
How do we approach this? Well, if user didn’t confirm installation due to confirmation not appearing, that means install session’s progress didn’t change!
val isSessionStuck = sessionInfo != null && sessionInfo.progress < getProgressThresholdValue()
If our app doesn’t have install permission, we can just cancel the session here.
if (!canInstallPackages) abortSession("Install permission denied")
We also need to distinguish whether install permission request took place or not, because confirmation doesn’t appear only if user went to permission settings. We can check whether the install permission status changed after returning. If it changed, that means this request actually took place. It doesn’t matter if permission was denied, because we handled it earlier.
// canInstallPackages is a field in our Activity
val previousCanInstallPackagesValue = canInstallPackages
canInstallPackages = checkCanInstallPackages()
val isInstallPermissionStatusChanged = previousCanInstallPackagesValue != canInstallPackages
And with this, we can finally relaunch confirmation:
if (isSessionStuck && isInstallPermissionStatusChanged) {
launchInstallActivity()
return
}
Nice, now it works correctly in this case!
However, while investigating the issue, I actually opened a whole can of worms. It turns out Android’s confirmation window is more bugged than I thought!
Cursed dialog
If we dismiss the confirmation via clicking outside a dialog instead of clicking Cancel, install session becomes stuck, and BroadcastReceiver doesn’t receive failure status!

This bug got fixed only on Android 14, so we have to work around it as well.
This is quite easy. If the previous check didn’t succeed (which is when we got install permission and showed confirmation window), we check if the session was not stuck. If it progressed, we just finish our Activity:
val isSessionAlive = sessionInfo != null
if (isSessionAlive && !isSessionStuck) {
finish()
return
}
But if it got stuck, it means that confirmation was dismissed.
// Though confirmation Activity usually always returns RESULT_CANCELED
// value, on some Android versions resultCode is not equal to RESULT_CANCELED
// if Activity was finished normally via Cancel or Install buttons.
val isActivityCancelled = resultCode == RESULT_CANCELED
if (isSessionAlive && isActivityCancelled) {
abortSession()
return
}
There are two other issues related to this.
The first one is the most interesting. Remember that on some devices confirmation didn’t appear after we grant permission? Our fix for that issue turns into a problem on devices where it does appear, because in this case we will show confirmation twice! (Again, only if we dismiss the dialog by clicking outside of it).
That happens because our initial fix doesn’t check if there is no confirmation Activity actually showing, but only if we possibly need to launch it. Improving our solution is a bit tricky, because we also have to account for process death, as on Android 11 changing install permission state always kills the process.
Let’s remember Activity’s lifecycle. onStart() is called when Activity becomes visible, and onResume() is called when Activity gets focus to interact with user. Now we’ll see some logs with this in mind.
First, let’s look at lifecycle callbacks order in cases when confirmation Activity does reappear after permission request.

After each lifecycle method call you can see a class name of Activity which was on top at that time.
- First, our Activity is created, started and resumed.
- Then it goes to background (indicated by onStop) as ManageAppExternalSourcesActivity comes to top. ManageAppExternalSourcesActivity is the install permission settings screen.
- On Android 11, as I’ve already mentioned, process gets killed when install permission status changes, and when we go back, PackageInstallerActivity is displayed first. PackageInstallerActivity contains install confirmation dialog.
- While our Activity becomes visible, i.e., is started (because PackageInstallerActivity has transparent background), we see that PackageInstallerActivity is actually on top of activity stack and is visible above ours.
- Then we get result in onActivityResult().
Let’s compare this with other logs.

On Android 10 we don’t observe process death, otherwise the order is roughly the same as it was on Android 11. When we go back from permission settings, our Activity becomes visible, and PackageInstallerActivity is visible on top.

On Android 9 and earlier versions, install confirmation is full-screen, not a dialog, so our Activity is not restarted until we confirm installation and go back. But we don’t need to check if our Activity is on top when started (it will always be anyway), because install session is going to be cancelled correctly via clicking Cancel button or going back. Therefore, our initial check (isSessionStuck && isInstallPermissionStatusChanged) is still necessary to avoid relaunching confirmation on correctly working Android versions.
Now let’s see logs when confirmation Activity doesn’t reappear.

- Our Activity launches confirmation, and then PackageInstallerActivity redirects to install permission settings.
- After granting permission and returning, we get a result in onActivityResult(), and our Activity gets started and resumed again.
As we can see, when our Activity becomes visible again, i.e., started, there are no other activities above it. It’s the same as on generic Android 9, but this time the check for stuck session succeeds. Compare this with earlier logs (Android 10+), where PackageInstallerActivity is displayed on top at this moment.
So, we can conclude that in order to determine whether confirmation Activity needs to be relaunched, we also need to remember if our Activity was on top when it was started. If it was, then we check whether session is stuck and permission status has changed, and only then we start confirmation again from onActivityResult(). Otherwise, if there was another Activity in foreground, we just handle our stuff in onActivityResult() and we’re done!
In code, it may look like this:
private var wasOnTopOnStart = false
override fun onStart() {
super.onStart()
wasOnTopOnStart = isOnTop()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
// ...
if (isSessionStuck && isInstallPermissionStatusChanged && wasOnTopOnStart) {
launchInstallActivity()
return
}
// ...
}
Now it’s time to fix the last issue.
Since Android 12, a confirmation window appears immediately after granting install permission, not when returning from the settings screen.

On Android 12–13 our workarounds won’t do, because if we dismiss the dialog by clicking outside of it, we don’t receive result in onActivityResult(), so the checks will not even execute. Another bug.
Here is a log of our Activity’s lifecycle on Android 12:

As with the previous bug, we can circumvent it by determining whether our Activity is on top of activity stack when it’s started. It would mean there’s no confirmation Activity with the dialog above ours because it was already dismissed. Note that onActivityResult() is always called before onResume(). So if our Activity was on top when started and if onActivityResult() was not called when we enter onResume(), we cancel the session:
private var isOnActivityResultCalled = false
// We shouldn't do anything if our Activity is freshly created.
private var isFirstResume = true
override fun onResume() {
super.onResume()
if (isFirstResume) {
isFirstResume = false
return
}
if (!isOnActivityResultCalled && wasOnTopOnStart) {
abortSession()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
isOnActivityResultCalled = true
// ...
}
I omit code related to state saving and restoring which is needed to handle configuration changes and process restoration correctly, but the idea is there.
Conclusion
As we saw, there may be a lot of frustrating peculiarities caused by incorrect Android behavior. I showed some examples of how you can work around non-trivial edge cases and ensure that your app handles all situations reliably.
If you need this exact functionality in your app (installing APKs), you don’t have to implement it all yourself, because I already did it in my open-source library Ackpine! I’ll be happy if you give it a try and come with feedback! You can read my article about it, or visit a website with documentation:
Thanks for reading and enjoy your coding!