Skip to content

Commit 8f1058f

Browse files
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
1 parent 8ba070b commit 8f1058f

16 files changed

+415
-114
lines changed

app/src/main/AndroidManifest.xml

+4-3
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@
2323
android:label="@string/app_name"
2424
android:requestLegacyExternalStorage="true"
2525
android:roundIcon="@mipmap/ic_launcher_round"
26-
android:supportsRtl="true"
27-
tools:ignore="AllowBackup">
28-
<service android:name=".installer.service.InstallService" />
26+
android:supportsRtl="true">
27+
28+
<receiver android:name=".installers.pm.PMIntentReceiver" />
29+
2930
<provider
3031
android:name="androidx.core.content.FileProvider"
3132
android:authorities="${applicationId}.provider"

app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import android.app.Application
44
import com.aliucord.manager.di.*
55
import com.aliucord.manager.domain.repository.AliucordMavenRepository
66
import com.aliucord.manager.domain.repository.GithubRepository
7+
import com.aliucord.manager.installers.pm.PMInstaller
8+
import com.aliucord.manager.manager.InstallerManager
79
import com.aliucord.manager.network.service.*
810
import com.aliucord.manager.ui.screens.about.AboutModel
911
import com.aliucord.manager.ui.screens.home.HomeModel
@@ -60,6 +62,12 @@ class ManagerApplication : Application() {
6062
single { providePreferences() }
6163
single { provideDownloadManager() }
6264
single { providePathManager() }
65+
singleOf(::InstallerManager)
66+
})
67+
68+
// Installers
69+
modules(module {
70+
singleOf(::PMInstaller)
6371
})
6472
}
6573
}

app/src/main/kotlin/com/aliucord/manager/installer/service/InstallService.kt

-52
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
package com.aliucord.manager.installer.steps.install
22

3-
import android.app.Application
43
import com.aliucord.manager.R
54
import com.aliucord.manager.installer.steps.StepGroup
65
import com.aliucord.manager.installer.steps.StepRunner
76
import com.aliucord.manager.installer.steps.base.Step
7+
import com.aliucord.manager.installer.steps.base.StepState
88
import com.aliucord.manager.installer.steps.patch.CopyDependenciesStep
9-
import com.aliucord.manager.installer.util.installApks
9+
import com.aliucord.manager.installers.InstallerResult
10+
import com.aliucord.manager.manager.InstallerManager
1011
import com.aliucord.manager.manager.PreferencesManager
1112
import org.koin.core.component.KoinComponent
1213
import org.koin.core.component.inject
@@ -15,7 +16,7 @@ import org.koin.core.component.inject
1516
* Install the final APK with the system's PackageManager.
1617
*/
1718
class InstallStep : Step(), KoinComponent {
18-
private val application: Application by inject()
19+
private val installers: InstallerManager by inject()
1920
private val prefs: PreferencesManager by inject()
2021

2122
override val group = StepGroup.Install
@@ -24,9 +25,19 @@ class InstallStep : Step(), KoinComponent {
2425
override suspend fun execute(container: StepRunner) {
2526
val apk = container.getStep<CopyDependenciesStep>().patchedApk
2627

27-
application.installApks(
28+
val result = installers.getActiveInstaller().waitInstall(
29+
apks = listOf(apk),
2830
silent = !prefs.devMode,
29-
apks = arrayOf(apk),
3031
)
32+
33+
when (result) {
34+
is InstallerResult.Error -> throw Error("Failed to install APKs: ${result.debugReason}")
35+
is InstallerResult.Cancelled -> {
36+
// The install screen is automatically closed immediately once cleanup finishes
37+
state = StepState.Skipped
38+
}
39+
40+
else -> {}
41+
}
3142
}
3243
}

app/src/main/kotlin/com/aliucord/manager/installer/util/PackageInstaller.kt

+1-43
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,10 @@
11
package com.aliucord.manager.installer.util
22

3-
import android.annotation.SuppressLint
4-
import android.app.Application
5-
import android.app.PendingIntent
63
import android.content.Context
74
import android.content.Intent
8-
import android.content.pm.PackageInstaller.SessionParams
9-
import android.content.pm.PackageManager
105
import android.net.Uri
11-
import android.os.Build
12-
import com.aliucord.manager.installer.service.InstallService
13-
import java.io.File
14-
15-
fun Application.installApks(silent: Boolean = false, vararg apks: File) {
16-
val packageInstaller = packageManager.packageInstaller
17-
val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
18-
if (Build.VERSION.SDK_INT >= 31) {
19-
setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST)
20-
21-
if (silent) {
22-
setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
23-
}
24-
}
25-
}
26-
27-
val sessionId = packageInstaller.createSession(params)
28-
val session = packageInstaller.openSession(sessionId)
29-
30-
apks.forEach { apk ->
31-
session.openWrite(apk.name, 0, apk.length()).use {
32-
it.write(apk.readBytes())
33-
session.fsync(it)
34-
}
35-
}
36-
37-
val callbackIntent = Intent(this, InstallService::class.java)
38-
39-
@SuppressLint("UnspecifiedImmutableFlag")
40-
val contentIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
41-
PendingIntent.getService(this, 0, callbackIntent, PendingIntent.FLAG_MUTABLE)
42-
} else {
43-
PendingIntent.getService(this, 0, callbackIntent, 0)
44-
}
45-
46-
session.commit(contentIntent.intentSender)
47-
session.close()
48-
}
496

7+
// TODO: move this to PMInstaller
508
fun Context.uninstallApk(packageName: String) {
519
val packageURI = Uri.parse("package:$packageName")
5210
val uninstallIntent = Intent(Intent.ACTION_DELETE, packageURI).apply {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.aliucord.manager.installers
2+
3+
import java.io.File
4+
5+
/**
6+
* A generic installer interface that manages installing APKs
7+
*/
8+
interface Installer {
9+
/**
10+
* Starts an installation and forgets about it. A toast will be shown when the installation was completed.
11+
* @param apks All APKs (including any splits) to merge into a single install.
12+
* @param silent If this is an update, then the update will occur without user interaction.
13+
*/
14+
fun install(apks: List<File>, silent: Boolean = true)
15+
16+
/**
17+
* Starts an installation and waits for it to finish with a result. A toast will be shown when the installation was completed.
18+
* @param apks All APKs (including any splits) to merge into a single install.
19+
* @param silent If this is an update, then the update will occur without user interaction.
20+
*/
21+
suspend fun waitInstall(apks: List<File>, silent: Boolean = true): InstallerResult
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.aliucord.manager.installers
2+
3+
import android.os.Parcelable
4+
import androidx.annotation.StringRes
5+
import kotlinx.parcelize.Parcelize
6+
7+
/**
8+
* The state of an APK installation after it has completed and cleaned up.
9+
*/
10+
sealed interface InstallerResult : Parcelable {
11+
/**
12+
* The installation was successfully completed.
13+
*/
14+
@Parcelize
15+
data object Success : InstallerResult
16+
17+
/**
18+
* This installation was interrupted and the install session has been canceled.
19+
* @param systemTriggered Whether the cancellation happened from the system (ie. clicked cancel on the install prompt)
20+
* Otherwise, this was caused by a coroutine cancellation.
21+
*/
22+
@Parcelize
23+
data class Cancelled(val systemTriggered: Boolean) : InstallerResult
24+
25+
/**
26+
* This installation encountered an error and has been aborted.
27+
* All implementors should implement [Parcelable].
28+
*/
29+
abstract class Error : InstallerResult {
30+
/**
31+
* Loggable error that should not be shown to the user.
32+
*/
33+
abstract val debugReason: String
34+
35+
/**
36+
* Simplified + translatable user facing errors.
37+
*/
38+
@get:StringRes
39+
abstract val localizedReason: Int
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.aliucord.manager.installers.pm
2+
3+
import android.annotation.SuppressLint
4+
import android.app.PendingIntent
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.content.pm.*
8+
import android.content.pm.PackageInstaller.SessionParams
9+
import android.os.Build
10+
import android.os.Process
11+
import android.util.Log
12+
import com.aliucord.manager.BuildConfig
13+
import com.aliucord.manager.installers.Installer
14+
import com.aliucord.manager.installers.InstallerResult
15+
import kotlinx.coroutines.suspendCancellableCoroutine
16+
import java.io.File
17+
18+
/**
19+
* APK installer using the [PackageInstaller] from the system's [PackageManager] service.
20+
*/
21+
class PMInstaller(
22+
val context: Context,
23+
) : Installer {
24+
init {
25+
val pkgInstaller = context.packageManager.packageInstaller
26+
27+
// Destroy all open sessions that may have not been previously cleaned up
28+
for (session in pkgInstaller.mySessions) {
29+
Log.d(BuildConfig.TAG, "Deleting PackageInstaller session ${session.sessionId}")
30+
pkgInstaller.abandonSession(session.sessionId)
31+
}
32+
}
33+
34+
override fun install(apks: List<File>, silent: Boolean) {
35+
startInstall(createInstallSession(silent), apks, false)
36+
}
37+
38+
override suspend fun waitInstall(apks: List<File>, silent: Boolean): InstallerResult {
39+
val sessionId = createInstallSession(silent)
40+
41+
return suspendCancellableCoroutine { continuation ->
42+
// This will receive parsed data forwarded by PMIntentReceiver
43+
val relayReceiver = PMResultReceiver(sessionId, continuation)
44+
45+
// Unregister PMResultReceiver when this coroutine finishes
46+
// additionally, cancel the install session entirely
47+
continuation.invokeOnCancellation {
48+
context.unregisterReceiver(relayReceiver)
49+
context.packageManager.packageInstaller.abandonSession(sessionId)
50+
}
51+
52+
@SuppressLint("UnspecifiedRegisterReceiverFlag")
53+
if (Build.VERSION.SDK_INT >= 33) {
54+
context.registerReceiver(relayReceiver, relayReceiver.filter, Context.RECEIVER_NOT_EXPORTED)
55+
} else {
56+
context.registerReceiver(relayReceiver, relayReceiver.filter)
57+
}
58+
59+
startInstall(sessionId, apks, relay = true)
60+
}
61+
}
62+
63+
/**
64+
* Starts a [PackageInstaller] session with the necessary params.
65+
* @param silent If this is an update, then the update will occur without user interaction.
66+
* @return The open install session id.
67+
*/
68+
private fun createInstallSession(silent: Boolean): Int {
69+
val params = SessionParams(SessionParams.MODE_FULL_INSTALL).apply {
70+
setInstallLocation(PackageInfo.INSTALL_LOCATION_AUTO)
71+
72+
if (Build.VERSION.SDK_INT >= 24) {
73+
setOriginatingUid(Process.myUid())
74+
}
75+
76+
if (Build.VERSION.SDK_INT >= 26) {
77+
setInstallReason(PackageManager.INSTALL_REASON_USER)
78+
}
79+
80+
if (Build.VERSION.SDK_INT >= 31) {
81+
setInstallScenario(PackageManager.INSTALL_SCENARIO_FAST)
82+
83+
if (silent) {
84+
setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED)
85+
}
86+
}
87+
88+
if (Build.VERSION.SDK_INT >= 34) {
89+
setPackageSource(PackageInstaller.PACKAGE_SOURCE_OTHER)
90+
}
91+
}
92+
93+
return context.packageManager.packageInstaller.createSession(params)
94+
}
95+
96+
/**
97+
* Start a [PackageInstaller] session for installation.
98+
* @param apks The apks to install
99+
* @param relay Whether to use the [PMResultReceiver] flow.
100+
*/
101+
private fun startInstall(sessionId: Int, apks: List<File>, relay: Boolean) {
102+
val callbackIntent = Intent(context, PMIntentReceiver::class.java)
103+
.putExtra(PMIntentReceiver.EXTRA_SESSION_ID, sessionId)
104+
.putExtra(PMIntentReceiver.EXTRA_RELAY_ENABLED, relay)
105+
106+
val pendingIntent = PendingIntent.getBroadcast(
107+
/* context = */ context,
108+
/* requestCode = */ 0,
109+
/* intent = */ callbackIntent,
110+
/* flags = */ PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
111+
)
112+
113+
context.packageManager.packageInstaller.openSession(sessionId).use { session ->
114+
val bufferSize = 1 * 1024 * 1024 // 1MiB
115+
116+
for (apk in apks) {
117+
session.openWrite(apk.name, 0, apk.length()).use { outStream ->
118+
apk.inputStream().use { it.copyTo(outStream, bufferSize) }
119+
session.fsync(outStream)
120+
}
121+
}
122+
123+
session.commit(pendingIntent.intentSender)
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)