feat: rewrite PM installer (#68)

- Separate installer into a generic interface (for Shizuku support later)
- Heavily improve installation UI flow
  - Better handling of waiting for install completions
  - Handle install prompt cancellations
This commit is contained in:
rushii 2024-02-07 21:00:10 -08:00 • committed by GitHub
parent 8ba070b23b
commit 8f1058f6bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 414 additions and 113 deletions

View file

@ -23,9 +23,10 @@
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
tools:ignore="AllowBackup">
<service android:name=".installer.service.InstallService" />
android:supportsRtl="true">
<receiver android:name=".installers.pm.PMIntentReceiver" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View file

@ -4,6 +4,8 @@ import android.app.Application
import com.aliucord.manager.di.*
import com.aliucord.manager.domain.repository.AliucordMavenRepository
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.installers.pm.PMInstaller
import com.aliucord.manager.manager.InstallerManager
import com.aliucord.manager.network.service.*
import com.aliucord.manager.ui.screens.about.AboutModel
import com.aliucord.manager.ui.screens.home.HomeModel
@ -60,6 +62,12 @@ class ManagerApplication : Application() {
single { providePreferences() }
single { provideDownloadManager() }
single { providePathManager() }
singleOf(::InstallerManager)
})
// Installers
modules(module {
singleOf(::PMInstaller)
})
}
}

View file

@ -1,52 +0,0 @@
package com.aliucord.manager.installer.service
import android.app.Service
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.IBinder
import android.util.Log
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.util.showToast
class InstallService : Service() {
private val errorMessages = mapOf(
PackageInstaller.STATUS_FAILURE to R.string.install_error_unknown,
PackageInstaller.STATUS_FAILURE_BLOCKED to R.string.install_error_blocked,
PackageInstaller.STATUS_FAILURE_INVALID to R.string.install_error_invalid,
PackageInstaller.STATUS_FAILURE_CONFLICT to R.string.install_error_conflict,
PackageInstaller.STATUS_FAILURE_STORAGE to R.string.install_error_storage,
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE to R.string.install_error_incompatible,
PackageInstaller.STATUS_FAILURE_TIMEOUT to R.string.install_error_timeout
)
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
when (val statusCode = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
@Suppress("DEPRECATION") // No.
val confirmationIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(confirmationIntent)
}
PackageInstaller.STATUS_SUCCESS -> showToast(R.string.installer_success)
PackageInstaller.STATUS_FAILURE_ABORTED -> showToast(R.string.installer_aborted)
else -> {
Log.i(BuildConfig.TAG, "Install failed with error code $statusCode")
if (errorMessages[statusCode] != null) {
showToast(errorMessages[statusCode]!!)
} else {
showToast(R.string.install_error_code, statusCode)
}
}
}
stopSelf()
return START_NOT_STICKY
}
override fun onBind(intent: Intent): IBinder? = null
}

View file

@ -1,12 +1,13 @@
package com.aliucord.manager.installer.steps.install
import android.app.Application
import com.aliucord.manager.R
import com.aliucord.manager.installer.steps.StepGroup
import com.aliucord.manager.installer.steps.StepRunner
import com.aliucord.manager.installer.steps.base.Step
import com.aliucord.manager.installer.steps.base.StepState
import com.aliucord.manager.installer.steps.patch.CopyDependenciesStep
import com.aliucord.manager.installer.util.installApks
import com.aliucord.manager.installers.InstallerResult
import com.aliucord.manager.manager.InstallerManager
import com.aliucord.manager.manager.PreferencesManager
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -15,7 +16,7 @@ import org.koin.core.component.inject
* Install the final APK with the system's PackageManager.
*/
class InstallStep : Step(), KoinComponent {
private val application: Application by inject()
private val installers: InstallerManager by inject()
private val prefs: PreferencesManager by inject()
override val group = StepGroup.Install
@ -24,9 +25,19 @@ class InstallStep : Step(), KoinComponent {
override suspend fun execute(container: StepRunner) {
val apk = container.getStep<CopyDependenciesStep>().patchedApk
application.installApks(
val result = installers.getActiveInstaller().waitInstall(
apks = listOf(apk),
silent = !prefs.devMode,
apks = arrayOf(apk),
)
when (result) {
is InstallerResult.Error -> throw Error("Failed to install APKs: ${result.debugReason}")
is InstallerResult.Cancelled -> {
// The install screen is automatically closed immediately once cleanup finishes
state = StepState.Skipped
}
else -> {}
}
}
}

View file

@ -1,52 +1,10 @@
package com.aliucord.manager.installer.util
import android.annotation.SuppressLint
import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller.SessionParams
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import com.aliucord.manager.installer.service.InstallService
import java.io.File
fun Application.installApks(silent: Boolean = false, vararg apks: File) {
val packageInstaller = packageManager.packageInstaller
val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
if (Build.VERSION.SDK_INT >= 31) {
setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST)
if (silent) {
setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
}
val sessionId = packageInstaller.createSession(params)
val session = packageInstaller.openSession(sessionId)
apks.forEach { apk ->
session.openWrite(apk.name, 0, apk.length()).use {
it.write(apk.readBytes())
session.fsync(it)
}
}
val callbackIntent = Intent(this, InstallService::class.java)
@SuppressLint("UnspecifiedImmutableFlag")
val contentIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getService(this, 0, callbackIntent, PendingIntent.FLAG_MUTABLE)
} else {
PendingIntent.getService(this, 0, callbackIntent, 0)
}
session.commit(contentIntent.intentSender)
session.close()
}
// TODO: move this to PMInstaller
fun Context.uninstallApk(packageName: String) {
val packageURI = Uri.parse("package:$packageName")
val uninstallIntent = Intent(Intent.ACTION_DELETE, packageURI).apply {

View file

@ -0,0 +1,22 @@
package com.aliucord.manager.installers
import java.io.File
/**
* A generic installer interface that manages installing APKs
*/
interface Installer {
/**
* Starts an installation and forgets about it. A toast will be shown when the installation was completed.
* @param apks All APKs (including any splits) to merge into a single install.
* @param silent If this is an update, then the update will occur without user interaction.
*/
fun install(apks: List<File>, silent: Boolean = true)
/**
* Starts an installation and waits for it to finish with a result. A toast will be shown when the installation was completed.
* @param apks All APKs (including any splits) to merge into a single install.
* @param silent If this is an update, then the update will occur without user interaction.
*/
suspend fun waitInstall(apks: List<File>, silent: Boolean = true): InstallerResult
}

View file

@ -0,0 +1,41 @@
package com.aliucord.manager.installers
import android.os.Parcelable
import androidx.annotation.StringRes
import kotlinx.parcelize.Parcelize
/**
* The state of an APK installation after it has completed and cleaned up.
*/
sealed interface InstallerResult : Parcelable {
/**
* The installation was successfully completed.
*/
@Parcelize
data object Success : InstallerResult
/**
* This installation was interrupted and the install session has been canceled.
* @param systemTriggered Whether the cancellation happened from the system (ie. clicked cancel on the install prompt)
* Otherwise, this was caused by a coroutine cancellation.
*/
@Parcelize
data class Cancelled(val systemTriggered: Boolean) : InstallerResult
/**
* This installation encountered an error and has been aborted.
* All implementors should implement [Parcelable].
*/
abstract class Error : InstallerResult {
/**
* Loggable error that should not be shown to the user.
*/
abstract val debugReason: String
/**
* Simplified + translatable user facing errors.
*/
@get:StringRes
abstract val localizedReason: Int
}
}

View file

@ -0,0 +1,126 @@
package com.aliucord.manager.installers.pm
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.*
import android.content.pm.PackageInstaller.SessionParams
import android.os.Build
import android.os.Process
import android.util.Log
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.installers.Installer
import com.aliucord.manager.installers.InstallerResult
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
/**
* APK installer using the [PackageInstaller] from the system's [PackageManager] service.
*/
class PMInstaller(
val context: Context,
) : Installer {
init {
val pkgInstaller = context.packageManager.packageInstaller
// Destroy all open sessions that may have not been previously cleaned up
for (session in pkgInstaller.mySessions) {
Log.d(BuildConfig.TAG, "Deleting PackageInstaller session ${session.sessionId}")
pkgInstaller.abandonSession(session.sessionId)
}
}
override fun install(apks: List<File>, silent: Boolean) {
startInstall(createInstallSession(silent), apks, false)
}
override suspend fun waitInstall(apks: List<File>, silent: Boolean): InstallerResult {
val sessionId = createInstallSession(silent)
return suspendCancellableCoroutine { continuation ->
// This will receive parsed data forwarded by PMIntentReceiver
val relayReceiver = PMResultReceiver(sessionId, continuation)
// Unregister PMResultReceiver when this coroutine finishes
// additionally, cancel the install session entirely
continuation.invokeOnCancellation {
context.unregisterReceiver(relayReceiver)
context.packageManager.packageInstaller.abandonSession(sessionId)
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(relayReceiver, relayReceiver.filter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(relayReceiver, relayReceiver.filter)
}
startInstall(sessionId, apks, relay = true)
}
}
/**
* Starts a [PackageInstaller] session with the necessary params.
* @param silent If this is an update, then the update will occur without user interaction.
* @return The open install session id.
*/
private fun createInstallSession(silent: Boolean): Int {
val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO)
if (Build.VERSION.SDK_INT >= 24) {
setOriginatingUid(Process.myUid())
}
if (Build.VERSION.SDK_INT >= 26) {
setInstallReason(PackageManager.INSTALL_REASON_USER)
}
if (Build.VERSION.SDK_INT >= 31) {
setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST)
if (silent) {
setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
if (Build.VERSION.SDK_INT >= 34) {
setPackageSource(PackageInstaller.PACKAGE_SOURCE_OTHER)
}
}
return context.packageManager.packageInstaller.createSession(params)
}
/**
* Start a [PackageInstaller] session for installation.
* @param apks The apks to install
* @param relay Whether to use the [PMResultReceiver] flow.
*/
private fun startInstall(sessionId: Int, apks: List<File>, relay: Boolean) {
val callbackIntent = Intent(context, PMIntentReceiver::class.java)
.putExtra(PMIntentReceiver.EXTRA_SESSION_ID, sessionId)
.putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, relay)
val pendingIntent = PendingIntent.getBroadcast(
/* context = */ context,
/* requestCode = */ 0,
/* intent = */ callbackIntent,
/* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
context.packageManager.packageInstaller.openSession(sessionId).use { session ->
val bufferSize = 1 * 1024 * 1024 // 1MiB
for (apk in apks) {
session.openWrite(apk.name, 0, apk.length()).use { outStream ->
apk.inputStream().use { it.copyTo(outStream, bufferSize) }
session.fsync(outStream)
}
}
session.commit(pendingIntent.intentSender)
}
}
}

View file

@ -0,0 +1,39 @@
package com.aliucord.manager.installers.pm
import android.content.pm.PackageInstaller
import android.os.Parcelable
import com.aliucord.manager.R
import com.aliucord.manager.installers.InstallerResult
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
/**
* Translates the errors returned by PackageInstaller's [PackageInstaller.EXTRA_STATUS]
* that is captured by a receiver into something human readable.
*/
@Parcelize
data class PMInstallerError(val status: Int) : InstallerResult.Error(), Parcelable {
@IgnoredOnParcel
override val debugReason = when (status) {
PackageInstaller.STATUS_FAILURE -> "Unknown failure"
PackageInstaller.STATUS_FAILURE_BLOCKED -> "Blocked"
PackageInstaller.STATUS_FAILURE_INVALID -> "Invalid package"
PackageInstaller.STATUS_FAILURE_CONFLICT -> "Package conflict"
PackageInstaller.STATUS_FAILURE_STORAGE -> "Storage error"
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "Device incompatibility"
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> "Operation timeout"
else -> "Unknown code ($status)"
}
@IgnoredOnParcel
override val localizedReason = when (status) {
PackageInstaller.STATUS_FAILURE -> R.string.install_error_unknown
PackageInstaller.STATUS_FAILURE_BLOCKED -> R.string.install_error_blocked
PackageInstaller.STATUS_FAILURE_INVALID -> R.string.install_error_invalid
PackageInstaller.STATUS_FAILURE_CONFLICT -> R.string.install_error_conflict
PackageInstaller.STATUS_FAILURE_STORAGE -> R.string.install_error_storage
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> R.string.install_error_incompatible
/* PackageInstaller.STATUS_FAILURE_TIMEOUT */ 8 -> R.string.install_error_timeout
else -> R.string.install_error_unknown
}
}

View file

@ -0,0 +1,73 @@
package com.aliucord.manager.installers.pm
import android.content.*
import android.content.pm.PackageInstaller
import android.util.Log
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R
import com.aliucord.manager.installers.InstallerResult
import com.aliucord.manager.util.showToast
class PMIntentReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val realSessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
val expectedSessionId = intent.getIntExtra(EXTRA_SESSION_ID, -2)
if (realSessionId != expectedSessionId) return
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)
val installerResult = when (status) {
-999 -> {
// Invalid intent
null
}
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
@Suppress("DEPRECATION")
val confirmationIntent = intent
.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(confirmationIntent)
null // no result yet
}
PackageInstaller.STATUS_SUCCESS -> {
context.showToast(R.string.installer_success)
InstallerResult.Success
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
context.showToast(R.string.installer_aborted)
InstallerResult.Cancelled(systemTriggered = true)
}
else -> {
Log.w(BuildConfig.TAG, "Install failed with error code $status")
if (status <= PackageInstaller.STATUS_SUCCESS)
null // Unknown status code (not an error)
else {
PMInstallerError(status).also {
context.showToast(it.localizedReason)
}
}
}
}
// Forward result to PMResultReceiver if relaying is enabled and have a real result
if (installerResult != null && intent.getBooleanExtra(EXTRA_RELAY_ENABLED, false)) {
val relayIntent = Intent(PMResultReceiver.ACTION_RECEIVE_RESULT)
.putExtra(PMResultReceiver.EXTRA_RESULT, installerResult)
.putExtra(PMResultReceiver.EXTRA_SESSION_ID, realSessionId)
context.sendBroadcast(relayIntent)
}
}
companion object {
const val EXTRA_RELAY_ENABLED = "relayEnabled"
const val EXTRA_SESSION_ID = "expectedSessionId"
}
}

View file

@ -0,0 +1,33 @@
package com.aliucord.manager.installers.pm
import android.content.*
import com.aliucord.manager.installers.InstallerResult
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class PMResultReceiver(
private val sessionId: Int,
private val continuation: Continuation<InstallerResult>,
) : BroadcastReceiver() {
/**
* The intent filter this receiver should be registered with to work properly.
*/
val filter = IntentFilter(ACTION_RECEIVE_RESULT)
@Suppress("DEPRECATION")
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_RECEIVE_RESULT) return
if (intent.getIntExtra(EXTRA_SESSION_ID, -1) != sessionId) return
val result = intent.getParcelableExtra<InstallerResult>(EXTRA_RESULT)
?: return
continuation.resume(result)
}
companion object {
const val ACTION_RECEIVE_RESULT = "com.aliucord.manager.RELAY_PM_RESULT"
const val EXTRA_RESULT = "installerResult"
const val EXTRA_SESSION_ID = "sessionId"
}
}

View file

@ -0,0 +1,29 @@
package com.aliucord.manager.manager
import com.aliucord.manager.installers.Installer
import com.aliucord.manager.installers.pm.PMInstaller
import org.koin.core.annotation.KoinInternalApi
import org.koin.core.component.KoinComponent
import kotlin.reflect.KClass
/**
* Handle providing the correct install manager based on preferences.
*/
class InstallerManager(
private val prefs: PreferencesManager,
) : KoinComponent {
fun getActiveInstaller(): Installer =
getInstaller(prefs.installer)
@OptIn(KoinInternalApi::class)
fun getInstaller(type: InstallerSetting): Installer =
getKoin().scopeRegistry.rootScope.get(clazz = type.installerClass)
}
enum class InstallerSetting(
// @StringRes
// private val localizedName: Int,
val installerClass: KClass<out Installer>,
) {
PM(PMInstaller::class),
}

View file

@ -9,6 +9,7 @@ class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager
var dynamicColor by booleanPreference("dynamic_color", true)
var replaceIcon by booleanPreference("replace_icon", true)
var devMode by booleanPreference("dev_mode", false)
var installer by enumPreference("installer", InstallerSetting.PM)
var debuggable by booleanPreference("debuggable", false)
var appName by stringPreference("app_name", "Aliucord")
var packageName by stringPreference("package_name", "com.aliucord")

View file

@ -12,6 +12,8 @@ import com.aliucord.manager.R
import com.aliucord.manager.installer.steps.KotlinInstallRunner
import com.aliucord.manager.installer.steps.StepGroup
import com.aliucord.manager.installer.steps.base.Step
import com.aliucord.manager.installer.steps.base.StepState
import com.aliucord.manager.installer.steps.install.InstallStep
import com.aliucord.manager.manager.PathManager
import com.aliucord.manager.ui.util.toUnsafeImmutable
import com.aliucord.manager.util.*
@ -79,13 +81,20 @@ class InstallModel(
// Execute all the steps and catch any errors
when (val error = runner.executeAll()) {
// Successfully installed
null -> {
mutableState.value = InstallScreenState.Success
// If install step is marked skipped then the installation was manually aborted
// and if so, immediately close install screen
if (runner.getStep<InstallStep>().state == StepState.Skipped) {
mutableState.value = InstallScreenState.CloseScreen
}
// At this point, the installation has successfully completed
else {
mutableState.value = InstallScreenState.Success
// Wait 20s before returning to Home
delay(5000)
mutableState.value = InstallScreenState.CloseScreen
// Wait 20s before returning to Home
delay(5000)
mutableState.value = InstallScreenState.CloseScreen
}
}
else -> {

View file

@ -6,8 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aliucord.manager.BuildConfig
import com.aliucord.manager.domain.repository.GithubRepository
import com.aliucord.manager.installer.util.installApks
import com.aliucord.manager.manager.DownloadManager
import com.aliucord.manager.manager.*
import com.aliucord.manager.network.utils.SemVer
import com.aliucord.manager.network.utils.getOrNull
import kotlinx.coroutines.Dispatchers
@ -16,6 +15,7 @@ import kotlinx.coroutines.launch
class UpdaterViewModel(
private val github: GithubRepository,
private val downloadManager: DownloadManager,
private val installers: InstallerManager,
private val application: Application,
) : ViewModel() {
var showDialog by mutableStateOf(false)
@ -42,7 +42,10 @@ class UpdaterViewModel(
it.delete()
downloadManager.download(url, it)
application.installApks(silent = true, it)
installers.getInstaller(InstallerSetting.PM).install(
apks = listOf(it),
silent = true,
)
it.delete()
}

View file

@ -89,12 +89,11 @@
<string name="installer_file_save_success">Saved to %s</string>
<string name="installer_file_save_failed">Failed to save %s</string>
<string name="installer_success">Successfully installed Aliucord</string>
<string name="installer_aborted">Aborted Aliucord installation</string>
<string name="installer_aborted">Cancelled Aliucord installation</string>
<string name="installer_dl_verify_fail">Failed to verify download</string>
<string name="installer_uninstall_new">Please uninstall your current version of Aliucord in order to continue!</string>
<string name="install_error_unknown">Failed to install (Unknown reason)</string>
<string name="install_error_code">Failed to install (error code %d)</string>
<string name="install_error_blocked">Installation was blocked</string>
<string name="install_error_invalid">One or more APKs were invalid or corrupt</string>
<string name="install_error_conflict">Conflicts with an existing app, usually due to mismatched signatures</string>