Painless building of an Android package installer app

Ilya Fomichev
5 min readSep 24, 2023

--

Photo by Alexander London on Unsplash

Sometimes you need to install an app on a device. Not as a user, but as a developer of another app. Maybe your app is an app store, or a file manager, or even not any of the above, but you need to self-update and you’re not published on Play Store. In any case, you will turn to Android SDK APIs which handle APK installs, and as we all know, Android APIs may often be quite cumbersome to use.

Take APK installs, for instance. If you’re unlucky and have to support Android versions below 5.0, you need to use different APIs on different versions of Android: PackageInstaller on versions since 5.0, or some kind of Intent with an install action.

Intent.ACTION_INSTALL_PACKAGE way

Intent is pretty straightforward to use. You just create it, start an Activity for result and handle the returned code. Here is how we handle an install intent using AndroidX Activity Result API:

// registering a launcher in an Activity or Fragment
val installLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val isInstallSuccessful = result.resultCode == RESULT_OK
// and then doing anything depending on the result we got
}

// launching an intent, e.g. when clicking on a button
val intent = Intent().apply {
action = Intent.ACTION_INSTALL_PACKAGE
setDataAndType(apkUri, "application/vnd.android.package-archive")
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
putExtra(Intent.EXTRA_RETURN_RESULT, true)
}
installLauncher.launch(intent)

Don’t forget to declare an install permission in AndroidManifest:

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

Cool. But quite limited (it doesn’t support split APKs and doesn’t give a reason of installation fail), not even mentioning that this action was deprecated in Android Q in favor of PackageInstaller. Also, it doesn’t support content: URIs on Android versions below 7.0, and you can’t use file: URIs on versions since 7.0 (if you don’t want to crash with FileUriExposedException). So, in order to correctly handle this on all versions, you need to convert URIs and maybe even create a temporary copy of the file depending on Android version. It becomes not as straightforward as it seemed to be.

PackageInstaller way

In Android 5.0 Google introduced PackageInstaller. It’s an API which streamlines installation process, and adds an ability to install split APKs.

PackageInstaller is a lot more robust, and allows to build a full-fledged app store or package manager app. However, with robustness comes complexity.

So, how do we approach installation with PackageInstaller?

First, we need to create a Session:

val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val packageInstaller = context.packageManager.packageInstaller
val sessionId = packageInstaller.createSession(sessionParams)
val session = packageInstaller.openSession(sessionId)

Then, we need to write our APK(s) to it:

apkUris.forEachIndexed { index, apkUri ->
context.contentResolver.openInputStream(apkUri).use { apkStream ->
requireNotNull(apkStream) { "$apkUri: InputStream was null" }
val sessionStream = session.openWrite("$index.apk", 0, -1)
sessionStream.buffered().use { bufferedSessionStream ->
apkStream.copyTo(bufferedSessionStream)
bufferedSessionStream.flush()
session.fsync(sessionStream)
}
}
}

After that, we commit the session:

val receiverIntent = Intent(context, PackageInstallerStatusReceiver::class.java)
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val receiverPendingIntent = PendingIntent.getBroadcast(context, 0, receiverIntent, flags)
session.commit(receiverPendingIntent.intentSender)
session.close()

What’s PackageInstallerStatusReceiver? It’s a BroadcastReceiver which reacts to installation events. We have to not forget to register it in AndroidManifest, as well as declare an install permission:

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<receiver
android:name=".PackageInstallerStatusReceiver"
android:exported="false" />

And here’s a sample implementation of PackageInstallerStatusReceiver:

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 handle user's install confirmation
val confirmationIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirmationIntent != null) {
context.startActivity(confirmationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
PackageInstaller.STATUS_SUCCESS -> {
// do something on success
}
else -> {
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
println("PackageInstallerStatusReceiver: status=$status, message=$message")
}
}
}
}

Quite a convoluted way to install an app, huh?

The third way

There’s another way to launch an install session, that is to use Intent.ACTION_VIEW, but I won’t cover it here, because it doesn’t provide a result of installation, and it doesn’t relate to package installation directly.

We explored different ways to install an app. But we just scratched the surface. What if we need to handle system-initiated process death? What if we want to get the exact reason why install failed? What if we want to get progress updates while an install session is active? What if we want to defer user’s install confirmation via notification? Also, can there be a simpler way to do all of this without worrying about all the details and without writing a lot of code?

Well, there is. I introduce to you the Ackpine library.

Ackpine is a library providing consistent APIs for installing and uninstalling apps on an Android device. It’s easy to use, it’s robust, and it provides everything from the above paragraph.

It supports both Java and idiomatic Kotlin with Coroutines integration out of the box.

Ackpine uses Uri as a source of APK files, which allows to plug virtually any APK source in via ContentProviders, and makes them persistable. The library itself leverages this to provide the ability to install zipped split APKs without extracting them.

See the simple example of installing an app in Kotlin with Ackpine:

try {
when (val result = PackageInstaller.getInstance(context).createSession(apkUri).await()) {
is SessionResult.Success -> println("Success")
is SessionResult.Error -> println(result.cause.message)
}
} catch (_: CancellationException) {
println("Cancelled")
} catch (exception: Exception) {
println(exception)
}

Of course, it’s a barebones sample. We need to account for process death, as well as configure the session. Well, the latter is very easy with Kotlin DSLs:

val session = PackageInstaller.getInstance(context).createSession(baseApkUri) {
apks += apkSplitsUris
confirmation = Confirmation.DEFERRED
installerType = InstallerType.SESSION_BASED
name = fileName
requireUserAction = false
notification {
title = NotificationString.resource(R.string.install_message_title)
contentText = NotificationString.resource(R.string.install_message, fileName)
icon = R.drawable.ic_install
}
}

And to handle process death you would write something like this:

savedStateHandle[SESSION_ID_KEY] = session.id

// after process restart
val id: UUID? = savedStateHandle[SESSION_ID_KEY]
if (id != null) {
val result = packageInstaller.getSession(id)?.await()
// or anything else you want to do with the session
}

Also, Ackpine gives you the utilities which make work with zipped split APKs (such as APKS, APKM and XAPK files) a breeze:

val splits = ZippedApkSplits.getApksForUri(zippedFileUri, context) // reading APKs from a zipped file
.filterCompatible(context) // filtering the most compatible splits
.throwOnInvalidSplitPackage()
val splitsList = try {
splits.toList()
} catch (exception: SplitPackageException) {
println(exception)
emptyList()
}

Receiving installation progress updates is as simple as this:

session.progress
.onEach { progress -> println("Got session's progress: $progress") }
.launchIn(someCoroutineScope)

So, give it a try and let me hear your feedback!

You can find the library’s repo on GitHub:

It contains sample projects both in Java and Kotlin.

And the project’s website with documentation is here:

Enjoy your coding!

--

--