diff --git a/.releaserc b/.releaserc index f4aa59c..7a301c7 100644 --- a/.releaserc +++ b/.releaserc @@ -32,7 +32,7 @@ { "assets": [ { - "path": "build/libs/*-all*" + "path": "build/libs/morphe-cli*-all.jar" } ], successComment: false diff --git a/build.gradle.kts b/build.gradle.kts index 7fb4ed5..eb7cc95 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,7 +52,7 @@ val strippedApkEditorLib by tasks.registering(org.gradle.jvm.tasks.Jar::class) { } dependencies { - implementation(libs.morphe.patcher) + api(libs.morphe.patcher) implementation(libs.morphe.library) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) @@ -61,6 +61,7 @@ dependencies { implementation(files(strippedApkEditorLib)) testImplementation(libs.kotlin.test) + testImplementation(libs.junit.params) } kotlin { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 53481b5..3cb45ef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,14 @@ [versions] shadow = "8.3.9" +junit = "5.11.0" kotlin = "2.3.0" kotlinx = "1.9.0" picocli = "4.7.7" -morphe-patcher = "1.1.1" -morphe-library = "1.2.0" +morphe-patcher = "1.2.0-dev.3" # TODO: change to 1.2.0 before stable release +morphe-library = "1.2.1-dev.1" # TODO: change to 1.2.1 before stable release [libraries] +junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx" } diff --git a/settings.gradle.kts b/settings.gradle.kts index ead101f..68bbb2d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,20 +1,16 @@ rootProject.name = "morphe-cli" // Include morphe-patcher and morphe-library as composite builds if they exist locally -val morphePatcherDir = file("../morphe-patcher") -if (morphePatcherDir.exists()) { - includeBuild(morphePatcherDir) { - dependencySubstitution { - substitute(module("app.morphe:morphe-patcher")).using(project(":")) - } - } -} - -val morpheLibraryDir = file("../morphe-library") -if (morpheLibraryDir.exists()) { - includeBuild(morpheLibraryDir) { - dependencySubstitution { - substitute(module("app.morphe:morphe-library")).using(project(":")) +mapOf( + "morphe-patcher" to "app.morphe:morphe-patcher", + "morphe-library" to "app.morphe:morphe-library", +).forEach { (libraryPath, libraryName) -> + val libDir = file("../$libraryPath") + if (libDir.exists()) { + includeBuild(libDir) { + dependencySubstitution { + substitute(module(libraryName)).using(project(":")) + } } } } diff --git a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt index 242c5a7..6051f00 100644 --- a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt @@ -2,6 +2,7 @@ package app.morphe.cli.command import app.morphe.cli.command.utility.UtilityCommand import app.morphe.library.logging.Logger +import org.jetbrains.annotations.VisibleForTesting import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.IVersionProvider @@ -42,4 +43,5 @@ private object CLIVersionProvider : IVersionProvider { UtilityCommand::class, ] ) -private object MainCommand +@VisibleForTesting +internal object MainCommand diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 4e15ff5..a0dab48 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -1,3 +1,11 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + * + * Original hard forked code: + * https://github.com/revanced/revanced-cli + */ + package app.morphe.cli.command import app.morphe.cli.command.model.FailedPatch @@ -26,6 +34,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToStream +import org.jetbrains.annotations.VisibleForTesting import picocli.CommandLine import picocli.CommandLine.ArgGroup import picocli.CommandLine.Help.Visibility.ALWAYS @@ -38,6 +47,7 @@ import java.util.concurrent.Callable import java.util.logging.Logger @OptIn(ExperimentalSerializationApi::class) +@VisibleForTesting @CommandLine.Command( name = "patch", description = ["Patch an APK file."], @@ -249,7 +259,7 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["--custom-aapt2-binary"], - description = ["Path to a custom AAPT binary to compile resources with."], + description = ["Path to a custom AAPT binary to compile resources with. Only valid when --use-arsclib is not specified."], ) @Suppress("unused") private fun setAaptBinaryPath(aaptBinaryPath: File) { @@ -262,6 +272,13 @@ internal object PatchCommand : Callable { this.aaptBinaryPath = aaptBinaryPath } + @CommandLine.Option( + names = ["--force-apktool"], + description = ["Use apktool instead of arsclib to compile resources. Implied if --custom-aapt2-binary is specified."], + showDefaultValue = ALWAYS, + ) + private var forceApktool: Boolean = false + @CommandLine.Option( names = ["--unsigned"], description = ["Disable signing of the final apk."], @@ -436,11 +453,12 @@ internal object PatchCommand : Callable { inputApk, patcherTemporaryFilesPath, aaptBinaryPath?.path, - patcherTemporaryFilesPath.absolutePath + patcherTemporaryFilesPath.absolutePath, + if (aaptBinaryPath != null) { false } else { !forceApktool }, ), ).use { patcher -> val packageName = patcher.context.packageMetadata.packageName - val packageVersion = patcher.context.packageMetadata.packageVersion + val packageVersion = patcher.context.packageMetadata.versionName patchingResult.packageName = packageName patchingResult.packageVersion = packageVersion diff --git a/src/test/kotlin/app/morphe/cli/command/PatchingTest.kt b/src/test/kotlin/app/morphe/cli/command/PatchingTest.kt new file mode 100644 index 0000000..9b2aedb --- /dev/null +++ b/src/test/kotlin/app/morphe/cli/command/PatchingTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.cli.command + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import picocli.CommandLine +import java.io.File +import java.util.logging.Logger +import kotlin.io.path.createTempDirectory + +class PatchingTest { + private val logger = Logger.getLogger(PatchingTest::class.java.name) + + @ParameterizedTest + @ValueSource(booleans = [true, false]) + @Disabled("Need to create lighter weight patch bundle") + fun `patch example apk`(useArsclib: Boolean) { + val apkFileStream = javaClass.getResourceAsStream("/nowinandroid-apk") + val patchesFileStream = javaClass.getResourceAsStream("/patches.mpp") + + // Create output directories + val tempDir = createTempDirectory().toFile() + tempDir.deleteOnExit() + + val workingDir = tempDir.resolve("temp") + val apkFile = tempDir.resolve("input.apk").apply { apkFileStream.use { input -> outputStream().use { output -> input.copyTo(output) } } } + val patchesFile = tempDir.resolve("patches.mpp").apply { patchesFileStream.use { input -> outputStream().use { output -> input.copyTo(output) } } } + val outputApk = tempDir.resolve("${apkFile.nameWithoutExtension}-merged.apk") + val resultFile = tempDir.resolve("results.json") + + logger.info("Starting to patch") + val patchStartTime = System.currentTimeMillis() + val exitCode = patchApk( + apkFile = apkFile, + outputApk = outputApk, + resultFile = resultFile, + patchBundle = patchesFile, + tempDir = workingDir, + useArsclib = useArsclib, + ) + val duration = System.currentTimeMillis() - patchStartTime + logger.info("Patching completed in ${duration}ms") + + Assertions.assertTrue(exitCode == 0, "Patching with ARSCLib failed with exit code $exitCode") + Assertions.assertTrue(outputApk.exists(), "Output APK was not created") + Assertions.assertTrue(resultFile.exists(), "Result file was not created") + } + + /** + * Patches an APK using the specified configuration. + */ + private fun patchApk( + apkFile: File, + outputApk: File, + resultFile: File, + patchBundle: File, + tempDir: File, + useArsclib: Boolean, + ): Int { + var args = arrayOf( + "patch", + "--patches=${patchBundle.absolutePath}", + "--striplibs=x86_64", + "--result-file=${resultFile.absolutePath}", + "--out=${outputApk.absolutePath}", + "--temporary-files-path=${tempDir.absolutePath}", + "--enable=Override certificate pinning", + "--enable=Change package name", + apkFile.absolutePath + ) + + if (!useArsclib) { + args += "--force-apktool" + } + + return CommandLine(MainCommand).execute(*args) + } +} diff --git a/src/test/resources/nowinandroid-apk b/src/test/resources/nowinandroid-apk new file mode 100644 index 0000000..ebbc781 Binary files /dev/null and b/src/test/resources/nowinandroid-apk differ