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
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies {
implementation(libs.sigstore.java)
implementation(libs.revanced.patcher)
implementation(libs.revanced.library)
implementation(libs.reandroid.arsclib)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.picocli)

Expand Down Expand Up @@ -51,6 +52,7 @@ tasks {
minimize {
exclude(dependency("org.bouncycastle:.*"))
exclude(dependency("app.revanced:patcher"))
exclude(dependency("io.github.reandroid:ARSCLib"))
exclude(dependency("commons-logging:commons-logging"))
}
}
Expand Down
8 changes: 8 additions & 0 deletions docs/1_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ To patch an app using the default list of patches, use the `patch` command.
java -jar revanced-cli.jar patch -bp patches.rvp input.apk
```

APKM inputs are supported too. The CLI patches the base APK, merges the patched base APK with
the APKM splits, and saves the result as a single APK. The default patched output uses the
`.apk` extension:

```bash
java -jar revanced-cli.jar patch -bp patches.rvp input.apkm
```

You can also use multiple RVP files:

```bash
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ kotlinx = "1.10.2"
picocli = "4.7.7"
revanced-patcher = "22.0.1"
revanced-library = "4.0.1"
reandroid-arsclib = "1.3.8"
sigstore = "2.0.0"

[libraries]
Expand All @@ -15,6 +16,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
revanced-patcher = { module = "app.revanced:patcher", version.ref = "revanced-patcher" }
revanced-library = { module = "app.revanced:library", version.ref = "revanced-library" }
reandroid-arsclib = { module = "io.github.reandroid:ARSCLib", version.ref = "reandroid-arsclib" }
sigstore-java = { module = "dev.sigstore:sigstore-java", version.ref = "sigstore" }

[plugins]
Expand Down
161 changes: 161 additions & 0 deletions src/main/kotlin/app/revanced/cli/command/ApkmUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package app.revanced.cli.command

import com.reandroid.apk.APKLogger
import com.reandroid.apk.ApkBundle
import com.reandroid.apk.ApkModule
import com.reandroid.app.AndroidManifest
import java.io.File
import java.io.IOException
import java.nio.file.Files
import java.util.logging.Level
import java.util.logging.Logger
import java.util.zip.ZipInputStream

internal fun File.isApkm() = extension.equals("apkm", ignoreCase = true)

internal data class ExtractedApkm(
val extractedApksDirectory: File,
val baseApk: File,
)

internal object ApkmUtils {
private val logger = Logger.getLogger(ApkmUtils::class.java.name)

private val splitInstallMetadataNames = setOf(
"com.android.vending.splits.required",
"com.android.vending.splits",
"com.android.vending.derived.apk.id",
)

fun extract(
apkmFile: File,
temporaryFilesPath: File,
): ExtractedApkm {
if (temporaryFilesPath.exists()) {
temporaryFilesPath.deleteRecursively()
}

val extractedApksDirectory = temporaryFilesPath.resolve("extracted-apks").apply {
mkdirs()
}

val apkFiles = apkmFile.extractApksTo(extractedApksDirectory)
val baseApk = apkFiles.firstOrNull { it.name.equals("base.apk", ignoreCase = true) }
?: throw IOException("APKM file ${apkmFile.path} does not contain base.apk")

return ExtractedApkm(extractedApksDirectory, baseApk)
}

fun mergeExtractedApks(
extractedApkm: ExtractedApkm,
mergedApk: File,
patchedBaseApk: File,
): File {
mergedApk.parentFile.mkdirs()
if (mergedApk.exists()) {
mergedApk.delete()
}

if (patchedBaseApk.canonicalFile != extractedApkm.baseApk.canonicalFile) {
patchedBaseApk.copyTo(extractedApkm.baseApk, overwrite = true)
}

ApkBundle().use { bundle ->
bundle.setAPKLogger(ArscLogger)
bundle.loadApkDirectory(extractedApkm.extractedApksDirectory, true)

bundle.mergeModules(true).use { mergedModule ->
mergedModule.refreshTable()
mergedModule.removeSplitInstallRequirements()
mergedModule.refreshManifest()
mergedModule.writeApk(mergedApk)
}
}

logger.info("Merged APKM to $mergedApk")

return mergedApk
}

private fun File.extractApksTo(outputDirectory: File): List<File> {
val outputDirectoryPath = outputDirectory.toPath().toAbsolutePath().normalize()
val apkFiles = mutableListOf<File>()

ZipInputStream(inputStream().buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break

try {
if (entry.isDirectory || !entry.name.endsWith(".apk", ignoreCase = true)) {
continue
}

val outputFilePath = outputDirectoryPath.resolve(entry.name).normalize()
if (!outputFilePath.startsWith(outputDirectoryPath)) {
throw IOException("Unsafe APKM entry path: ${entry.name}")
}

Files.createDirectories(outputFilePath.parent)

if (Files.exists(outputFilePath)) {
throw IOException("Duplicate APKM entry path: ${entry.name}")
}

Files.copy(zip, outputFilePath)
apkFiles += outputFilePath.toFile()
} finally {
zip.closeEntry()
}
}
}

if (apkFiles.isEmpty()) {
throw IOException("APKM file $path does not contain any APK files")
}

logger.info("Extracted ${apkFiles.size} APK files from $path")

return apkFiles
}

private fun ApkModule.removeSplitInstallRequirements() {
val manifest = androidManifest ?: return
val manifestElement = manifest.manifestElement ?: return

manifest.setExtractNativeLibs(true)

listOf(
AndroidManifest.ID_isSplitRequired to AndroidManifest.NAME_isSplitRequired,
AndroidManifest.ID_requiredSplitTypes to AndroidManifest.NAME_requiredSplitTypes,
AndroidManifest.ID_splitTypes to AndroidManifest.NAME_splitTypes,
).forEach { (resourceId, name) ->
manifestElement.removeAttributesWithId(resourceId)
manifestElement.removeAttributesWithName(name)
}

manifest.getApplicationElement()?.removeElementsIf { element ->
if (!element.equalsName(AndroidManifest.TAG_meta_data)) {
return@removeElementsIf false
}

val name = element
.searchAttributeByResourceId(AndroidManifest.ID_name)
?.getValueAsString()

name in splitInstallMetadataNames
}

manifest.refreshFull()
}

private object ArscLogger : APKLogger {
override fun logMessage(msg: String) = logger.info(msg)

override fun logError(
msg: String,
tr: Throwable,
) = logger.log(Level.SEVERE, msg, tr)

override fun logVerbose(msg: String) = logger.fine(msg)
}
}
69 changes: 47 additions & 22 deletions src/main/kotlin/app/revanced/cli/command/PatchCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import java.util.logging.Logger

@CommandLine.Command(
name = "patch",
description = ["Patch an APK file."],
description = ["Patch an APK or APKM file."],
sortOptions = false,
)
internal object PatchCommand : Callable<Int> {
Expand All @@ -33,15 +33,16 @@ internal object PatchCommand : Callable<Int> {
// region Required parameters

@CommandLine.Parameters(
description = ["APK file to patch."],
description = ["APK or APKM file to patch."],
paramLabel = "<input>",
arity = "1",
)
@Suppress("unused")
private fun setApk(apk: File) {
if (!apk.exists()) {
throw CommandLine.ParameterException(
spec.commandLine(),
"APK file ${apk.path} does not exist",
"APK or APKM file ${apk.path} does not exist",
)
}
this.apk = apk
Expand Down Expand Up @@ -137,7 +138,7 @@ internal object PatchCommand : Callable<Int> {

@CommandLine.Option(
names = ["-o", "--out"],
description = ["Path to save the patched APK file to. Defaults to the same path as the supplied APK file."],
description = ["Path to save the patched APK file to. Defaults to the same path as the supplied input file. APKM inputs use an APK output extension."],
)
@Suppress("unused")
private fun setOutputFilePath(outputFilePath: File?) {
Expand Down Expand Up @@ -261,14 +262,27 @@ internal object PatchCommand : Callable<Int> {

val outputFilePath =
outputFilePath ?: File("").absoluteFile.resolve(
"${apk.nameWithoutExtension}-patched.${apk.extension}",
"${apk.nameWithoutExtension}-patched.${if (apk.isApkm()) "apk" else apk.extension}",
)

val temporaryFilesPath =
temporaryFilesPath ?: outputFilePath.parentFile.resolve(
"${outputFilePath.nameWithoutExtension}-temporary-files",
)

val extractedApkm = if (apk.isApkm()) {
try {
ApkmUtils.extract(apk, temporaryFilesPath.resolve("apkm"))
} catch (exception: Exception) {
logger.severe("Failed to extract APKM file ${apk.path}:\n${exception.stackTraceToString()}")
return -1
}
} else {
null
}

val patchTarget = extractedApkm?.baseApk ?: apk

val keystoreFilePath =
signing?.keystoreFilePath ?: outputFilePath.parentFile
.resolve("${outputFilePath.nameWithoutExtension}.keystore")
Expand Down Expand Up @@ -316,7 +330,7 @@ internal object PatchCommand : Callable<Int> {
lateinit var packageName: String

val patch = patcher(
apk,
patchTarget,
patcherTemporaryFilesPath,
aaptBinaryPath,
patcherTemporaryFilesPath.absolutePath,
Expand Down Expand Up @@ -353,24 +367,35 @@ internal object PatchCommand : Callable<Int> {

// region Save.

apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).let {
val patchedApkFile = patchTarget.copyTo(
temporaryFilesPath.resolve(patchTarget.name),
overwrite = true,
).also {
patchesResult.applyTo(it)
}

if (installation?.mount != true) {
ApkUtils.signApk(
it,
outputFilePath,
signing?.signer ?: "ReVanced",
ApkUtils.KeyStoreDetails(
keystoreFilePath,
signing?.keystorePassword,
signing?.keystoreEntryAlias ?: "ReVanced Key",
signing?.keystoreEntryPassword ?: "",
),
)
} else {
it.copyTo(outputFilePath, overwrite = true)
}
val apkFileToSave = extractedApkm?.let {
ApkmUtils.mergeExtractedApks(
it,
temporaryFilesPath.resolve("merged").resolve(outputFilePath.name),
patchedApkFile,
)
} ?: patchedApkFile

if (installation?.mount != true) {
ApkUtils.signApk(
apkFileToSave,
outputFilePath,
signing?.signer ?: "ReVanced",
ApkUtils.KeyStoreDetails(
keystoreFilePath,
signing?.keystorePassword,
signing?.keystoreEntryAlias ?: "ReVanced Key",
signing?.keystoreEntryPassword ?: "",
),
)
} else {
apkFileToSave.copyTo(outputFilePath, overwrite = true)
}

logger.info("Saved to $outputFilePath")
Expand Down