DEV Community

Cover image for Painless building of an Android package installer app
Ilya Fomichev
Ilya Fomichev

Posted on • Updated on • Originally published at Medium

Painless building of an Android package installer app

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)
Enter fullscreen mode Exit fullscreen mode

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

<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 logo

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)
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Receiving installation progress updates is as simple as this:

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

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

Check out 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!

Top comments (0)