mirror of
https://github.com/ReVanced/revanced-cli.git
synced 2026-01-18 08:53:58 +00:00
refactor!: restructure code
This commit focuses on improving code quality in a couple of places and bumping the dependency to ReVanced Patcher. BREAKING CHANGE: This introduces major changes to how ReVanced CLI is used from the command line.
This commit is contained in:
@@ -2,28 +2,26 @@ package app.revanced.cli.command
|
||||
|
||||
import app.revanced.cli.aligning.Aligning
|
||||
import app.revanced.cli.logging.impl.DefaultCliLogger
|
||||
import app.revanced.cli.patcher.Patcher
|
||||
import app.revanced.cli.patcher.logging.impl.PatcherLogger
|
||||
import app.revanced.cli.signing.Signing
|
||||
import app.revanced.cli.signing.SigningOptions
|
||||
import app.revanced.patcher.PatchBundleLoader
|
||||
import app.revanced.patcher.Patcher
|
||||
import app.revanced.patcher.PatcherOptions
|
||||
import app.revanced.patcher.data.Context
|
||||
import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages
|
||||
import app.revanced.patcher.extensions.PatchExtensions.description
|
||||
import app.revanced.patcher.extensions.PatchExtensions.include
|
||||
import app.revanced.patcher.extensions.PatchExtensions.patchName
|
||||
import app.revanced.patcher.patch.Patch
|
||||
import app.revanced.patcher.util.patch.PatchBundle
|
||||
import app.revanced.patcher.patch.PatchClass
|
||||
import app.revanced.utils.Options
|
||||
import app.revanced.utils.Options.setOptions
|
||||
import app.revanced.utils.adb.Adb
|
||||
import app.revanced.utils.adb.AdbManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import picocli.CommandLine.*
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* Alias for return type of [PatchBundle.loadPatches].
|
||||
*/
|
||||
internal typealias PatchList = List<Class<out Patch<Context>>>
|
||||
|
||||
internal typealias PatchList = List<PatchClass>
|
||||
|
||||
private class CLIVersionProvider : IVersionProvider {
|
||||
override fun getVersion() = arrayOf(
|
||||
@@ -42,156 +40,192 @@ internal object MainCommand : Runnable {
|
||||
@ArgGroup(exclusive = false, multiplicity = "1")
|
||||
lateinit var args: Args
|
||||
|
||||
/**
|
||||
* Arguments for the CLI
|
||||
*/
|
||||
class Args {
|
||||
// TODO: Move this so it is not required when listing patches
|
||||
@Option(names = ["-a", "--apk"], description = ["APK file to be patched"], required = true)
|
||||
lateinit var inputFile: File
|
||||
@Option(names = ["--uninstall"], description = ["Package name to uninstall"])
|
||||
var packageName: String? = null
|
||||
|
||||
@Option(names = ["--unmount"], description = ["Unmount a patched APK file"])
|
||||
var unmount: Boolean = false
|
||||
@Option(names = ["-d", "--device-serial"], description = ["ADB device serial number to deploy to"])
|
||||
var deviceSerial: String? = null
|
||||
|
||||
@Option(
|
||||
names = ["-d", "--deploy"],
|
||||
description = ["Deploy to the specified device that is connected via ADB"]
|
||||
)
|
||||
var deploy: String? = null
|
||||
@Option(names = ["--mount"], description = ["Handle deployments by mounting"])
|
||||
var mount: Boolean = false
|
||||
|
||||
@ArgGroup(exclusive = false)
|
||||
var patchArgs: PatchArgs? = null
|
||||
}
|
||||
|
||||
class PatchArgs {
|
||||
@Option(names = ["-b", "--bundle"], description = ["One or more bundles of patches"], required = true)
|
||||
var patchBundles = arrayOf<String>()
|
||||
/**
|
||||
* Arguments for patches.
|
||||
*/
|
||||
class PatchArgs {
|
||||
@Option(names = ["-b", "--bundle"], description = ["One or more bundles of patches"], required = true)
|
||||
var patchBundles = emptyList<File>()
|
||||
|
||||
@Option(names = ["--options"], description = ["Path to patch options JSON file"])
|
||||
var optionsFile: File = File("options.json")
|
||||
@ArgGroup(exclusive = false)
|
||||
var listingArgs: ListingArgs? = null
|
||||
|
||||
@ArgGroup(exclusive = false)
|
||||
var listingArgs: ListingArgs? = null
|
||||
@ArgGroup(exclusive = false)
|
||||
var patchingArgs: PatchingArgs? = null
|
||||
|
||||
@ArgGroup(exclusive = false)
|
||||
var patchingArgs: PatchingArgs? = null
|
||||
}
|
||||
/**
|
||||
* Arguments for patching.
|
||||
*/
|
||||
class PatchingArgs {
|
||||
@Option(names = ["-a", "--apk"], description = ["APK file to be patched"], required = true)
|
||||
lateinit var inputFile: File
|
||||
|
||||
class ListingArgs {
|
||||
@Option(names = ["-l", "--list"], description = ["List patches"], required = true)
|
||||
var listOnly: Boolean = false
|
||||
@Option(
|
||||
names = ["-o", "--out"],
|
||||
description = ["Path to save the patched APK file to"],
|
||||
required = true
|
||||
)
|
||||
lateinit var outputFilePath: File
|
||||
|
||||
@Option(names = ["--with-versions"], description = ["List patches with version compatibilities"])
|
||||
var withVersions: Boolean = false
|
||||
@Option(names = ["--options"], description = ["Path to patch options JSON file"])
|
||||
var optionsFile: File = File("options.json")
|
||||
|
||||
@Option(names = ["--with-packages"], description = ["List patches with package compatibilities"])
|
||||
var withPackages: Boolean = false
|
||||
}
|
||||
@Option(names = ["-e", "--exclude"], description = ["List of patches to exclude"])
|
||||
var excludedPatches = arrayOf<String>()
|
||||
|
||||
class PatchingArgs {
|
||||
@Option(names = ["-o", "--out"], description = ["Path to save the patched APK file to"], required = true)
|
||||
lateinit var outputPath: String
|
||||
@Option(
|
||||
names = ["--exclusive"],
|
||||
description = ["Only include patches that are explicitly specified to be included"]
|
||||
)
|
||||
var exclusive = false
|
||||
|
||||
@Option(names = ["-e", "--exclude"], description = ["Exclude patches"])
|
||||
var excludedPatches = arrayOf<String>()
|
||||
@Option(names = ["-i", "--include"], description = ["List of patches to include"])
|
||||
var includedPatches = arrayOf<String>()
|
||||
|
||||
@Option(
|
||||
names = ["--exclusive"],
|
||||
description = ["Only include patches that were explicitly specified to be included"]
|
||||
)
|
||||
var exclusive = false
|
||||
@Option(names = ["--experimental"], description = ["Ignore patches incompatibility to versions"])
|
||||
var experimental: Boolean = false
|
||||
|
||||
@Option(names = ["-i", "--include"], description = ["Include patches"])
|
||||
var includedPatches = arrayOf<String>()
|
||||
@Option(
|
||||
names = ["-m", "--merge"],
|
||||
description = ["One or more DEX files or containers to merge into the APK"]
|
||||
)
|
||||
var integrations = listOf<File>()
|
||||
|
||||
@Option(names = ["--experimental"], description = ["Ignore patches incompatibility to versions"])
|
||||
var experimental: Boolean = false
|
||||
@Option(names = ["--cn"], description = ["The common name of the signer of the patched APK file"])
|
||||
var commonName = "ReVanced"
|
||||
|
||||
@Option(names = ["-m", "--merge"], description = ["One or more DEX files or containers to merge into the APK"])
|
||||
var mergeFiles = listOf<File>()
|
||||
@Option(
|
||||
names = ["--keystore"],
|
||||
description = ["Path to the keystore to sign the patched APK file with"]
|
||||
)
|
||||
var keystorePath: String? = null
|
||||
|
||||
@Option(
|
||||
names = ["--mount"],
|
||||
description = ["Mount the patched APK file over the original file instead of installing it"]
|
||||
)
|
||||
var mount: Boolean = false
|
||||
@Option(
|
||||
names = ["-p", "--password"],
|
||||
description = ["The password of the keystore to sign the patched APK file with"]
|
||||
)
|
||||
var password = "ReVanced"
|
||||
|
||||
@Option(names = ["--cn"], description = ["The common name of the signer of the patched APK file"])
|
||||
var cn = "ReVanced"
|
||||
@Option(
|
||||
names = ["-r", "--resource-cache"],
|
||||
description = ["Path to temporary resource cache directory"]
|
||||
)
|
||||
var resourceCachePath = File("revanced-resource-cache")
|
||||
|
||||
@Option(names = ["--keystore"], description = ["Path to the keystore to sign the patched APK file with"])
|
||||
var keystorePath: String? = null
|
||||
@Option(
|
||||
names = ["-c", "--clean"],
|
||||
description = ["Clean up the temporary resource cache directory after patching"]
|
||||
)
|
||||
var clean: Boolean = false
|
||||
|
||||
@Option(
|
||||
names = ["-p", "--password"],
|
||||
description = ["The password of the keystore to sign the patched APK file with"]
|
||||
)
|
||||
var password = "ReVanced"
|
||||
@Option(
|
||||
names = ["--custom-aapt2-binary"],
|
||||
description = ["Path to a custom AAPT binary to compile resources with"]
|
||||
)
|
||||
var aaptBinaryPath = File("")
|
||||
}
|
||||
|
||||
@Option(names = ["-t", "--temp-dir"], description = ["Path to temporary resource cache directory"])
|
||||
var cacheDirectory = "revanced-cache"
|
||||
/**
|
||||
* Arguments for printing patches to the console.
|
||||
*/
|
||||
class ListingArgs {
|
||||
@Option(names = ["-l", "--list"], description = ["List patches"], required = true)
|
||||
var listOnly: Boolean = false
|
||||
|
||||
@Option(
|
||||
names = ["-c", "--clean"],
|
||||
description = ["Clean up the temporary resource cache directory after patching"]
|
||||
)
|
||||
var clean: Boolean = false
|
||||
@Option(names = ["--with-versions"], description = ["List patches and their compatible versions"])
|
||||
var withVersions: Boolean = false
|
||||
|
||||
@Option(
|
||||
names = ["--custom-aapt2-binary"],
|
||||
description = ["Path to custom AAPT binary to compile resources with"]
|
||||
)
|
||||
var aaptPath: String = ""
|
||||
@Option(names = ["--with-packages"], description = ["List patches and their compatible packages"])
|
||||
var withPackages: Boolean = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
if (args.patchArgs?.listingArgs?.listOnly == true) return printListOfPatches()
|
||||
if (args.unmount) return unmount()
|
||||
val patchArgs = args.patchArgs
|
||||
|
||||
val pArgs = this.args.patchArgs?.patchingArgs ?: return
|
||||
val outputFile = File(pArgs.outputPath) // the file to write to
|
||||
if (patchArgs?.listingArgs?.listOnly == true) return printListOfPatches()
|
||||
if (args.packageName != null) return uninstall()
|
||||
|
||||
val allPatches = args.patchArgs!!.patchBundles.flatMap { bundle ->
|
||||
PatchBundle.Jar(bundle).loadPatches()
|
||||
val patchingArgs = patchArgs?.patchingArgs ?: return
|
||||
|
||||
if (!patchingArgs.inputFile.exists()) return logger.error("Input file ${patchingArgs.inputFile} does not exist.")
|
||||
|
||||
logger.info("Loading patches")
|
||||
|
||||
val patches = PatchBundleLoader.Jar(*patchArgs.patchBundles.toTypedArray())
|
||||
val integrations = patchingArgs.integrations
|
||||
|
||||
logger.info("Setting up patch options")
|
||||
|
||||
patchingArgs.optionsFile.let {
|
||||
if (it.exists()) patches.setOptions(it, logger)
|
||||
else Options.serialize(patches, prettyPrint = true).let(it::writeText)
|
||||
}
|
||||
|
||||
args.patchArgs!!.optionsFile.let {
|
||||
if (it.exists()) allPatches.setOptions(it, logger)
|
||||
else Options.serialize(allPatches, prettyPrint = true).let(it::writeText)
|
||||
val adbManager = args.deviceSerial?.let { serial ->
|
||||
if (args.mount) AdbManager.RootAdbManager(serial, logger) else AdbManager.UserAdbManager(serial, logger)
|
||||
}
|
||||
|
||||
val patcher = app.revanced.patcher.Patcher(
|
||||
val patcher = Patcher(
|
||||
PatcherOptions(
|
||||
args.inputFile.also { if (!it.exists()) return logger.error("Input file ${args.inputFile} does not exist.") },
|
||||
pArgs.cacheDirectory,
|
||||
pArgs.aaptPath,
|
||||
pArgs.cacheDirectory,
|
||||
patchingArgs.inputFile,
|
||||
patchingArgs.resourceCachePath,
|
||||
patchingArgs.aaptBinaryPath.absolutePath,
|
||||
patchingArgs.resourceCachePath.absolutePath,
|
||||
PatcherLogger
|
||||
)
|
||||
)
|
||||
|
||||
// prepare adb
|
||||
val adb: Adb? = args.deploy?.let {
|
||||
Adb(outputFile, patcher.context.packageMetadata.packageName, args.deploy!!, !pArgs.mount)
|
||||
}
|
||||
val result = patcher.apply {
|
||||
acceptIntegrations(integrations)
|
||||
acceptPatches(filterPatchSelection(patches))
|
||||
|
||||
// start the patcher
|
||||
val result = Patcher.start(patcher, allPatches)
|
||||
// Execute patches.
|
||||
runBlocking {
|
||||
apply(false).collect { patchResult ->
|
||||
patchResult.exception?.let {
|
||||
logger.error("${patchResult.patchName} failed:\n${patchResult.exception}")
|
||||
} ?: logger.info("${patchResult.patchName} succeeded")
|
||||
}
|
||||
}
|
||||
}.get()
|
||||
|
||||
val cacheDirectory = File(pArgs.cacheDirectory)
|
||||
patcher.close()
|
||||
|
||||
// align the file
|
||||
val alignedFile = cacheDirectory.resolve("${outputFile.nameWithoutExtension}_aligned.apk")
|
||||
Aligning.align(result, args.inputFile, alignedFile)
|
||||
val outputFileNameWithoutExtension = patchingArgs.outputFilePath.nameWithoutExtension
|
||||
|
||||
// sign the file
|
||||
val finalFile = if (!pArgs.mount) {
|
||||
val signedOutput = cacheDirectory.resolve("${outputFile.nameWithoutExtension}_signed.apk")
|
||||
// Align the file.
|
||||
val alignedFile = patchingArgs.resourceCachePath.resolve("${outputFileNameWithoutExtension}_aligned.apk")
|
||||
Aligning.align(result, patchingArgs.inputFile, alignedFile)
|
||||
|
||||
// Sign the file if needed.
|
||||
val finalFile = if (!args.mount) {
|
||||
val signedOutput = patchingArgs.resourceCachePath.resolve("${outputFileNameWithoutExtension}_signed.apk")
|
||||
Signing.sign(
|
||||
alignedFile,
|
||||
signedOutput,
|
||||
SigningOptions(
|
||||
pArgs.cn,
|
||||
pArgs.password,
|
||||
pArgs.keystorePath ?: outputFile.absoluteFile.parentFile
|
||||
.resolve("${outputFile.nameWithoutExtension}.keystore")
|
||||
patchingArgs.commonName,
|
||||
patchingArgs.password,
|
||||
patchingArgs.keystorePath ?: patchingArgs.outputFilePath.absoluteFile.parentFile
|
||||
.resolve("${patchingArgs.outputFilePath.nameWithoutExtension}.keystore")
|
||||
.canonicalPath
|
||||
)
|
||||
)
|
||||
@@ -200,46 +234,41 @@ internal object MainCommand : Runnable {
|
||||
} else
|
||||
alignedFile
|
||||
|
||||
// finally copy to the specified output file
|
||||
logger.info("Copying ${finalFile.name} to ${outputFile.name}")
|
||||
finalFile.copyTo(outputFile, overwrite = true)
|
||||
logger.info("Copying ${finalFile.name} to ${patchingArgs.outputFilePath.name}")
|
||||
|
||||
// clean up the cache directory if needed
|
||||
if (pArgs.clean)
|
||||
cleanUp(pArgs.cacheDirectory)
|
||||
finalFile.copyTo(patchingArgs.outputFilePath, overwrite = true)
|
||||
adbManager?.install(AdbManager.Apk(patchingArgs.outputFilePath, patcher.context.packageMetadata.packageName))
|
||||
|
||||
// deploy if specified
|
||||
adb?.deploy()
|
||||
|
||||
if (pArgs.clean && args.deploy != null) Files.delete(outputFile.toPath())
|
||||
|
||||
logger.info("Finished")
|
||||
if (patchingArgs.clean) {
|
||||
logger.info("Cleaning up temporary files")
|
||||
patchingArgs.outputFilePath.delete()
|
||||
cleanUp(patchingArgs.resourceCachePath)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanUp(cacheDirectory: String) {
|
||||
val result = if (File(cacheDirectory).deleteRecursively())
|
||||
private fun cleanUp(resourceCachePath: File) {
|
||||
val result = if (resourceCachePath.deleteRecursively())
|
||||
"Cleaned up cache directory"
|
||||
else
|
||||
"Failed to clean up cache directory"
|
||||
logger.info(result)
|
||||
}
|
||||
|
||||
private fun unmount() {
|
||||
val adb: Adb? = args.deploy?.let {
|
||||
Adb(
|
||||
File("placeholder_file"),
|
||||
app.revanced.patcher.Patcher(PatcherOptions(args.inputFile, "")).context.packageMetadata.packageName,
|
||||
args.deploy!!,
|
||||
false
|
||||
)
|
||||
}
|
||||
adb?.uninstall()
|
||||
}
|
||||
/**
|
||||
* Uninstall the specified package from the specified device.
|
||||
*
|
||||
*/
|
||||
private fun uninstall() = args.deviceSerial?.let { serial ->
|
||||
if (args.mount) {
|
||||
AdbManager.RootAdbManager(serial, logger)
|
||||
} else {
|
||||
AdbManager.UserAdbManager(serial, logger)
|
||||
}.uninstall(args.packageName!!)
|
||||
} ?: logger.error("No device serial specified")
|
||||
|
||||
private fun printListOfPatches() {
|
||||
val logged = mutableListOf<String>()
|
||||
for (patchBundlePath in args.patchArgs?.patchBundles!!) for (patch in PatchBundle.Jar(patchBundlePath)
|
||||
.loadPatches()) {
|
||||
for (patch in PatchBundleLoader.Jar(*args.patchArgs!!.patchBundles.toTypedArray())) {
|
||||
if (patch.patchName in logged) continue
|
||||
for (compatiblePackage in patch.compatiblePackages ?: continue) {
|
||||
val packageEntryStr = buildString {
|
||||
@@ -271,4 +300,78 @@ internal object MainCommand : Runnable {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Patcher.filterPatchSelection(patches: PatchList) = buildList {
|
||||
val packageName = context.packageMetadata.packageName
|
||||
val packageVersion = context.packageMetadata.packageVersion
|
||||
val patchingArgs = args.patchArgs!!.patchingArgs!!
|
||||
|
||||
patches.forEach patch@{ patch ->
|
||||
val formattedPatchName = patch.patchName.lowercase().replace(" ", "-")
|
||||
|
||||
/**
|
||||
* Check if the patch is explicitly excluded.
|
||||
*
|
||||
* Cases:
|
||||
* 1. -e patch.name
|
||||
* 2. -i patch.name -e patch.name
|
||||
*/
|
||||
|
||||
val excluded = patchingArgs.excludedPatches.contains(formattedPatchName)
|
||||
if (excluded) return@patch logger.info("Excluding ${patch.patchName}")
|
||||
|
||||
/**
|
||||
* Check if the patch is constrained to packages.
|
||||
*/
|
||||
|
||||
patch.compatiblePackages?.let { packages ->
|
||||
packages.singleOrNull { it.name == packageName }?.let { `package` ->
|
||||
/**
|
||||
* Check if the package version matches.
|
||||
* If experimental is true, version matching will be skipped.
|
||||
*/
|
||||
|
||||
val matchesVersion = patchingArgs.experimental || `package`.versions.let {
|
||||
it.isEmpty() || it.any { version -> version == packageVersion }
|
||||
}
|
||||
|
||||
if (!matchesVersion) return@patch logger.warn(
|
||||
"${patch.patchName} is incompatible with version $packageVersion. " +
|
||||
"This patch is only compatible with version " +
|
||||
packages.joinToString(";") { `package` ->
|
||||
"${`package`.name}: ${`package`.versions.joinToString(", ")}"
|
||||
}
|
||||
)
|
||||
|
||||
} ?: return@patch logger.trace(
|
||||
"${patch.patchName} is incompatible with $packageName. " +
|
||||
"This patch is only compatible with " +
|
||||
packages.joinToString(", ") { `package` -> `package`.name }
|
||||
)
|
||||
|
||||
return@let
|
||||
} ?: logger.trace("$formattedPatchName: No constraint on packages.")
|
||||
|
||||
/**
|
||||
* Check if the patch is explicitly included.
|
||||
*
|
||||
* Cases:
|
||||
* 1. --exclusive
|
||||
* 2. --exclusive -i patch.name
|
||||
*/
|
||||
|
||||
val exclusive = patchingArgs.exclusive
|
||||
val explicitlyIncluded = patchingArgs.includedPatches.contains(formattedPatchName)
|
||||
|
||||
val implicitlyIncluded = !exclusive && patch.include // Case 3.
|
||||
val exclusivelyIncluded = exclusive && explicitlyIncluded // Case 2.
|
||||
|
||||
val included = implicitlyIncluded || exclusivelyIncluded
|
||||
if (!included) return@patch logger.info("${patch.patchName} excluded by default") // Case 1.
|
||||
|
||||
logger.trace("Adding $formattedPatchName")
|
||||
|
||||
add(patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user