Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import app.revanced.library.installation.installer.Installer.Apk
import java.io.Closeable
import java.io.File

Expand All @@ -26,7 +25,7 @@ import java.io.File
class LocalInstaller(
private val context: Context,
onResult: (result: LocalInstallerResult) -> Unit,
) : Installer<Unit, Installation>(), Closeable {
) : Installer<Unit, Installation, InstallerOptions>(), Closeable {
private val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pmStatus = intent.getIntExtra(LocalInstallerService.EXTRA_STATUS, -999)
Expand Down Expand Up @@ -56,13 +55,14 @@ class LocalInstaller(
)
}

override suspend fun install(apk: Apk) {
logger.info("Installing ${apk.file.name}")
override suspend fun install(options: InstallerOptions) {
val patchedApk = options.apk
logger.info("Installing ${patchedApk.file.name}")

val packageInstaller = context.packageManager.packageInstaller

packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->
session.writeApk(apk.file)
session.writeApk(patchedApk.file)
session.commit(intentSender)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ class AdbShellCommandRunner : ShellCommandRunner {
override val output by lazy { process.inputStream.bufferedReader().readText().removeSuffix("\n") }
override val error by lazy { process.errorStream.bufferedReader().readText() }

override fun waitFor() {
override fun waitFor(): RunResult {
process.waitFor()
return this
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ interface RunResult {
/**
* Waits for the command to finish.
*/
fun waitFor() {}
fun waitFor(): RunResult = this
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import se.vidstige.jadb.managers.PackageManager.UPDATE_OWNERSHIP
*/
class AdbInstaller(
deviceSerial: String? = null,
) : Installer<AdbInstallerResult, Installation>() {
) : Installer<AdbInstallerResult, Installation, InstallerOptions>() {
private val shellCommandRunner: ShellCommandRunner
private val packageManager: PackageManager

Expand All @@ -30,11 +30,13 @@ class AdbInstaller(
logger.fine("Connected to $deviceSerial")
}

override suspend fun install(apk: Apk): AdbInstallerResult {
override suspend fun install(options: InstallerOptions): AdbInstallerResult {
val patchedApk = options.apk

return runPackageManager {
val sdkVersion = shellCommandRunner(GET_SDK_VERSION).output.toInt()
if (sdkVersion < 34) install(apk.file)
else installWithOptions(apk.file, listOf(UPDATE_OWNERSHIP))
if (sdkVersion < 34) install(patchedApk.file)
else installWithOptions(patchedApk.file, listOf(UPDATE_OWNERSHIP))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ internal object Constants {
const val INSTALLED_APK_PATH = "pm path $PLACEHOLDER"
const val CREATE_INSTALLATION_PATH = "$CREATE_DIR $MOUNT_PATH"
const val GET_SDK_VERSION = "getprop ro.build.version.sdk"
const val UNINSTALL_KEEP_DATA = "pm uninstall -k $PLACEHOLDER"
const val INSTALL_STOCK_APK = "pm install -r -d --user 0 $PLACEHOLDER"
const val GET_INSTALLED_VERSION_CODE =
"dumpsys package $PLACEHOLDER | sed -n 's/.*versionCode=\\([0-9]*\\).*/\\1/p' | head -n 1"
const val GET_INSTALLED_VERSION_NAME =
"dumpsys package $PLACEHOLDER | sed -n 's/.*versionName=//p' | head -n 1"

const val MOUNT_APK =
"base_path=\"$MOUNTED_APK_PATH\" && " +
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package app.revanced.library.installation.installer

import app.revanced.library.installation.installer.Installer.Apk
import java.io.File
import java.util.logging.Logger

Expand All @@ -10,7 +9,7 @@ import java.util.logging.Logger
* @param TInstallerResult The type of the result of the installation.
* @param TInstallation The type of the installation.
*/
abstract class Installer<TInstallerResult, TInstallation : Installation> internal constructor() {
abstract class Installer<TInstallerResult, TInstallation : Installation, TInstallerOptions : InstallerOptions> internal constructor() {
/**
* The [Logger].
*/
Expand All @@ -19,11 +18,11 @@ abstract class Installer<TInstallerResult, TInstallation : Installation> interna
/**
* Installs the [Apk] file.
*
* @param apk The [Apk] file.
* @param options The [InstallerOptions].
*
* @return The result of the installation.
*/
abstract suspend fun install(apk: Apk): TInstallerResult
abstract suspend fun install(options: TInstallerOptions): TInstallerResult

/**
* Uninstalls the package.
Expand All @@ -43,11 +42,5 @@ abstract class Installer<TInstallerResult, TInstallation : Installation> interna
*/
abstract suspend fun getInstallation(packageName: String): TInstallation?

/**
* Apk file for [Installer].
*
* @param file The [Apk] file.
* @param packageName The package name of the [Apk] file.
*/
class Apk(val file: File, val packageName: String? = null)
open class Apk(val file: File)
}
Comment thread
Ushie marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package app.revanced.library.installation.installer

open class InstallerOptions(
val apk: Installer.Apk
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import app.revanced.library.installation.command.ShellCommandRunner
import app.revanced.library.installation.installer.Constants.CREATE_INSTALLATION_PATH
import app.revanced.library.installation.installer.Constants.DELETE
import app.revanced.library.installation.installer.Constants.EXISTS
import app.revanced.library.installation.installer.Constants.GET_INSTALLED_VERSION_CODE
import app.revanced.library.installation.installer.Constants.GET_INSTALLED_VERSION_NAME
import app.revanced.library.installation.installer.Constants.INSTALLED_APK_PATH
import app.revanced.library.installation.installer.Constants.INSTALL_MOUNT_SCRIPT
import app.revanced.library.installation.installer.Constants.INSTALL_STOCK_APK
import app.revanced.library.installation.installer.Constants.KILL
import app.revanced.library.installation.installer.Constants.MOUNTED_APK_PATH
import app.revanced.library.installation.installer.Constants.MOUNT_APK
Expand All @@ -16,8 +19,6 @@ import app.revanced.library.installation.installer.Constants.RESTART
import app.revanced.library.installation.installer.Constants.TMP_FILE_PATH
import app.revanced.library.installation.installer.Constants.UMOUNT
import app.revanced.library.installation.installer.Constants.invoke
import app.revanced.library.installation.installer.Installer.Apk
import app.revanced.library.installation.installer.RootInstaller.NoRootPermissionException
import java.io.File

/**
Expand All @@ -27,35 +28,61 @@ import java.io.File
*
* @throws NoRootPermissionException If the device does not have root permission.
*/
@Suppress("MemberVisibilityCanBePrivate")
@Suppress("MemberVisibilityCanBePrivate", "unused")
abstract class RootInstaller internal constructor(
shellCommandRunnerSupplier: (RootInstaller) -> ShellCommandRunner,
) : Installer<RootInstallerResult, RootInstallation>() {
) : Installer<RootInstallerResult, RootInstallation, RootInstallerOptions>() {

/**
* The command runner used to run commands on the device.
*/
@Suppress("LeakingThis")
protected val shellCommandRunner = shellCommandRunnerSupplier(this)

init {
if (!shellCommandRunner.hasRootPermission()) throw NoRootPermissionException()
}

/**
* Installs the given [apk] by mounting.
* Installs the given patched APK by mounting it over a regular installation of the stock APK.
*
* @param apk The [Apk] to install.
* The stock APK is used to ensure a valid base installation for mounting. If the app is not
* currently installed, the stock APK is installed first. If the installed app version does not
* match the expected stock APK version, installation is aborted.
*
* @throws PackageNameRequiredException If the [Apk] does not have a package name.
* @param options The installer options containing the patched APK to mount and the stock APK
* used as the installation base.
*
* @throws PackageVersionMismatchException If the installed app version does not match the
* expected stock APK version.
*/
override suspend fun install(apk: Apk): RootInstallerResult {
logger.info("Installing ${apk.packageName} by mounting")
override suspend fun install(options: RootInstallerOptions): RootInstallerResult {
val stockApk = options.stockApk
val packageName = stockApk.packageName
logger.info("Installing $packageName by mounting")

// Ensure the installed base app matches the stock APK version.
val installedVersionName = try {
getInstalledVersionName(packageName)
} catch (_: PackageNotInstalledException) {
null
}

val packageName = apk.packageName?.also { it.assertInstalled() } ?: throw PackageNameRequiredException()
when {
installedVersionName == null -> {
logger.info("Installing stock APK for $packageName")
INSTALL_STOCK_APK(stockApk.file.absolutePath)().waitFor()
packageName.assertInstalled()
}

installedVersionName != stockApk.versionName -> {
throw PackageVersionMismatchException(packageName)
}
}

packageName.assertInstalled()
Comment thread
Ushie marked this conversation as resolved.

// Setup files.
apk.file.move(TMP_FILE_PATH)
options.stockApk.file.move(TMP_FILE_PATH)
CREATE_INSTALLATION_PATH().waitFor()
MOUNT_APK(packageName)().waitFor()

Expand Down Expand Up @@ -88,7 +115,7 @@ abstract class RootInstaller internal constructor(
val patchedApkPath = MOUNTED_APK_PATH(packageName)

val patchedApkExists = EXISTS(patchedApkPath)().exitCode == 0
if (patchedApkExists) return null
if (!patchedApkExists) return null
Comment thread
Ushie marked this conversation as resolved.

return RootInstallation(
INSTALLED_APK_PATH(packageName)().output.ifEmpty { null },
Expand All @@ -97,6 +124,22 @@ abstract class RootInstaller internal constructor(
)
}

fun getInstalledVersionName(packageName: String): String =
Copy link
Copy Markdown
Member

@oSumAtrIX oSumAtrIX Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why were these APIs added here instead of a utils class? Is it because it needs the shell command runner? In the case of a utils class a command runner can be passed to it for this purpose

GET_INSTALLED_VERSION_NAME(packageName)()
.waitFor()
.output
.trim()
.takeIf { it.isNotEmpty() }
?: throw PackageNotInstalledException(packageName)

fun getInstalledVersionCode(packageName: String): Int =
GET_INSTALLED_VERSION_CODE(packageName)()
.waitFor()
.output
.trim()
.toIntOrNull()
?: throw PackageNotInstalledException(packageName)

/**
* Runs a command on the device.
*/
Expand All @@ -114,21 +157,26 @@ abstract class RootInstaller internal constructor(
*
* @param content The content of the file.
*/
protected fun String.write(content: String) = shellCommandRunner.write(content.byteInputStream(), this)
protected fun String.write(content: String) =
shellCommandRunner.write(content.byteInputStream(), this)

/**
* Asserts that the package is installed.
*
* @throws FailedToFindInstalledPackageException If the package is not installed.
* @throws PackageNotInstalledException If the package is not installed.
*/
private fun String.assertInstalled() {
if (INSTALLED_APK_PATH(this)().output.isEmpty()) {
throw FailedToFindInstalledPackageException(this)
throw PackageNotInstalledException(this)
}
}

internal class FailedToFindInstalledPackageException internal constructor(packageName: String) : Exception("Failed to resolve installed APK path for package \"$packageName\"")
internal class PackageVersionMismatchException internal constructor(packageName: String) :
Exception("Package $packageName does not match the expected version")

internal class PackageNotInstalledException internal constructor(packageName: String) :
Exception("Package $packageName is not installed")

internal class PackageNameRequiredException internal constructor() : Exception("Package name is required")
internal class NoRootPermissionException internal constructor() : Exception("No root permission")
internal class NoRootPermissionException internal constructor() :
Exception("Root permission is not granted")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isnt no root permission simpler

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package app.revanced.library.installation.installer

import java.io.File

class RootInstallerOptions(
patchedApk: Installer.Apk,
val stockApk: Apk,
) : InstallerOptions(patchedApk)

class Apk(file: File, val packageName: String, val versionName: String) : Installer.Apk(file)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason this class is inside the RootInstallerOptions.kt class? Class apk is inside the Installer class, so this class should be inside RootInstaller.kt

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, but since the options were moved out of the Installer class, maybe class APK should move to InstallerOptions instead since thats where it's usage is

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be breaking change, refactoring like this can come in another PR, for now put APK in RootInstaller for consistency

Loading