Skip to content

Commit e78eef7

Browse files
authored
chore: Merge branch dev to main
2 parents c73e360 + a864a57 commit e78eef7

10 files changed

Lines changed: 353 additions & 108 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
1-
# 1.0.0 (2026-01-11)
2-
3-
4-
### Bug Fixes
5-
6-
* Migrate to new package, use maven patcher dependency ([0b50811](https://github.com/AzyrRuthless/morphe-cli/commit/0b50811a5a2a0a574538b2fbffe027c04613db6c))
7-
* Use patcher / library 1.0.1 release ([0ca02e9](https://github.com/AzyrRuthless/morphe-cli/commit/0ca02e920b9e3189ac2b3a18587f0fb9efd0de4e))
8-
* Use repo library release ([0e4fae1](https://github.com/AzyrRuthless/morphe-cli/commit/0e4fae1b24bb21baa5004126df364b736d84270b))
1+
# [1.1.0-dev.1](https://github.com/AzyrRuthless/morphe-cli/compare/v1.0.0...v1.1.0-dev.1) (2026-02-06)
92

103

114
### Features
125

13-
* add command option `unsigned` ([#20](https://github.com/AzyrRuthless/morphe-cli/issues/20)) ([4505091](https://github.com/AzyrRuthless/morphe-cli/commit/4505091624b854706b39656bae642e5dc1132dcd))
14-
* Initial release ([874ecbc](https://github.com/AzyrRuthless/morphe-cli/commit/874ecbce34d6499882f7a5cf648bd828c823935a))
6+
* Add ability to write patching results to JSON file ([#25](https://github.com/AzyrRuthless/morphe-cli/issues/25)) ([ed0d1ef](https://github.com/AzyrRuthless/morphe-cli/commit/ed0d1ef4150411c8027156bad8338194480b4e88))
7+
* Update to Morphe patcher / library 1.1.0 ([2445b24](https://github.com/AzyrRuthless/morphe-cli/commit/2445b24656228dd5c487e18fe6e441d5b8b6204b))
8+
* Update to Morphe patcher 1.1.1 ([8fa5a83](https://github.com/AzyrRuthless/morphe-cli/commit/8fa5a83d18fa35668d1d07a852bda62a452c0ad7))
159

1610
# 1.0.0-dev.1 (2026-01-11)
1711

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
22

33
plugins {
44
alias(libs.plugins.kotlin)
5+
alias(libs.plugins.kotlin.serialization)
56
alias(libs.plugins.shadow)
67
application
78
`maven-publish`
@@ -35,6 +36,7 @@ dependencies {
3536
implementation(libs.morphe.patcher)
3637
implementation(libs.morphe.library)
3738
implementation(libs.kotlinx.coroutines.core)
39+
implementation(libs.kotlinx.serialization.json)
3840
implementation(libs.picocli)
3941

4042
testImplementation(libs.kotlin.test)

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
org.gradle.parallel = true
22
org.gradle.caching = true
33
kotlin.code.style = official
4-
version = 1.0.0
4+
version = 1.1.0-dev.1

gradle/libs.versions.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
[versions]
22
shadow = "8.3.9"
3-
kotlin = "2.0.21"
4-
kotlinx = "1.8.1"
3+
kotlin = "2.3.0"
4+
kotlinx = "1.9.0"
55
picocli = "4.7.7"
6-
morphe-patcher = "1.0.1"
7-
morphe-library = "1.0.1"
6+
morphe-patcher = "1.1.1"
7+
morphe-library = "1.2.0"
88

99
[libraries]
1010
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
1111
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" }
12+
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx" }
1213
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
1314
morphe-patcher = { module = "app.morphe:morphe-patcher", version.ref = "morphe-patcher" }
1415
morphe-library = { module = "app.morphe:morphe-library-jvm", version.ref = "morphe-library" }
1516

1617
[plugins]
1718
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
1819
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
20+
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

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

Lines changed: 164 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
package app.morphe.cli.command
22

3+
import app.morphe.cli.command.model.FailedPatch
4+
import app.morphe.cli.command.model.PatchingResult
5+
import app.morphe.cli.command.model.PatchingStep
6+
import app.morphe.cli.command.model.PatchingStepResult
7+
import app.morphe.cli.command.model.addStepResult
8+
import app.morphe.cli.command.model.toSerializablePatch
39
import app.morphe.library.ApkUtils
410
import app.morphe.library.ApkUtils.applyTo
511
import app.morphe.library.installation.installer.*
@@ -9,6 +15,9 @@ import app.morphe.patcher.PatcherConfig
915
import app.morphe.patcher.patch.Patch
1016
import app.morphe.patcher.patch.loadPatchesFromJar
1117
import kotlinx.coroutines.runBlocking
18+
import kotlinx.serialization.ExperimentalSerializationApi
19+
import kotlinx.serialization.json.Json
20+
import kotlinx.serialization.json.encodeToStream
1221
import picocli.CommandLine
1322
import picocli.CommandLine.ArgGroup
1423
import picocli.CommandLine.Help.Visibility.ALWAYS
@@ -25,6 +34,7 @@ import java.util.zip.ZipEntry
2534
import java.util.zip.ZipFile
2635
import java.util.zip.ZipOutputStream
2736

37+
@OptIn(ExperimentalSerializationApi::class)
2838
@CommandLine.Command(
2939
name = "patch",
3040
description = ["Patch an APK file."],
@@ -121,6 +131,17 @@ internal object PatchCommand : Runnable {
121131
this.outputFilePath = outputFilePath?.absoluteFile
122132
}
123133

134+
private var patchingResultOutputFilePath: File? = null
135+
136+
@CommandLine.Option(
137+
names = ["-r", "--result-file"],
138+
description = ["Path to save the patching result file to"],
139+
)
140+
@Suppress("unused")
141+
private fun setPatchingResultOutputFilePath(outputFilePath: File?) {
142+
this.patchingResultOutputFilePath = outputFilePath?.absoluteFile
143+
}
144+
124145
@CommandLine.Option(
125146
names = ["-i", "--install"],
126147
description = ["Serial of the ADB device to install to. If not specified, the first connected device will be used."],
@@ -310,118 +331,168 @@ internal object PatchCommand : Runnable {
310331

311332
val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher")
312333

313-
val (packageName, patcherResult) = Patcher(
314-
PatcherConfig(
315-
apk,
316-
patcherTemporaryFilesPath,
317-
aaptBinaryPath?.path,
318-
patcherTemporaryFilesPath.absolutePath,
319-
),
320-
).use { patcher ->
321-
val packageName = patcher.context.packageMetadata.packageName
322-
val packageVersion = patcher.context.packageMetadata.packageVersion
323-
324-
val filteredPatches = patches.filterPatchSelection(packageName, packageVersion)
325-
326-
logger.info("Setting patch options")
327-
328-
val patchesList = patches.toList()
329-
selection.filter { it.enabled != null }.associate {
330-
val enabledSelection = it.enabled!!
331-
332-
(enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to
333-
enabledSelection.options
334-
}.let(filteredPatches::setOptions)
335-
336-
patcher += filteredPatches
337-
338-
// Execute patches.
339-
runBlocking {
340-
patcher().collect { patchResult ->
341-
val exception = patchResult.exception
342-
?: return@collect logger.info("\"${patchResult.patch}\" succeeded")
343-
344-
StringWriter().use { writer ->
345-
exception.printStackTrace(PrintWriter(writer))
346-
347-
logger.severe("\"${patchResult.patch}\" failed:\n$writer")
334+
val patchingResult = PatchingResult()
335+
336+
try {
337+
val (packageName, patcherResult) = Patcher(
338+
PatcherConfig(
339+
apk,
340+
patcherTemporaryFilesPath,
341+
aaptBinaryPath?.path,
342+
patcherTemporaryFilesPath.absolutePath,
343+
),
344+
).use { patcher ->
345+
val packageName = patcher.context.packageMetadata.packageName
346+
val packageVersion = patcher.context.packageMetadata.packageVersion
347+
348+
patchingResult.packageName = packageName
349+
patchingResult.packageVersion = packageVersion
350+
351+
val filteredPatches = patches.filterPatchSelection(packageName, packageVersion)
352+
353+
logger.info("Setting patch options")
354+
355+
val patchesList = patches.toList()
356+
selection.filter { it.enabled != null }.associate {
357+
val enabledSelection = it.enabled!!
358+
359+
(enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to
360+
enabledSelection.options
361+
}.let(filteredPatches::setOptions)
362+
363+
patcher += filteredPatches
364+
365+
// Execute patches.
366+
patchingResult.addStepResult(
367+
PatchingStep.PATCHING,
368+
{
369+
runBlocking {
370+
patcher().collect { patchResult ->
371+
patchResult.exception?.let { exception ->
372+
StringWriter().use { writer ->
373+
exception.printStackTrace(PrintWriter(writer))
374+
375+
logger.severe("\"${patchResult.patch}\" failed:\n$writer")
376+
377+
patchingResult.failedPatches.add(
378+
FailedPatch(
379+
patchResult.patch.toSerializablePatch(),
380+
writer.toString()
381+
)
382+
)
383+
patchingResult.success = false
384+
}
385+
} ?: patchResult.patch.let {
386+
patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch())
387+
logger.info("\"${patchResult.patch}\" succeeded")
388+
}
389+
}
390+
}
348391
}
349-
}
392+
)
393+
394+
patcher.context.packageMetadata.packageName to patcher.get()
350395
}
351396

352-
patcher.context.packageMetadata.packageName to patcher.get()
353-
}
397+
// region Save.
354398

355-
// region Save.
399+
apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply {
400+
patchingResult.addStepResult(
401+
PatchingStep.REBUILDING,
402+
{
403+
// 1. Apply patches to the APK
404+
patcherResult.applyTo(this)
356405

357-
apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply {
358-
patcherResult.applyTo(this)
359-
}.let { patchedApkFile ->
406+
// 2. Rip Libraries (if requested)
407+
if (ripLibs.isNotEmpty()) {
408+
ripLibraries(this, ripLibs)
409+
}
360410

361-
if (ripLibs.isNotEmpty()) {
362-
ripLibraries(patchedApkFile, ripLibs)
363-
}
411+
// 3. Zipalign (if not mounting)
412+
if (!mount) {
413+
logger.info("Running zipalign")
414+
val alignedApk = File.createTempFile("aligned", ".apk", temporaryFilesPath)
415+
val zipalign = zipalignBinaryPath?.absolutePath ?: "zipalign"
364416

365-
// zipalign logic start
366-
if (!mount) {
367-
logger.info("Running zipalign")
368-
val alignedApk = File.createTempFile("aligned", ".apk", temporaryFilesPath)
369-
val zipalign = zipalignBinaryPath?.absolutePath ?: "zipalign"
417+
val process = ProcessBuilder(
418+
zipalign, "-p", "-f", "4",
419+
this.absolutePath,
420+
alignedApk.absolutePath
421+
).redirectErrorStream(true).start()
370422

371-
val process = ProcessBuilder(
372-
zipalign, "-p", "-f", "4",
373-
patchedApkFile.absolutePath,
374-
alignedApk.absolutePath
375-
).redirectErrorStream(true).start()
423+
// Consume output to prevent buffer blocking and for debug logging
424+
val output = process.inputStream.bufferedReader().use { it.readText() }
425+
val exitCode = process.waitFor()
376426

377-
// Consume output to prevent buffer blocking and for debug logging
378-
val output = process.inputStream.bufferedReader().use { it.readText() }
379-
val exitCode = process.waitFor()
427+
if (exitCode != 0) {
428+
throw RuntimeException("zipalign failed (exit code $exitCode):\n$output")
429+
}
380430

381-
if (exitCode != 0) {
382-
throw RuntimeException("zipalign failed (exit code $exitCode):\n$output")
431+
Files.move(alignedApk.toPath(), this.toPath(), StandardCopyOption.REPLACE_EXISTING)
432+
logger.info("zipalign completed")
433+
}
434+
}
435+
)
436+
}.let { patchedApkFile ->
437+
if (!mount && !unsigned) {
438+
patchingResult.addStepResult(
439+
PatchingStep.SIGNING,
440+
{
441+
ApkUtils.signApk(
442+
patchedApkFile,
443+
outputFilePath,
444+
signer,
445+
ApkUtils.KeyStoreDetails(
446+
keystoreFilePath,
447+
keyStorePassword,
448+
keyStoreEntryAlias,
449+
keyStoreEntryPassword,
450+
),
451+
)
452+
}
453+
)
454+
} else {
455+
patchedApkFile.copyTo(outputFilePath, overwrite = true)
383456
}
384-
385-
Files.move(alignedApk.toPath(), patchedApkFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
386-
logger.info("zipalign completed")
387457
}
388-
// zipalign logic end
389-
390-
if (!mount && !unsigned) {
391-
ApkUtils.signApk(
392-
patchedApkFile,
393-
outputFilePath,
394-
signer,
395-
ApkUtils.KeyStoreDetails(
396-
keystoreFilePath,
397-
keyStorePassword,
398-
keyStoreEntryAlias,
399-
keyStoreEntryPassword,
400-
),
458+
logger.info("Saved to $outputFilePath")
459+
460+
// endregion
461+
462+
// region Install.
463+
464+
deviceSerial?.let {
465+
patchingResult.addStepResult(
466+
PatchingStep.INSTALLING,
467+
{
468+
runBlocking {
469+
val result = installer!!.install(Installer.Apk(outputFilePath, packageName))
470+
when (result) {
471+
RootInstallerResult.FAILURE -> {
472+
logger.severe("Failed to mount the patched APK file")
473+
throw IllegalStateException("Failed to mount the patched APK file")
474+
}
475+
is AdbInstallerResult.Failure -> {
476+
logger.severe(result.exception.toString())
477+
throw result.exception
478+
}
479+
else -> logger.info("Installed the patched APK file")
480+
}
481+
}
482+
}
401483
)
402-
} else {
403-
patchedApkFile.copyTo(outputFilePath, overwrite = true)
404484
}
405-
}
406485

407-
logger.info("Saved to $outputFilePath")
408-
409-
// endregion
410-
411-
// region Install.
412-
413-
deviceSerial?.let {
414-
runBlocking {
415-
when (val result = installer!!.install(Installer.Apk(outputFilePath, packageName))) {
416-
RootInstallerResult.FAILURE -> logger.severe("Failed to mount the patched APK file")
417-
is AdbInstallerResult.Failure -> logger.severe(result.exception.toString())
418-
else -> logger.info("Installed the patched APK file")
486+
// endregion
487+
} finally {
488+
patchingResultOutputFilePath?.let { outputFile ->
489+
outputFile.outputStream().use { outputStream ->
490+
Json.encodeToStream(patchingResult, outputStream)
419491
}
492+
logger.info("Patching result saved to $outputFile")
420493
}
421494
}
422495

423-
// endregion
424-
425496
if (purge) {
426497
logger.info("Purging temporary files")
427498
purge(temporaryFilesPath)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package app.morphe.cli.command.model
2+
3+
import kotlinx.serialization.ExperimentalSerializationApi
4+
import kotlinx.serialization.Serializable
5+
6+
@ExperimentalSerializationApi
7+
@Serializable
8+
data class FailedPatch(
9+
val patch: SerializablePatch,
10+
val reason: String
11+
)

0 commit comments

Comments
 (0)