Skip to content

Commit 07c3f7e

Browse files
authored
feat: Add ARSCLib support (MorpheApp#55)
1 parent eaf4915 commit 07c3f7e

8 files changed

Lines changed: 125 additions & 22 deletions

File tree

.releaserc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
{
3333
"assets": [
3434
{
35-
"path": "build/libs/*-all*"
35+
"path": "build/libs/morphe-cli*-all.jar"
3636
}
3737
],
3838
successComment: false

build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ val strippedApkEditorLib by tasks.registering(org.gradle.jvm.tasks.Jar::class) {
5252
}
5353

5454
dependencies {
55-
implementation(libs.morphe.patcher)
55+
api(libs.morphe.patcher)
5656
implementation(libs.morphe.library)
5757
implementation(libs.kotlinx.coroutines.core)
5858
implementation(libs.kotlinx.serialization.json)
@@ -61,6 +61,7 @@ dependencies {
6161
implementation(files(strippedApkEditorLib))
6262

6363
testImplementation(libs.kotlin.test)
64+
testImplementation(libs.junit.params)
6465
}
6566

6667
kotlin {

gradle/libs.versions.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
[versions]
22
shadow = "8.3.9"
3+
junit = "5.11.0"
34
kotlin = "2.3.0"
45
kotlinx = "1.9.0"
56
picocli = "4.7.7"
6-
morphe-patcher = "1.1.1"
7-
morphe-library = "1.2.0"
7+
morphe-patcher = "1.2.0-dev.3" # TODO: change to 1.2.0 before stable release
8+
morphe-library = "1.2.1-dev.1" # TODO: change to 1.2.1 before stable release
89

910
[libraries]
11+
junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" }
1012
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
1113
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" }
1214
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx" }

settings.gradle.kts

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
rootProject.name = "morphe-cli"
22

33
// Include morphe-patcher and morphe-library as composite builds if they exist locally
4-
val morphePatcherDir = file("../morphe-patcher")
5-
if (morphePatcherDir.exists()) {
6-
includeBuild(morphePatcherDir) {
7-
dependencySubstitution {
8-
substitute(module("app.morphe:morphe-patcher")).using(project(":"))
9-
}
10-
}
11-
}
12-
13-
val morpheLibraryDir = file("../morphe-library")
14-
if (morpheLibraryDir.exists()) {
15-
includeBuild(morpheLibraryDir) {
16-
dependencySubstitution {
17-
substitute(module("app.morphe:morphe-library")).using(project(":"))
4+
mapOf(
5+
"morphe-patcher" to "app.morphe:morphe-patcher",
6+
"morphe-library" to "app.morphe:morphe-library",
7+
).forEach { (libraryPath, libraryName) ->
8+
val libDir = file("../$libraryPath")
9+
if (libDir.exists()) {
10+
includeBuild(libDir) {
11+
dependencySubstitution {
12+
substitute(module(libraryName)).using(project(":"))
13+
}
1814
}
1915
}
2016
}

src/main/kotlin/app/morphe/cli/command/MainCommand.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app.morphe.cli.command
22

33
import app.morphe.cli.command.utility.UtilityCommand
44
import app.morphe.library.logging.Logger
5+
import org.jetbrains.annotations.VisibleForTesting
56
import picocli.CommandLine
67
import picocli.CommandLine.Command
78
import picocli.CommandLine.IVersionProvider
@@ -42,4 +43,5 @@ private object CLIVersionProvider : IVersionProvider {
4243
UtilityCommand::class,
4344
]
4445
)
45-
private object MainCommand
46+
@VisibleForTesting
47+
internal object MainCommand

src/main/kotlin/app/morphe/cli/command/PatchCommand.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-cli
4+
*
5+
* Original hard forked code:
6+
* https://github.com/revanced/revanced-cli
7+
*/
8+
19
package app.morphe.cli.command
210

311
import app.morphe.cli.command.model.FailedPatch
@@ -26,6 +34,7 @@ import kotlinx.coroutines.runBlocking
2634
import kotlinx.serialization.ExperimentalSerializationApi
2735
import kotlinx.serialization.json.Json
2836
import kotlinx.serialization.json.encodeToStream
37+
import org.jetbrains.annotations.VisibleForTesting
2938
import picocli.CommandLine
3039
import picocli.CommandLine.ArgGroup
3140
import picocli.CommandLine.Help.Visibility.ALWAYS
@@ -38,6 +47,7 @@ import java.util.concurrent.Callable
3847
import java.util.logging.Logger
3948

4049
@OptIn(ExperimentalSerializationApi::class)
50+
@VisibleForTesting
4151
@CommandLine.Command(
4252
name = "patch",
4353
description = ["Patch an APK file."],
@@ -249,7 +259,7 @@ internal object PatchCommand : Callable<Int> {
249259

250260
@CommandLine.Option(
251261
names = ["--custom-aapt2-binary"],
252-
description = ["Path to a custom AAPT binary to compile resources with."],
262+
description = ["Path to a custom AAPT binary to compile resources with. Only valid when --use-arsclib is not specified."],
253263
)
254264
@Suppress("unused")
255265
private fun setAaptBinaryPath(aaptBinaryPath: File) {
@@ -262,6 +272,13 @@ internal object PatchCommand : Callable<Int> {
262272
this.aaptBinaryPath = aaptBinaryPath
263273
}
264274

275+
@CommandLine.Option(
276+
names = ["--force-apktool"],
277+
description = ["Use apktool instead of arsclib to compile resources. Implied if --custom-aapt2-binary is specified."],
278+
showDefaultValue = ALWAYS,
279+
)
280+
private var forceApktool: Boolean = false
281+
265282
@CommandLine.Option(
266283
names = ["--unsigned"],
267284
description = ["Disable signing of the final apk."],
@@ -436,11 +453,12 @@ internal object PatchCommand : Callable<Int> {
436453
inputApk,
437454
patcherTemporaryFilesPath,
438455
aaptBinaryPath?.path,
439-
patcherTemporaryFilesPath.absolutePath
456+
patcherTemporaryFilesPath.absolutePath,
457+
if (aaptBinaryPath != null) { false } else { !forceApktool },
440458
),
441459
).use { patcher ->
442460
val packageName = patcher.context.packageMetadata.packageName
443-
val packageVersion = patcher.context.packageMetadata.packageVersion
461+
val packageVersion = patcher.context.packageMetadata.versionName
444462

445463
patchingResult.packageName = packageName
446464
patchingResult.packageVersion = packageVersion
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-cli
4+
*/
5+
6+
package app.morphe.cli.command
7+
8+
import org.junit.jupiter.api.Assertions
9+
import org.junit.jupiter.api.Disabled
10+
import org.junit.jupiter.params.ParameterizedTest
11+
import org.junit.jupiter.params.provider.ValueSource
12+
import picocli.CommandLine
13+
import java.io.File
14+
import java.util.logging.Logger
15+
import kotlin.io.path.createTempDirectory
16+
17+
class PatchingTest {
18+
private val logger = Logger.getLogger(PatchingTest::class.java.name)
19+
20+
@ParameterizedTest
21+
@ValueSource(booleans = [true, false])
22+
@Disabled("Need to create lighter weight patch bundle")
23+
fun `patch example apk`(useArsclib: Boolean) {
24+
val apkFileStream = javaClass.getResourceAsStream("/nowinandroid-apk")
25+
val patchesFileStream = javaClass.getResourceAsStream("/patches.mpp")
26+
27+
// Create output directories
28+
val tempDir = createTempDirectory().toFile()
29+
tempDir.deleteOnExit()
30+
31+
val workingDir = tempDir.resolve("temp")
32+
val apkFile = tempDir.resolve("input.apk").apply { apkFileStream.use { input -> outputStream().use { output -> input.copyTo(output) } } }
33+
val patchesFile = tempDir.resolve("patches.mpp").apply { patchesFileStream.use { input -> outputStream().use { output -> input.copyTo(output) } } }
34+
val outputApk = tempDir.resolve("${apkFile.nameWithoutExtension}-merged.apk")
35+
val resultFile = tempDir.resolve("results.json")
36+
37+
logger.info("Starting to patch")
38+
val patchStartTime = System.currentTimeMillis()
39+
val exitCode = patchApk(
40+
apkFile = apkFile,
41+
outputApk = outputApk,
42+
resultFile = resultFile,
43+
patchBundle = patchesFile,
44+
tempDir = workingDir,
45+
useArsclib = useArsclib,
46+
)
47+
val duration = System.currentTimeMillis() - patchStartTime
48+
logger.info("Patching completed in ${duration}ms")
49+
50+
Assertions.assertTrue(exitCode == 0, "Patching with ARSCLib failed with exit code $exitCode")
51+
Assertions.assertTrue(outputApk.exists(), "Output APK was not created")
52+
Assertions.assertTrue(resultFile.exists(), "Result file was not created")
53+
}
54+
55+
/**
56+
* Patches an APK using the specified configuration.
57+
*/
58+
private fun patchApk(
59+
apkFile: File,
60+
outputApk: File,
61+
resultFile: File,
62+
patchBundle: File,
63+
tempDir: File,
64+
useArsclib: Boolean,
65+
): Int {
66+
var args = arrayOf(
67+
"patch",
68+
"--patches=${patchBundle.absolutePath}",
69+
"--striplibs=x86_64",
70+
"--result-file=${resultFile.absolutePath}",
71+
"--out=${outputApk.absolutePath}",
72+
"--temporary-files-path=${tempDir.absolutePath}",
73+
"--enable=Override certificate pinning",
74+
"--enable=Change package name",
75+
apkFile.absolutePath
76+
)
77+
78+
if (!useArsclib) {
79+
args += "--force-apktool"
80+
}
81+
82+
return CommandLine(MainCommand).execute(*args)
83+
}
84+
}
4.85 MB
Binary file not shown.

0 commit comments

Comments
 (0)