From fce4709aefd40605009a04ae586849408d4e3241 Mon Sep 17 00:00:00 2001 From: Ben Lavi Date: Sat, 30 May 2026 19:59:14 +0300 Subject: [PATCH] feat: support patching APKM inputs Extract APKM files, patch the base APK, merge the patched base with the original splits, and save the result as a signed APK output. --- build.gradle.kts | 2 + docs/1_usage.md | 8 + gradle/libs.versions.toml | 2 + .../app/revanced/cli/command/ApkmUtils.kt | 161 ++++++++++++++++++ .../app/revanced/cli/command/PatchCommand.kt | 69 +++++--- 5 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 src/main/kotlin/app/revanced/cli/command/ApkmUtils.kt diff --git a/build.gradle.kts b/build.gradle.kts index 93bc9a5a..0273ec23 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) @@ -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")) } } diff --git a/docs/1_usage.md b/docs/1_usage.md index 1140a109..40d41055 100644 --- a/docs/1_usage.md +++ b/docs/1_usage.md @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 88016558..1287950c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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] @@ -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] diff --git a/src/main/kotlin/app/revanced/cli/command/ApkmUtils.kt b/src/main/kotlin/app/revanced/cli/command/ApkmUtils.kt new file mode 100644 index 00000000..71de42c9 --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/command/ApkmUtils.kt @@ -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 { + val outputDirectoryPath = outputDirectory.toPath().toAbsolutePath().normalize() + val apkFiles = mutableListOf() + + 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) + } +} diff --git a/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt b/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt index 00265405..64815026 100644 --- a/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/revanced/cli/command/PatchCommand.kt @@ -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 { @@ -33,7 +33,8 @@ internal object PatchCommand : Callable { // region Required parameters @CommandLine.Parameters( - description = ["APK file to patch."], + description = ["APK or APKM file to patch."], + paramLabel = "", arity = "1", ) @Suppress("unused") @@ -41,7 +42,7 @@ internal object PatchCommand : Callable { 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 @@ -137,7 +138,7 @@ internal object PatchCommand : Callable { @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?) { @@ -261,7 +262,7 @@ internal object PatchCommand : Callable { val outputFilePath = outputFilePath ?: File("").absoluteFile.resolve( - "${apk.nameWithoutExtension}-patched.${apk.extension}", + "${apk.nameWithoutExtension}-patched.${if (apk.isApkm()) "apk" else apk.extension}", ) val temporaryFilesPath = @@ -269,6 +270,19 @@ internal object PatchCommand : Callable { "${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") @@ -316,7 +330,7 @@ internal object PatchCommand : Callable { lateinit var packageName: String val patch = patcher( - apk, + patchTarget, patcherTemporaryFilesPath, aaptBinaryPath, patcherTemporaryFilesPath.absolutePath, @@ -353,24 +367,35 @@ internal object PatchCommand : Callable { // 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")