feat: Set patch options via CLI (#336)

BREAKING CHANGE: This commit changes various CLI options and removes the `options.json` file. Instead, patch options can now be passed via CLI options
This commit is contained in:
oSumAtrIX
2024-08-13 00:12:45 +04:00
committed by GitHub
parent 54ae01cd76
commit 23002434b2
10 changed files with 409 additions and 203 deletions

View File

@@ -0,0 +1,105 @@
package app.revanced.cli.command
import picocli.CommandLine
class OptionKeyConverter : CommandLine.ITypeConverter<String> {
override fun convert(value: String): String = value
}
class OptionValueConverter : CommandLine.ITypeConverter<Any?> {
override fun convert(value: String?): Any? {
value ?: return null
return when {
value.startsWith("[") && value.endsWith("]") -> {
val innerValue = value.substring(1, value.length - 1)
buildList {
var nestLevel = 0
var insideQuote = false
var escaped = false
val item = buildString {
for (char in innerValue) {
when (char) {
'\\' -> {
if (escaped || nestLevel != 0) {
append(char)
}
escaped = !escaped
}
'"', '\'' -> {
if (!escaped) {
insideQuote = !insideQuote
} else {
escaped = false
}
append(char)
}
'[' -> {
if (!insideQuote) {
nestLevel++
}
append(char)
}
']' -> {
if (!insideQuote) {
nestLevel--
if (nestLevel == -1) {
return value
}
}
append(char)
}
',' -> if (nestLevel == 0) {
if (insideQuote) {
append(char)
} else {
add(convert(toString()))
setLength(0)
}
} else {
append(char)
}
else -> append(char)
}
}
}
if (item.isNotEmpty()) {
add(convert(item))
}
}
}
value.startsWith("\"") && value.endsWith("\"") -> value.substring(1, value.length - 1)
value.startsWith("'") && value.endsWith("'") -> value.substring(1, value.length - 1)
value.endsWith("f") -> value.dropLast(1).toFloat()
value.endsWith("L") -> value.dropLast(1).toLong()
value.equals("true", ignoreCase = true) -> true
value.equals("false", ignoreCase = true) -> false
value.toIntOrNull() != null -> value.toInt()
value.toLongOrNull() != null -> value.toLong()
value.toDoubleOrNull() != null -> value.toDouble()
value.toFloatOrNull() != null -> value.toFloat()
value == "null" -> null
value == "int[]" -> emptyList<Int>()
value == "long[]" -> emptyList<Long>()
value == "double[]" -> emptyList<Double>()
value == "float[]" -> emptyList<Float>()
value == "boolean[]" -> emptyList<Boolean>()
value == "string[]" -> emptyList<String>()
else -> value
}
}
}

View File

@@ -1,8 +1,8 @@
package app.revanced.cli.command
import app.revanced.library.PackageName
import app.revanced.library.PatchUtils
import app.revanced.library.VersionMap
import app.revanced.library.mostCommonCompatibleVersions
import app.revanced.patcher.patch.loadPatchesFromJar
import picocli.CommandLine
import java.io.File
@@ -12,11 +12,11 @@ import java.util.logging.Logger
name = "list-versions",
description = [
"List the most common compatible versions of apps that are compatible " +
"with the patches in the supplied patch bundles.",
"with the patches in the supplied patch bundles.",
],
)
internal class ListCompatibleVersions : Runnable {
private val logger = Logger.getLogger(ListCompatibleVersions::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@CommandLine.Parameters(
description = ["Paths to patch bundles."],
@@ -58,8 +58,7 @@ internal class ListCompatibleVersions : Runnable {
val patches = loadPatchesFromJar(patchBundles)
PatchUtils.getMostCommonCompatibleVersions(
patches,
patches.mostCommonCompatibleVersions(
packageNames,
countUnusedPatches,
).entries.joinToString("\n", transform = ::buildString).let(logger::info)

View File

@@ -14,7 +14,7 @@ import app.revanced.patcher.patch.Option as PatchOption
description = ["List patches from supplied patch bundles."],
)
internal object ListPatchesCommand : Runnable {
private val logger = Logger.getLogger(ListPatchesCommand::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@Parameters(
description = ["Paths to patch bundles."],
@@ -95,9 +95,11 @@ internal object ListPatchesCommand : Runnable {
} ?: append("Key: $key")
values?.let { values ->
appendLine("\nValid values:")
appendLine("\nPossible values:")
append(values.map { "${it.value} (${it.key})" }.joinToString("\n").prependIndent("\t"))
}
append("\nType: $type")
}
fun IndexedValue<Patch<*>>.buildString() =

View File

@@ -34,7 +34,6 @@ private object CLIVersionProvider : IVersionProvider {
versionProvider = CLIVersionProvider::class,
subcommands = [
PatchCommand::class,
OptionsCommand::class,
ListPatchesCommand::class,
ListCompatibleVersions::class,
UtilityCommand::class,

View File

@@ -1,62 +0,0 @@
package app.revanced.cli.command
import app.revanced.library.Options
import app.revanced.library.Options.setOptions
import app.revanced.patcher.patch.loadPatchesFromJar
import picocli.CommandLine
import picocli.CommandLine.Help.Visibility.ALWAYS
import java.io.File
import java.util.logging.Logger
@CommandLine.Command(
name = "options",
description = ["Generate options file from patches."],
)
internal object OptionsCommand : Runnable {
private val logger = Logger.getLogger(OptionsCommand::class.java.name)
@CommandLine.Parameters(
description = ["Paths to patch bundles."],
arity = "1..*",
)
private lateinit var patchBundles: Set<File>
@CommandLine.Option(
names = ["-p", "--path"],
description = ["Path to patch options JSON file."],
showDefaultValue = ALWAYS,
)
private var filePath: File = File("options.json")
@CommandLine.Option(
names = ["-o", "--overwrite"],
description = ["Overwrite existing options file."],
showDefaultValue = ALWAYS,
)
private var overwrite: Boolean = false
@CommandLine.Option(
names = ["-u", "--update"],
description = ["Update existing options by adding missing and removing non-existent options."],
showDefaultValue = ALWAYS,
)
private var update: Boolean = false
override fun run() =
try {
loadPatchesFromJar(patchBundles).let { patches ->
val exists = filePath.exists()
if (!exists || overwrite) {
if (exists && update) patches.setOptions(filePath)
Options.serialize(patches, prettyPrint = true).let(filePath::writeText)
} else {
throw OptionsFileAlreadyExistsException()
}
}
} catch (ex: OptionsFileAlreadyExistsException) {
logger.severe("Options file already exists, use --overwrite to override it")
}
class OptionsFileAlreadyExistsException : Exception()
}

View File

@@ -2,15 +2,15 @@ package app.revanced.cli.command
import app.revanced.library.ApkUtils
import app.revanced.library.ApkUtils.applyTo
import app.revanced.library.Options
import app.revanced.library.Options.setOptions
import app.revanced.library.installation.installer.*
import app.revanced.library.setOptions
import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherConfig
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.loadPatchesFromJar
import kotlinx.coroutines.runBlocking
import picocli.CommandLine
import picocli.CommandLine.ArgGroup
import picocli.CommandLine.Help.Visibility.ALWAYS
import picocli.CommandLine.Model.CommandSpec
import picocli.CommandLine.Spec
@@ -24,44 +24,71 @@ import java.util.logging.Logger
description = ["Patch an APK file."],
)
internal object PatchCommand : Runnable {
private val logger = Logger.getLogger(PatchCommand::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@Spec
lateinit var spec: CommandSpec // injected by picocli
private lateinit var spec: CommandSpec
private lateinit var apk: File
@ArgGroup(multiplicity = "0..*")
private lateinit var selection: Set<Selection>
private var patchBundles = emptySet<File>()
internal class Selection {
@ArgGroup(exclusive = false, multiplicity = "1")
internal var include: IncludeSelection? = null
@CommandLine.Option(
names = ["-i", "--include"],
description = ["List of patches to include."],
)
private var includedPatches = hashSetOf<String>()
internal class IncludeSelection {
@ArgGroup(multiplicity = "1")
internal lateinit var selector: IncludeSelector
@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>()
internal class IncludeSelector {
@CommandLine.Option(
names = ["-i", "--include"],
description = ["The name of the patch."],
required = true,
)
internal var name: String? = null
@CommandLine.Option(
names = ["-e", "--exclude"],
description = ["List of patches to exclude."],
)
private var excludedPatches = hashSetOf<String>()
@CommandLine.Option(
names = ["--ii"],
description = ["The index of the patch in the combined list of all supplied patch bundles."],
required = true,
)
internal var index: Int? = null
}
@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>()
@CommandLine.Option(
names = ["-O", "--options"],
description = ["The option values keyed by the option keys."],
mapFallbackValue = CommandLine.Option.NULL_VALUE,
converter = [OptionKeyConverter::class, OptionValueConverter::class],
)
internal var options = mutableMapOf<String, Any?>()
}
@CommandLine.Option(
names = ["--options"],
description = ["Path to patch options JSON file."],
)
private var optionsFile: File? = null
@ArgGroup(exclusive = false, multiplicity = "1")
internal var exclude: ExcludeSelection? = null
internal class ExcludeSelection {
@ArgGroup(multiplicity = "1")
internal lateinit var selector: ExcludeSelector
internal class ExcludeSelector {
@CommandLine.Option(
names = ["-e", "--exclude"],
description = ["The name of the patch."],
required = true,
)
internal var name: String? = null
@CommandLine.Option(
names = ["--ie"],
description = ["The index of the patch in the combined list of all supplied patch bundles."],
required = true,
)
internal var index: Int? = null
}
}
}
@CommandLine.Option(
names = ["--exclusive"],
@@ -141,7 +168,7 @@ internal object PatchCommand : Runnable {
@CommandLine.Option(
names = ["-t", "--temporary-files-path"],
description = ["Path to temporary files directory."],
description = ["Path to store temporary files."],
)
private var temporaryFilesPath: File? = null
@@ -154,13 +181,6 @@ internal object PatchCommand : Runnable {
)
private var purge: Boolean = false
@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",
@@ -176,14 +196,7 @@ internal object PatchCommand : Runnable {
this.apk = apk
}
@CommandLine.Option(
names = ["-m", "--merge"],
description = ["One or more DEX files or containers to merge into the APK."],
)
@Suppress("unused")
private fun setIntegrations(integrations: Set<File>) {
logger.warning("The --merge option is not used anymore.")
}
private lateinit var apk: File
@CommandLine.Option(
names = ["-b", "--patch-bundle"],
@@ -198,6 +211,8 @@ internal object PatchCommand : Runnable {
this.patchBundles = patchBundles
}
private var patchBundles = emptySet<File>()
@CommandLine.Option(
names = ["--custom-aapt2-binary"],
description = ["Path to a custom AAPT binary to compile resources with."],
@@ -226,11 +241,6 @@ internal object PatchCommand : Runnable {
"${outputFilePath.nameWithoutExtension}-temporary-files",
)
val optionsFile =
optionsFile ?: outputFilePath.parentFile.resolve(
"${outputFilePath.nameWithoutExtension}-options.json",
)
val keystoreFilePath =
keystoreFilePath ?: outputFilePath.parentFile
.resolve("${outputFilePath.nameWithoutExtension}.keystore")
@@ -243,21 +253,10 @@ internal object PatchCommand : Runnable {
val patches = loadPatchesFromJar(patchBundles)
// 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")}")
}
}
// endregion
val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher")
val (packageName, patcherResult) = Patcher(
PatcherConfig(
apk,
@@ -267,16 +266,20 @@ internal object PatchCommand : Runnable {
true,
),
).use { patcher ->
val filteredPatches =
patcher.filterPatchSelection(patches).also { patches ->
logger.info("Setting patch options")
val packageName = patcher.context.packageMetadata.packageName
val packageVersion = patcher.context.packageMetadata.packageVersion
if (optionsFile.exists()) {
patches.setOptions(optionsFile)
} else {
Options.serialize(patches, prettyPrint = true).let(optionsFile::writeText)
}
}
val filteredPatches = patches.filterPatchSelection(packageName, packageVersion)
logger.info("Setting patch options")
val patchesList = patches.toList()
selection.filter { it.include != null }.associate {
val includeSelection = it.include!!
(includeSelection.selector.name ?: patchesList[includeSelection.selector.index!!].name!!) to
includeSelection.options
}.let(filteredPatches::setOptions)
patcher += filteredPatches
@@ -297,7 +300,7 @@ internal object PatchCommand : Runnable {
patcher.context.packageMetadata.packageName to patcher.get()
}
// region Save
// region Save.
apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply {
patcherResult.applyTo(this)
@@ -323,9 +326,9 @@ internal object PatchCommand : Runnable {
// endregion
// region Install
// region Install.
deviceSerial?.let { it ->
deviceSerial?.let {
val deviceSerial = it.ifEmpty { null }
runBlocking {
@@ -352,64 +355,72 @@ internal object PatchCommand : Runnable {
}
/**
* Filter the patches to be added to the patcher. The filter is based on the following:
* Filter the patches based on the selection.
*
* @param patches The patches to filter.
* @param packageName The package name of the APK file to be patched.
* @param packageVersion The version of the APK file to be patched.
* @return The filtered patches.
*/
private fun Patcher.filterPatchSelection(patches: Set<Patch<*>>): Set<Patch<*>> =
buildSet {
val packageName = context.packageMetadata.packageName
val packageVersion = context.packageMetadata.packageVersion
private fun Set<Patch<*>>.filterPatchSelection(
packageName: String,
packageVersion: String,
): Set<Patch<*>> = buildSet {
val includedPatchesByName =
selection.asSequence().mapNotNull { it.include?.selector?.name }.toSet()
val includedPatchesByIndex =
selection.asSequence().mapNotNull { it.include?.selector?.index }.toSet()
patches.withIndex().forEach patch@{ (i, patch) ->
val patchName = patch.name!!
val excludedPatches =
selection.asSequence().mapNotNull { it.exclude?.selector?.name }.toSet()
val excludedPatchesByIndex =
selection.asSequence().mapNotNull { it.exclude?.selector?.index }.toSet()
val explicitlyExcluded = excludedPatches.contains(patchName) || excludedPatchesByIndex.contains(i)
if (explicitlyExcluded) return@patch logger.info("\"$patchName\" excluded manually")
this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) ->
val patchName = patch.name!!
// Make sure the patch is compatible with the supplied APK files package name and version.
patch.compatiblePackages?.let { packages ->
packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) ->
if (versions?.isEmpty() == true) {
return@patch logger.warning("\"$patchName\" incompatible with \"$packageName\"")
}
val isManuallyExcluded = patchName in excludedPatches || i in excludedPatchesByIndex
if (isManuallyExcluded) return@patchLoop logger.info("\"$patchName\" excluded manually")
val matchesVersion = force ||
versions?.let { it.any { version -> version == packageVersion } }
?: true
// Make sure the patch is compatible with the supplied APK files package name and version.
patch.compatiblePackages?.let { packages ->
packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) ->
if (versions?.isEmpty() == true) {
return@patchLoop logger.warning("\"$patchName\" incompatible with \"$packageName\"")
}
if (!matchesVersion) {
return@patch logger.warning(
"\"$patchName\" incompatible with $packageName $packageVersion " +
"but compatible with " +
packages.joinToString("; ") { (packageName, versions) ->
packageName + " " + versions!!.joinToString(", ")
},
)
}
} ?: return@patch logger.fine(
"\"$patchName\" incompatible with $packageName. " +
"It is only compatible with " +
packages.joinToString(", ") { (name, _) -> name },
)
val matchesVersion =
force || versions?.let { it.any { version -> version == packageVersion } } ?: true
return@let
} ?: logger.fine("\"$patchName\" has no package constraints")
if (!matchesVersion) {
return@patchLoop logger.warning(
"\"$patchName\" incompatible with $packageName $packageVersion " +
"but compatible with " +
packages.joinToString("; ") { (packageName, versions) ->
packageName + " " + versions!!.joinToString(", ")
},
)
}
} ?: return@patchLoop logger.fine(
"\"$patchName\" incompatible with $packageName. " +
"It is only compatible with " +
packages.joinToString(", ") { (name, _) -> name },
)
// 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)
return@let
} ?: logger.fine("\"$patchName\" has no package constraints")
val included = implicitlyIncluded || explicitlyIncluded
if (!included) return@patch logger.info("\"$patchName\" excluded") // Case 1.
val isIncluded = !exclusive && patch.use
val isManuallyIncluded = patchName in includedPatchesByName || i in includedPatchesByIndex
add(patch)
logger.fine("\"$patchName\" added")
if (!(isIncluded || isManuallyIncluded)) {
return@patchLoop logger.info("\"$patchName\" excluded")
}
add(patch)
logger.fine("\"$patchName\" added")
}
}
private fun purge(resourceCachePath: File) {
val result =

View File

@@ -13,7 +13,7 @@ import java.util.logging.Logger
description = ["Install an APK file to devices with the supplied ADB device serials"],
)
internal object InstallCommand : Runnable {
private val logger = Logger.getLogger(InstallCommand::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@Parameters(
description = ["ADB device serials. If not supplied, the first connected device will be used."],

View File

@@ -1,6 +1,9 @@
package app.revanced.cli.command.utility
import app.revanced.library.installation.installer.*
import app.revanced.library.installation.installer.AdbInstaller
import app.revanced.library.installation.installer.AdbInstallerResult
import app.revanced.library.installation.installer.AdbRootInstaller
import app.revanced.library.installation.installer.RootInstallerResult
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
@@ -13,7 +16,7 @@ import java.util.logging.Logger
description = ["Uninstall a patched app from the devices with the supplied ADB device serials"],
)
internal object UninstallCommand : Runnable {
private val logger = Logger.getLogger(UninstallCommand::class.java.name)
private val logger = Logger.getLogger(this::class.java.name)
@Parameters(
description = ["ADB device serials. If not supplied, the first connected device will be used."],