Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rewrite PM installer #68

Merged
merged 2 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +62,12 @@ class ManagerApplication : Application() {
single { providePreferences() }
single { provideDownloadManager() }
single { providePathManager() }
singleOf(::InstallerManager)
})

// Installers
modules(module {
singleOf(::PMInstaller)
})
}
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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 -> {}
}
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/kotlin/com/aliucord/manager/installers/Installer.kt
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}
126 changes: 126 additions & 0 deletions app/src/main/kotlin/com/aliucord/manager/installers/pm/PMInstaller.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading