Skip to content
Draft
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: 1 addition & 1 deletion .releaserc
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
{
"assets": [
{
"path": "build/libs/*-all*"
"path": "build/libs/morphe-cli*-all.jar"
}
],
successComment: false
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# [1.5.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.4.0...v1.5.0-dev.1) (2026-02-21)


### Features

* Add ARSCLib support ([#55](https://github.com/MorpheApp/morphe-cli/issues/55)) ([07c3f7e](https://github.com/MorpheApp/morphe-cli/commit/07c3f7ec50d52739ee2695f52c3c7182f2287ecf))

# [1.4.0](https://github.com/MorpheApp/morphe-cli/compare/v1.3.0...v1.4.0) (2026-02-21)


Expand Down
3 changes: 2 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -61,6 +61,7 @@ dependencies {
implementation(files(strippedApkEditorLib))

testImplementation(libs.kotlin.test)
testImplementation(libs.junit.params)
}

kotlin {
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
org.gradle.parallel = true
org.gradle.caching = true
kotlin.code.style = official
version = 1.4.0
version = 1.5.0-dev.1
6 changes: 4 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
24 changes: 10 additions & 14 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -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(":"))
}
}
}
}
4 changes: 3 additions & 1 deletion src/main/kotlin/app/morphe/cli/command/MainCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,4 +43,5 @@ private object CLIVersionProvider : IVersionProvider {
UtilityCommand::class,
]
)
private object MainCommand
@VisibleForTesting
internal object MainCommand
24 changes: 21 additions & 3 deletions src/main/kotlin/app/morphe/cli/command/PatchCommand.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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."],
Expand Down Expand Up @@ -249,7 +259,7 @@ internal object PatchCommand : Callable<Int> {

@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) {
Expand All @@ -262,6 +272,13 @@ internal object PatchCommand : Callable<Int> {
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."],
Expand Down Expand Up @@ -436,11 +453,12 @@ internal object PatchCommand : Callable<Int> {
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
Expand Down
84 changes: 84 additions & 0 deletions src/test/kotlin/app/morphe/cli/command/PatchingTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
Binary file added src/test/resources/nowinandroid-apk
Binary file not shown.