revanced-cli/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt

412 lines
14 KiB
Kotlin
Raw Normal View History

2023-08-23 03:08:21 +02:00
package app.revanced.cli.command
import app.revanced.library.ApkUtils
2024-02-11 21:47:15 +01:00
import app.revanced.library.ApkUtils.applyTo
import app.revanced.library.ApkUtils.sign
import app.revanced.library.Options
import app.revanced.library.Options.setOptions
import app.revanced.library.adb.AdbManager
import app.revanced.patcher.PatchBundleLoader
import app.revanced.patcher.PatchSet
import app.revanced.patcher.Patcher
2024-02-11 21:47:15 +01:00
import app.revanced.patcher.PatcherConfig
2023-08-23 03:08:21 +02:00
import kotlinx.coroutines.runBlocking
import picocli.CommandLine
import picocli.CommandLine.Help.Visibility.ALWAYS
import picocli.CommandLine.Model.CommandSpec
import picocli.CommandLine.Spec
2023-08-23 03:08:21 +02:00
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.util.logging.Logger
2023-08-23 03:08:21 +02:00
@CommandLine.Command(
name = "patch",
description = ["Patch an APK file."],
2023-08-23 03:08:21 +02:00
)
internal object PatchCommand : Runnable {
private val logger = Logger.getLogger(PatchCommand::class.java.name)
@Spec
lateinit var spec: CommandSpec // injected by picocli
2023-08-23 03:08:21 +02:00
private lateinit var apk: File
2023-08-23 03:08:21 +02:00
2024-02-11 21:47:15 +01:00
private var integrations = setOf<File>()
2023-08-23 03:08:21 +02:00
2024-02-11 21:47:15 +01:00
private var patchBundles = emptySet<File>()
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["-i", "--include"],
description = ["List of patches to include."],
2023-08-23 03:08:21 +02:00
)
private var includedPatches = hashSetOf<String>()
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--ii"],
description = ["List of patches to include by their index in relation to the supplied patch bundles."],
)
private var includedPatchesByIndex = arrayOf<Int>()
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["-e", "--exclude"],
description = ["List of patches to exclude."],
2023-08-23 03:08:21 +02:00
)
private var excludedPatches = hashSetOf<String>()
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--ei"],
description = ["List of patches to exclude by their index in relation to the supplied patch bundles."],
)
private var excludedPatchesByIndex = arrayOf<Int>()
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--options"],
description = ["Path to patch options JSON file."],
2023-08-23 03:08:21 +02:00
)
2023-11-26 04:44:00 +01:00
private var optionsFile: File? = null
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--exclusive"],
description = ["Only include patches that are explicitly specified to be included."],
showDefaultValue = ALWAYS,
2023-08-23 03:08:21 +02:00
)
private var exclusive = false
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["-f", "--force"],
description = ["Bypass compatibility checks for the supplied APK's version."],
showDefaultValue = ALWAYS,
2023-08-23 03:08:21 +02:00
)
private var force: Boolean = false
2023-08-23 03:08:21 +02:00
2023-11-26 04:44:00 +01:00
private var outputFilePath: File? = null
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["-o", "--out"],
2023-11-26 04:44:00 +01:00
description = ["Path to save the patched APK file to. Defaults to the same directory as the supplied APK file."],
2023-08-23 03:08:21 +02:00
)
2023-11-26 04:44:00 +01:00
private fun setOutputFilePath(outputFilePath: File?) {
this.outputFilePath = outputFilePath?.absoluteFile
}
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["-d", "--device-serial"],
description = ["ADB device serial to install to. If not supplied, the first connected device will be used."],
2024-02-11 21:47:15 +01:00
// Empty string to indicate that the first connected device should be used.
fallbackValue = "",
arity = "0..1",
2023-08-23 03:08:21 +02:00
)
private var deviceSerial: String? = null
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--mount"],
description = ["Install by mounting the patched APK file."],
showDefaultValue = ALWAYS,
2023-08-23 03:08:21 +02:00
)
private var mount: Boolean = false
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--keystore"],
description = [
"Path to the keystore to sign the patched APK file with. " +
"Defaults to the same directory as the supplied APK file.",
],
)
private var keystoreFilePath: File? = null
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--keystore-password"],
description = ["The password of the keystore to sign the patched APK file with. Empty password by default."],
2023-08-23 03:08:21 +02:00
)
private var keyStorePassword: String? = null // Empty password by default
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--alias"],
2024-03-14 12:19:57 +01:00
description = ["The alias of the keystore entry to sign the patched APK file with."],
showDefaultValue = ALWAYS,
2023-08-23 03:08:21 +02:00
)
private var alias = "ReVanced Key"
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--keystore-entry-password"],
description = ["The password of the entry from the keystore for the key to sign the patched APK file with."],
2023-08-23 03:08:21 +02:00
)
private var password = "" // Empty password by default
@CommandLine.Option(
names = ["--signer"],
description = ["The name of the signer to sign the patched APK file with."],
showDefaultValue = ALWAYS,
)
private var signer = "ReVanced"
2023-08-23 03:08:21 +02:00
2024-02-11 21:47:15 +01:00
@CommandLine.Option(
names = ["-t", "--temporary-files-path"],
description = ["Path to temporary files directory."],
)
private var temporaryFilesPath: File? = null
2023-08-23 03:08:21 +02:00
private var aaptBinaryPath: File? = null
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["-p", "--purge"],
description = ["Purge the temporary resource cache directory after patching."],
showDefaultValue = ALWAYS,
2023-08-23 03:08:21 +02:00
)
private var purge: Boolean = false
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["-w", "--warn"],
description = ["Warn if a patch can not be found in the supplied patch bundles."],
showDefaultValue = ALWAYS,
)
private var warn: Boolean = false
@CommandLine.Parameters(
description = ["APK file to be patched."],
arity = "1..1",
)
@Suppress("unused")
private fun setApk(apk: File) {
if (!apk.exists()) {
throw CommandLine.ParameterException(
spec.commandLine(),
"APK file ${apk.path} does not exist",
)
}
this.apk = apk
}
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["-m", "--merge"],
description = ["One or more DEX files or containers to merge into the APK."],
)
@Suppress("unused")
private fun setIntegrations(integrations: Array<File>) {
integrations.firstOrNull { !it.exists() }?.let {
throw CommandLine.ParameterException(spec.commandLine(), "Integrations file ${it.path} does not exist.")
}
this.integrations += integrations
}
@CommandLine.Option(
names = ["-b", "--patch-bundle"],
description = ["One or more bundles of patches."],
required = true,
)
2023-09-17 03:39:34 +02:00
@Suppress("unused")
2024-02-11 21:47:15 +01:00
private fun setPatchBundles(patchBundles: Set<File>) {
patchBundles.firstOrNull { !it.exists() }?.let {
throw CommandLine.ParameterException(spec.commandLine(), "Patch bundle ${it.name} does not exist")
2023-08-23 03:08:21 +02:00
}
2024-02-11 21:47:15 +01:00
this.patchBundles = patchBundles
}
2023-08-23 03:08:21 +02:00
@CommandLine.Option(
names = ["--custom-aapt2-binary"],
description = ["Path to a custom AAPT binary to compile resources with."],
)
2023-09-17 03:39:34 +02:00
@Suppress("unused")
private fun setAaptBinaryPath(aaptBinaryPath: File) {
if (!aaptBinaryPath.exists()) {
throw CommandLine.ParameterException(
spec.commandLine(),
"AAPT binary ${aaptBinaryPath.name} does not exist",
)
}
this.aaptBinaryPath = aaptBinaryPath
}
2023-08-23 03:08:21 +02:00
override fun run() {
2023-11-26 04:44:00 +01:00
// region Setup
2023-11-26 05:56:31 +01:00
val outputFilePath =
outputFilePath ?: File("").absoluteFile.resolve(
"${apk.nameWithoutExtension}-patched.${apk.extension}",
)
2023-11-26 04:44:00 +01:00
2024-02-11 21:47:15 +01:00
val temporaryFilesPath =
temporaryFilesPath ?: outputFilePath.parentFile.resolve(
"${outputFilePath.nameWithoutExtension}-temporary-files",
2023-11-26 05:56:31 +01:00
)
2023-11-26 04:44:00 +01:00
2023-11-26 05:56:31 +01:00
val optionsFile =
optionsFile ?: outputFilePath.parentFile.resolve(
"${outputFilePath.nameWithoutExtension}-options.json",
)
2023-11-26 04:44:00 +01:00
2023-11-26 05:56:31 +01:00
val keystoreFilePath =
keystoreFilePath ?: outputFilePath.parentFile
.resolve("${outputFilePath.nameWithoutExtension}.keystore")
2023-11-26 04:44:00 +01:00
// endregion
2023-08-23 03:08:21 +02:00
// region Load patches
logger.info("Loading patches")
val patches = PatchBundleLoader.Jar(*patchBundles.toTypedArray())
// Warn if a patch can not be found in the supplied patch bundles.
if (warn) {
patches.map { it.name }.toHashSet().let { availableNames ->
(includedPatches + excludedPatches).filter { name ->
!availableNames.contains(name)
}
}.let { unknownPatches ->
if (unknownPatches.isEmpty()) return@let
logger.warning("Unknown input of patches:\n${unknownPatches.joinToString("\n")}")
}
}
2023-08-23 03:08:21 +02:00
// endregion
2024-03-11 10:05:32 +01:00
val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher")
2024-02-24 01:10:23 +01:00
val (packageName, patcherResult) = Patcher(
2024-02-11 21:47:15 +01:00
PatcherConfig(
2023-08-23 03:08:21 +02:00
apk,
2024-03-11 10:05:32 +01:00
patcherTemporaryFilesPath,
aaptBinaryPath?.path,
2024-03-11 10:05:32 +01:00
patcherTemporaryFilesPath.absolutePath,
true,
),
).use { patcher ->
2023-11-26 05:56:31 +01:00
val filteredPatches =
patcher.filterPatchSelection(patches).also { patches ->
logger.info("Setting patch options")
if (optionsFile.exists()) {
patches.setOptions(optionsFile)
} else {
Options.serialize(patches, prettyPrint = true).let(optionsFile::writeText)
}
}
// region Patch
2024-02-24 01:10:23 +01:00
patcher.context.packageMetadata.packageName to patcher.apply {
acceptIntegrations(integrations)
acceptPatches(filteredPatches)
// Execute patches.
runBlocking {
apply(false).collect { patchResult ->
patchResult.exception?.let {
StringWriter().use { writer ->
it.printStackTrace(PrintWriter(writer))
logger.severe("${patchResult.patch.name} failed:\n$writer")
}
} ?: logger.info("${patchResult.patch.name} succeeded")
}
2024-02-24 01:10:23 +01:00
}
}.get()
// endregion
2024-02-24 01:10:23 +01:00
}
2023-08-23 03:08:21 +02:00
2024-02-24 01:10:23 +01:00
// region Save
2024-03-11 10:05:32 +01:00
apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply {
patcherResult.applyTo(this)
}.let { patchedApkFile ->
2024-03-11 10:05:32 +01:00
if (!mount) {
sign(
patchedApkFile,
2024-03-11 10:05:32 +01:00
outputFilePath,
ApkUtils.SigningOptions(
keystoreFilePath,
keyStorePassword,
alias,
password,
signer,
),
)
} else {
patchedApkFile.copyTo(outputFilePath, overwrite = true)
2024-03-11 10:05:32 +01:00
}
2024-02-24 01:10:23 +01:00
}
2023-08-23 03:08:21 +02:00
2024-02-24 01:10:23 +01:00
logger.info("Saved to $outputFilePath")
2023-12-01 23:52:05 +01:00
2024-02-24 01:10:23 +01:00
// endregion
2023-08-23 03:08:21 +02:00
2024-02-24 01:10:23 +01:00
// region Install
2024-02-24 01:10:23 +01:00
deviceSerial?.let { serial ->
AdbManager.getAdbManager(deviceSerial = serial.ifEmpty { null }, mount)
}?.install(AdbManager.Apk(outputFilePath, packageName))
2024-02-24 01:10:23 +01:00
// endregion
2023-08-23 03:08:21 +02:00
if (purge) {
logger.info("Purging temporary files")
2024-02-11 21:47:15 +01:00
purge(temporaryFilesPath)
2023-08-23 03:08:21 +02:00
}
}
/**
* Filter the patches to be added to the patcher. The filter is based on the following:
*
* @param patches The patches to filter.
* @return The filtered patches.
*/
2023-11-26 05:56:31 +01:00
private fun Patcher.filterPatchSelection(patches: PatchSet): PatchSet =
buildSet {
val packageName = context.packageMetadata.packageName
val packageVersion = context.packageMetadata.packageVersion
patches.withIndex().forEach patch@{ (i, patch) ->
val patchName = patch.name!!
val explicitlyExcluded = excludedPatches.contains(patchName) || excludedPatchesByIndex.contains(i)
if (explicitlyExcluded) return@patch logger.info("Excluding $patchName")
// Make sure the patch is compatible with the supplied APK files package name and version.
patch.compatiblePackages?.let { packages ->
packages.singleOrNull { it.name == packageName }?.let { `package` ->
val matchesVersion =
force || `package`.versions?.let {
it.any { version -> version == packageVersion }
} ?: true
if (!matchesVersion) {
return@patch logger.warning(
"$patchName is incompatible with version $packageVersion. " +
"This patch is only compatible with version " +
packages.joinToString(";") { pkg ->
pkg.versions!!.joinToString(", ")
},
)
}
} ?: return@patch logger.fine(
"$patchName is incompatible with $packageName. " +
"This patch is only compatible with " +
packages.joinToString(", ") { `package` -> `package`.name },
)
return@let
} ?: logger.fine("$patchName has no constraint on packages.")
// If the patch is implicitly used, it will be only included if [exclusive] is false.
val implicitlyIncluded = !exclusive && patch.use
// If the patch is explicitly used, it will be included even if [exclusive] is false.
val explicitlyIncluded = includedPatches.contains(patchName) || includedPatchesByIndex.contains(i)
val included = implicitlyIncluded || explicitlyIncluded
if (!included) return@patch logger.info("$patchName excluded") // Case 1.
logger.fine("Adding $patchName")
add(patch)
}
2023-08-23 03:08:21 +02:00
}
private fun purge(resourceCachePath: File) {
2023-11-26 05:56:31 +01:00
val result =
if (resourceCachePath.deleteRecursively()) {
"Purged resource cache directory"
} else {
"Failed to purge resource cache directory"
}
2023-08-23 03:08:21 +02:00
logger.info(result)
}
}