Как с легкостью создать установщик пакетов Android

Иногда требуется установить приложение на устройство не как пользователю, а как разработчику другого приложения. Возможно, вашему приложению, будь то магазин приложений или файловый менеджер, требуется самообновление, а вы его не опубликовали на Play Store. В любом случае вы обратитесь к стандартизированным интерфейсам (API) Android SDK, обеспечивающим установку APK (Android Package Kit). Но, как известно, Android-интерфейсы часто оказываются довольно трудоемкими в использовании.

Возьмем, к примеру, установку APK. Если вы вынуждены поддерживать версии Android ниже 5.0, то для разных версий Android придется использовать разные API: PackageInstaller для версий от 5.0 или какой-нибудь Intent с действием установки.


Способ Intent.ACTION_INSTALL_PACKAGE

Intent довольно прост в использовании. Достаточно создать его, запустить Activity для получения результата и обработать возвращенный код. Вот как обрабатывается установочный intent с помощью API AndroidX Activity Result:

// регистрация лаунчера в Activity или Fragment
val installLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val isInstallSuccessful = result.resultCode == RESULT_OK
// затем проводятся действия в зависимости от полученного результата
}

// запуск intent, например, при нажатии на кнопку
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)

Не забудьте объявить разрешение на установку в AndroidManifest:

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

Все просто, но и ограничений достаточно (нет поддержки разделенных APK и указания причины неудачной установки), не говоря уже о том, что это действие устарело в Android Q и было заменено в пользу PackageInstaller. Кроме того, не обеспечивается поддержка content: URI на версиях Android ниже 7.0, а также нельзя использовать file: URI на версиях от 7.0 (иначе наступит аварийное завершение FileUriExposedException). Таким образом, для корректной работы на всех версиях необходимо преобразовывать URI и, возможно, даже создавать временную копию файла в зависимости от версии Android.


Способ PackageInstaller

В Android 5.0 компания Google представила PackageInstaller. Это API, который упрощает процесс установки и добавляет возможность установки разделенных APK.

PackageInstaller гораздо более надежен и позволяет создать полноценный магазин приложений или менеджер пакетов. Однако вместе с надежностью приходит и сложность.

Как выполнить установку с помощью PackageInstaller? Сначала необходимо создать сессию:

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

Затем необходимо записать в нее используемые APK:

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)
}
}
}

После этого нужно подтвердить изменения в сессии:

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()

Что такое PackageInstallerStatusReceiver? Это BroadcastReceiver, который реагирует на события установки. Нужно не забыть зарегистрировать его в AndroidManifest, а также объявить разрешение на установку:

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

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

А вот пример реализации 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 -> {
// здесь мы обрабатываем подтверждение установки пользователем
val confirmationIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirmationIntent != null) {
context.startActivity(confirmationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
PackageInstaller.STATUS_SUCCESS -> {
// произвольные шаги после успешного выполнения операции
}
else -> {
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
println("PackageInstallerStatusReceiver: status=$status, message=$message")
}
}
}

Это довольно сложный способ установки приложения.


Третий вариант

Существует еще один способ запуска сессии установки  —  это Intent.ACTION_VIEW. Но здесь мы его рассматривать не будем, поскольку он не дает результата установки и не имеет прямого отношения к установке пакетов.


Мы рассмотрели различные способы установки приложений. Но это только вершина айсберга. А как быть, если нужно:

  • обработать завершение процесса, инициированное системой;
  • узнать точную причину неудачи установки;
  • получать обновления о ходе установки во время активной сессии установки;
  • отложить подтверждение установки пользователем с помощью уведомления?

Есть ли более простой способ сделать все это, не задумываясь обо всех деталях и не написав много кода? Да, и это библиотека Ackpine.

Ackpine  —  это библиотека, предоставляющая согласованные API для установки и удаления приложений на Android-устройствах. Она проста в использовании, надежна и обеспечивает все то, о чем говорилось выше.

Она поддерживает как Java, так и идиоматический Kotlin с интеграцией корутин “из коробки”.

В качестве источника APK-файлов Ackpine использует Uri, что позволяет подключить практически любые APK-источники через ContentProviders и сделать их устойчивыми. Библиотека использует эту фичу для обеспечения возможности установки разделенных APK, заархивированных в формате zip, без их извлечения.


Посмотрите простой пример установки приложения на Kotlin с помощью 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)
}

Разумеется, это “голый” пример. Нам необходимо учесть завершение процесса, а также настроить сессию. Последнее очень легко сделать с помощью DSL-средств Kotlin:

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
}
}

А для обработки завершения процесса можно написать что-то вроде этого:

savedStateHandle[SESSION_ID_KEY] = session.id

// после рестарта процесса
val id: UUID? = savedStateHandle[SESSION_ID_KEY]
if (id != null) {
val result = packageInstaller.getSession(id)?.await()
// или какие-либо другие действия, относящиеся к сессии
}

Кроме того, Ackpine предоставляет утилиты, позволяющие легко работать с разделенными APK, заархивированными в формате zip (такими как APKS, APKM и XAPK):

val splits = ZippedApkSplits.getApksForUri(zippedFileUri, context) // чтение APK из zip-файла
.filterCompatible(context) // фильтрация по наиболее подходящим разделенным вариантам
.throwOnInvalidSplitPackage()
val splitsList = try {
splits.toList()
} catch (exception: SplitPackageException) {
println(exception)
emptyList()
}

Получать обновления о ходе установки очень просто:

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

Репозиторий библиотеки можно найти на GitHub здесь. Он содержит примеры проектов как на Java, так и на Kotlin. Cайт проекта с документацией находится здесь.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Ilya Fomichev: Painless building of an Android package installer app

Предыдущая статьяКак автоанализ кода с помощью ИИ повышает безопасность приложений
Следующая статьяГлубокое погружение в Java: рефлексия и загрузчик классов. Часть 2