feat: use separate command to patch

This commit is contained in:
oSumAtrIX
2023-08-23 03:08:21 +02:00
parent 8a5daab2a3
commit 32da961d57
12 changed files with 547 additions and 112 deletions

View File

@@ -1,37 +0,0 @@
package app.revanced.cli.aligning
import app.revanced.cli.command.MainCommand.logger
import app.revanced.patcher.PatcherResult
import app.revanced.utils.signing.align.ZipAligner
import app.revanced.utils.signing.align.zip.ZipFile
import app.revanced.utils.signing.align.zip.structures.ZipEntry
import java.io.File
object Aligning {
fun align(result: PatcherResult, inputFile: File, outputFile: File) {
logger.info("Aligning ${inputFile.name} to ${outputFile.name}")
if (outputFile.exists()) outputFile.delete()
ZipFile(outputFile).use { file ->
result.dexFiles.forEach {
file.addEntryCompressData(
ZipEntry.createWithName(it.name),
it.stream.readBytes()
)
}
result.resourceFile?.let {
file.copyEntriesFromFileAligned(
ZipFile(it),
ZipAligner::getEntryAlignment
)
}
file.copyEntriesFromFileAligned(
ZipFile(inputFile),
ZipAligner::getEntryAlignment
)
}
}
}

View File

@@ -0,0 +1,39 @@
package app.revanced.cli.command
import app.revanced.cli.logging.impl.DefaultCliLogger
import app.revanced.patcher.patch.PatchClass
import picocli.CommandLine
import picocli.CommandLine.Command
import picocli.CommandLine.IVersionProvider
import java.util.*
fun main(args: Array<String>) {
CommandLine(Main).execute(*args)
}
internal typealias PatchList = List<PatchClass>
internal val logger = DefaultCliLogger()
object CLIVersionProvider : IVersionProvider {
override fun getVersion(): Array<String> {
Properties().apply {
load(Main::class.java.getResourceAsStream("/app/revanced/cli/version.properties"))
}.let {
return arrayOf("ReVanced CLI v${it.getProperty("version")}")
}
}
}
@Command(
name = "revanced-cli",
description = ["Command line application to use ReVanced"],
mixinStandardHelpOptions = true,
versionProvider = CLIVersionProvider::class,
subcommands = [
ListPatchesCommand::class,
PatchCommand::class,
UninstallCommand::class
]
)
internal object Main

View File

@@ -0,0 +1,412 @@
package app.revanced.cli.command
import app.revanced.cli.patcher.logging.impl.PatcherLogger
import app.revanced.patcher.PatchBundleLoader
import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherOptions
import app.revanced.patcher.PatcherResult
import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages
import app.revanced.patcher.extensions.PatchExtensions.include
import app.revanced.patcher.extensions.PatchExtensions.patchName
import app.revanced.utils.Options
import app.revanced.utils.Options.setOptions
import app.revanced.utils.adb.AdbManager
import app.revanced.utils.align.ZipAligner
import app.revanced.utils.align.zip.ZipFile
import app.revanced.utils.align.zip.structures.ZipEntry
import app.revanced.utils.signing.ApkSigner
import app.revanced.utils.signing.SigningOptions
import kotlinx.coroutines.runBlocking
import picocli.CommandLine
import picocli.CommandLine.Help.Visibility.ALWAYS
import java.io.File
@CommandLine.Command(
name = "patch",
description = ["Patch the supplied APK file with the supplied patches and integrations"]
)
internal object PatchCommand: Runnable {
@CommandLine.Parameters(
description = ["APK file to be patched"],
arity = "1..1"
)
lateinit var apk: File
@CommandLine.Option(
names = ["-b", "--bundle"],
description = ["One or more bundles of patches"],
required = true
)
var patchBundles = emptyList<File>()
@CommandLine.Option(
names = ["-m", "--merge"],
description = ["One or more DEX files or containers to merge into the APK"]
)
var integrations = listOf<File>()
@CommandLine.Option(
names = ["-i", "--include"],
description = ["List of patches to include"]
)
var includedPatches = arrayOf<String>()
@CommandLine.Option(
names = ["-e", "--exclude"],
description = ["List of patches to exclude"]
)
var excludedPatches = arrayOf<String>()
@CommandLine.Option(
names = ["--options"],
description = ["Path to patch options JSON file"],
showDefaultValue = ALWAYS
)
var optionsFile: File = File("options.json")
@CommandLine.Option(
names = ["--exclusive"],
description = ["Only include patches that are explicitly specified to be included"],
showDefaultValue = ALWAYS
)
var exclusive = false
@CommandLine.Option(
names = ["--experimental"],
description = ["Ignore patches incompatibility to versions"],
showDefaultValue = ALWAYS
)
var experimental: Boolean = false
@CommandLine.Option(
names = ["-o", "--out"],
description = ["Path to save the patched APK file to"],
required = true
)
lateinit var outputFilePath: File
@CommandLine.Option(
names = ["-d", "--device-serial"],
description = ["ADB device serial to install to"],
showDefaultValue = ALWAYS
)
var deviceSerial: String? = null
@CommandLine.Option(
names = ["--mount"],
description = ["Install by mounting the patched package"],
showDefaultValue = ALWAYS
)
var mount: Boolean = false
@CommandLine.Option(
names = ["--common-name"],
description = ["The common name of the signer of the patched APK file"],
showDefaultValue = ALWAYS
)
var commonName = "ReVanced"
@CommandLine.Option(
names = ["--keystore"],
description = ["Path to the keystore to sign the patched APK file with"]
)
var keystorePath: String? = null
@CommandLine.Option(
names = ["--password"],
description = ["The password of the keystore to sign the patched APK file with"]
)
var password = "ReVanced"
@CommandLine.Option(
names = ["-r", "--resource-cache"],
description = ["Path to temporary resource cache directory"],
showDefaultValue = ALWAYS
)
var resourceCachePath = File("revanced-resource-cache")
@CommandLine.Option(
names = ["--custom-aapt2-binary"],
description = ["Path to a custom AAPT binary to compile resources with"]
)
var aaptBinaryPath = File("")
@CommandLine.Option(
names = ["-p", "--purge"],
description = ["Purge the temporary resource cache directory after patching"],
showDefaultValue = ALWAYS
)
var purge: Boolean = false
override fun run() {
// region Prepare
if (!apk.exists()) {
logger.error("Input file ${apk.name} does not exist")
return
}
val adbManager = deviceSerial?.let { serial ->
if (mount) AdbManager.RootAdbManager(serial, logger) else AdbManager.UserAdbManager(
serial,
logger
)
}
// endregion
// region Load patches
logger.info("Loading patches")
val patches = PatchBundleLoader.Jar(*patchBundles.toTypedArray())
val integrations = integrations
logger.info("Setting up patch options")
optionsFile.let {
if (it.exists()) patches.setOptions(it, logger)
else Options.serialize(patches, prettyPrint = true).let(it::writeText)
}
// endregion
// region Patch
val patcher = Patcher(
PatcherOptions(
apk,
resourceCachePath,
aaptBinaryPath.absolutePath,
resourceCachePath.absolutePath,
PatcherLogger
)
)
val result = patcher.apply {
acceptIntegrations(integrations)
acceptPatches(filterPatchSelection(patches))
// Execute patches.
runBlocking {
apply(false).collect { patchResult ->
patchResult.exception?.let {
logger.error("${patchResult.patchName} failed:\n${patchResult.exception}")
} ?: logger.info("${patchResult.patchName} succeeded")
}
}
}.get()
patcher.close()
// endregion
// region Finish
val alignAndSignedFile = sign(
apk.newAlignedFile(
result,
resourceCachePath.resolve("${outputFilePath.nameWithoutExtension}_aligned.apk")
)
)
logger.info("Copying to ${outputFilePath.name}")
alignAndSignedFile.copyTo(outputFilePath, overwrite = true)
adbManager?.install(AdbManager.Apk(outputFilePath, patcher.context.packageMetadata.packageName))
if (purge) {
logger.info("Purging temporary files")
outputFilePath.delete()
purge(resourceCachePath)
}
// endregion
}
/**
* Filter the patches to be added to the patcher. The filter is based on the following:
* - [includedPatches] (explicitly included)
* - [excludedPatches] (explicitly excluded)
* - [exclusive] (only include patches that are explicitly included)
* - [experimental] (ignore patches incompatibility to versions)
* - package name and version of the input APK file (if [experimental] is false)
*
* @param patches The patches to filter.
* @return The filtered patches.
*/
private fun Patcher.filterPatchSelection(patches: PatchList) = buildList {
val packageName = context.packageMetadata.packageName
val packageVersion = context.packageMetadata.packageVersion
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
*/
/**
* Check if the patch is explicitly excluded.
*
* Cases:
* 1. -e patch.name
* 2. -i patch.name -e patch.name
*/
val excluded = excludedPatches.contains(formattedPatchName)
if (excluded) return@patch logger.info("Excluding ${patch.patchName}")
/**
* Check if the patch is constrained to packages.
*/
/**
* 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.
*/
/**
* Check if the package version matches.
* If experimental is true, version matching will be skipped.
*/
val matchesVersion = 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
*/
/**
* Check if the patch is explicitly included.
*
* Cases:
* 1. --exclusive
* 2. --exclusive -i patch.name
*/
val explicitlyIncluded = 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)
}
}
/**
* Create a new aligned APK file.
*
* @param result The result of the patching process.
* @param outputFile The file to save the aligned APK to.
*/
private fun File.newAlignedFile(
result: PatcherResult,
outputFile: File
): File {
logger.info("Aligning $name to ${outputFile.name}")
if (outputFile.exists()) outputFile.delete()
ZipFile(outputFile).use { file ->
result.dexFiles.forEach {
file.addEntryCompressData(
ZipEntry.createWithName(it.name),
it.stream.readBytes()
)
}
result.resourceFile?.let {
file.copyEntriesFromFileAligned(
ZipFile(it),
ZipAligner::getEntryAlignment
)
}
// TODO: Do not compress result.doNotCompress
file.copyEntriesFromFileAligned(
ZipFile(this),
ZipAligner::getEntryAlignment
)
}
return outputFile
}
/**
* Sign the APK file.
*
* @param inputFile The APK file to sign.
* @return The signed APK file. If [mount] is true, the input file will be returned.
*/
private fun sign(inputFile: File) = if (mount)
inputFile
else {
logger.info("Signing ${inputFile.name}")
val keyStoreFilePath = keystorePath ?: outputFilePath
.absoluteFile.parentFile.resolve("${outputFilePath.nameWithoutExtension}.keystore").canonicalPath
val options = SigningOptions(
commonName,
password,
keyStoreFilePath
)
ApkSigner(options)
.signApk(
inputFile,
resourceCachePath.resolve("${outputFilePath.nameWithoutExtension}_signed.apk")
)
}
private fun purge(resourceCachePath: File) {
val result = if (resourceCachePath.deleteRecursively())
"Purged resource cache directory"
else
"Failed to purge resource cache directory"
logger.info(result)
}
}

View File

@@ -1,6 +1,6 @@
package app.revanced.utils.signing.align
package app.revanced.utils.align
import app.revanced.utils.signing.align.zip.structures.ZipEntry
import app.revanced.utils.align.zip.structures.ZipEntry
internal object ZipAligner {
private const val DEFAULT_ALIGNMENT = 4

View File

@@ -1,4 +1,4 @@
package app.revanced.utils.signing.align.zip
package app.revanced.utils.align.zip
import java.io.DataInput
import java.io.DataOutput

View File

@@ -1,7 +1,7 @@
package app.revanced.utils.signing.align.zip
package app.revanced.utils.align.zip
import app.revanced.utils.signing.align.zip.structures.ZipEndRecord
import app.revanced.utils.signing.align.zip.structures.ZipEntry
import app.revanced.utils.align.zip.structures.ZipEndRecord
import app.revanced.utils.align.zip.structures.ZipEntry
import java.io.Closeable
import java.io.File
import java.io.RandomAccessFile
@@ -11,15 +11,15 @@ import java.util.zip.CRC32
import java.util.zip.Deflater
class ZipFile(file: File) : Closeable {
var entries: MutableList<ZipEntry> = mutableListOf()
private var entries: MutableList<ZipEntry> = mutableListOf()
private val filePointer: RandomAccessFile = RandomAccessFile(file, "rw")
private var CDNeedsRewrite = false
private var centralDirectoryNeedsRewrite = false
private val compressionLevel = 5
init {
//if file isn't empty try to load entries
// If file isn't empty try to load entries.
if (file.length() > 0) {
val endRecord = findEndRecord()
@@ -29,17 +29,17 @@ class ZipFile(file: File) : Closeable {
entries = readEntries(endRecord).toMutableList()
}
//seek back to start for writing
// Seek back to start for writing.
filePointer.seek(0)
}
private fun findEndRecord(): ZipEndRecord {
//look from end to start since end record is at the end
// Look from end to start since end record is at the end.
for (i in filePointer.length() - 1 downTo 0) {
filePointer.seek(i)
//possible beginning of signature
// Possible beginning of signature.
if (filePointer.readByte() == 0x50.toByte()) {
//seek back to get the full int
// Seek back to get the full int.
filePointer.seek(i)
val possibleSignature = filePointer.readUIntLE()
if (possibleSignature == ZipEndRecord.ECD_SIGNATURE) {
@@ -76,7 +76,7 @@ class ZipFile(file: File) : Closeable {
}
private fun writeCD() {
val CDStart = filePointer.channel.position().toUInt()
val centralDirectoryStartOffset = filePointer.channel.position().toUInt()
entries.forEach {
filePointer.channel.write(it.toCDE())
@@ -89,8 +89,8 @@ class ZipFile(file: File) : Closeable {
0u,
entriesCount,
entriesCount,
filePointer.channel.position().toUInt() - CDStart,
CDStart,
filePointer.channel.position().toUInt() - centralDirectoryStartOffset,
centralDirectoryStartOffset,
""
)
@@ -98,7 +98,7 @@ class ZipFile(file: File) : Closeable {
}
private fun addEntry(entry: ZipEntry, data: ByteBuffer) {
CDNeedsRewrite = true
centralDirectoryNeedsRewrite = true
entry.localHeaderOffset = filePointer.channel.position().toUInt()
@@ -114,8 +114,7 @@ class ZipFile(file: File) : Closeable {
compressor.finish()
val uncompressedSize = data.size
val compressedData =
ByteArray(uncompressedSize) //i'm guessing compression won't make the data bigger
val compressedData = ByteArray(uncompressedSize) // I'm guessing compression won't make the data bigger.
val compressedDataLength = compressor.deflate(compressedData)
val compressedBuffer =
@@ -126,7 +125,7 @@ class ZipFile(file: File) : Closeable {
val crc = CRC32()
crc.update(data)
entry.compression = 8u //deflate compression
entry.compression = 8u // Deflate compression.
entry.uncompressedSize = uncompressedSize.toUInt()
entry.compressedSize = compressedDataLength.toUInt()
entry.crc32 = crc.value.toUInt()
@@ -136,14 +135,14 @@ class ZipFile(file: File) : Closeable {
private fun addEntryCopyData(entry: ZipEntry, data: ByteBuffer, alignment: Int? = null) {
alignment?.let {
//calculate where data would end up
// Calculate where data would end up.
val dataOffset = filePointer.filePointer + entry.LFHSize
val mod = dataOffset % alignment
//wrong alignment
// Wrong alignment.
if (mod != 0L) {
//add padding at end of extra field
// Add padding at end of extra field.
entry.localExtraField =
entry.localExtraField.copyOf((entry.localExtraField.size + (alignment - mod)).toInt())
}
@@ -152,7 +151,7 @@ class ZipFile(file: File) : Closeable {
addEntry(entry, data)
}
fun getDataForEntry(entry: ZipEntry): ByteBuffer {
private fun getDataForEntry(entry: ZipEntry): ByteBuffer {
return filePointer.channel.map(
FileChannel.MapMode.READ_ONLY,
entry.dataOffset.toLong(),
@@ -160,9 +159,15 @@ class ZipFile(file: File) : Closeable {
)
}
/**
* Copies all entries from [file] to this file but skip already existing entries.
*
* @param file The file to copy entries from.
* @param entryAlignment A function that returns the alignment for a given entry.
*/
fun copyEntriesFromFileAligned(file: ZipFile, entryAlignment: (entry: ZipEntry) -> Int?) {
for (entry in file.entries) {
if (entries.any { it.fileName == entry.fileName }) continue //don't add duplicates
if (entries.any { it.fileName == entry.fileName }) continue // Skip duplicates
val data = file.getDataForEntry(entry)
addEntryCopyData(entry, data, entryAlignment(entry))
@@ -170,7 +175,7 @@ class ZipFile(file: File) : Closeable {
}
override fun close() {
if (CDNeedsRewrite) writeCD()
if (centralDirectoryNeedsRewrite) writeCD()
filePointer.close()
}
}

View File

@@ -1,9 +1,9 @@
package app.revanced.utils.signing.align.zip.structures
package app.revanced.utils.align.zip.structures
import app.revanced.utils.signing.align.zip.putUInt
import app.revanced.utils.signing.align.zip.putUShort
import app.revanced.utils.signing.align.zip.readUIntLE
import app.revanced.utils.signing.align.zip.readUShortLE
import app.revanced.utils.align.zip.putUInt
import app.revanced.utils.align.zip.putUShort
import app.revanced.utils.align.zip.readUIntLE
import app.revanced.utils.align.zip.readUShortLE
import java.io.DataInput
import java.nio.ByteBuffer
import java.nio.ByteOrder

View File

@@ -1,6 +1,6 @@
package app.revanced.utils.signing.align.zip.structures
package app.revanced.utils.align.zip.structures
import app.revanced.utils.signing.align.zip.*
import app.revanced.utils.align.zip.*
import java.io.DataInput
import java.nio.ByteBuffer
import java.nio.ByteOrder

View File

@@ -1,7 +1,6 @@
package app.revanced.utils.signing
import app.revanced.cli.command.MainCommand.logger
import app.revanced.cli.signing.SigningOptions
import app.revanced.cli.command.logger
import com.android.apksig.ApkSigner
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
@@ -18,10 +17,40 @@ import java.security.*
import java.security.cert.X509Certificate
import java.util.*
internal class Signer(
internal class ApkSigner(
private val signingOptions: SigningOptions
) {
private val signer: ApkSigner.Builder
private val passwordCharArray = signingOptions.password.toCharArray()
init {
Security.addProvider(BouncyCastleProvider())
val keyStore = KeyStore.getInstance("BKS", "BC")
val alias = keyStore.let { store ->
FileInputStream(File(signingOptions.keyStoreFilePath).also {
if (!it.exists()) {
logger.info("Creating keystore at ${it.absolutePath}")
newKeystore(it)
} else {
logger.info("Using keystore at ${it.absolutePath}")
}
}).use { fis -> store.load(fis, null) }
store.aliases().nextElement()
}
with(
ApkSigner.SignerConfig.Builder(
signingOptions.cn,
keyStore.getKey(alias, passwordCharArray) as PrivateKey,
listOf(keyStore.getCertificate(alias) as X509Certificate)
).build()
) {
this@ApkSigner.signer = ApkSigner.Builder(listOf(this))
signer.setCreatedBy(signingOptions.cn)
}
}
private fun newKeystore(out: File) {
val (publicKey, privateKey) = createKey()
val privateKS = KeyStore.getInstance("BKS", "BC")
@@ -50,30 +79,12 @@ internal class Signer(
return JcaX509CertificateConverter().getCertificate(builder.build(signer)) to pair.private
}
fun signApk(input: File, output: File) {
Security.addProvider(BouncyCastleProvider())
// TODO: keystore should be saved securely
val ks = File(signingOptions.keyStoreFilePath)
if (!ks.exists()) newKeystore(ks) else {
logger.info("Found existing keystore: ${ks.name}")
}
val keyStore = KeyStore.getInstance("BKS", "BC")
FileInputStream(ks).use { fis -> keyStore.load(fis, null) }
val alias = keyStore.aliases().nextElement()
val config = ApkSigner.SignerConfig.Builder(
signingOptions.cn,
keyStore.getKey(alias, passwordCharArray) as PrivateKey,
listOf(keyStore.getCertificate(alias) as X509Certificate)
).build()
val signer = ApkSigner.Builder(listOf(config))
signer.setCreatedBy(signingOptions.cn)
fun signApk(input: File, output: File): File {
signer.setInputApk(input)
signer.setOutputApk(output)
signer.build().sign()
return output
}
}

View File

@@ -0,0 +1,7 @@
package app.revanced.utils.signing
data class SigningOptions(
val cn: String,
val password: String,
val keyStoreFilePath: String
)