From 304b3ea4a9ed266d4a02f290dfda24218f3b098a Mon Sep 17 00:00:00 2001 From: Eric Ahn Date: Sun, 11 Jan 2026 14:10:02 -0800 Subject: [PATCH 1/2] feat: Add ability to write patching results to JSON file (#25) Adds the ability to dump the patching results to a JSON file by passing `-r`/`--result-file`. The result file contains package metadata, the result of the overall process, the result of each individual step, and a list of successfully applied/failed patches along with the options used and stacktraces (if applicable). --- build.gradle.kts | 2 + gradle/libs.versions.toml | 6 +- .../app/morphe/cli/command/PatchCommand.kt | 211 ++++++++++++------ .../morphe/cli/command/model/FailedPatch.kt | 11 + .../cli/command/model/PatchingResult.kt | 43 ++++ .../morphe/cli/command/model/PatchingStep.kt | 8 + .../cli/command/model/PatchingStepResult.kt | 12 + .../cli/command/model/SerializablePatch.kt | 102 +++++++++ 8 files changed, 323 insertions(+), 72 deletions(-) create mode 100644 src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt create mode 100644 src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt create mode 100644 src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt create mode 100644 src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt create mode 100644 src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt diff --git a/build.gradle.kts b/build.gradle.kts index b99f5df..6474d12 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlin) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.shadow) application `maven-publish` @@ -35,6 +36,7 @@ dependencies { implementation(libs.morphe.patcher) implementation(libs.morphe.library) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) implementation(libs.picocli) testImplementation(libs.kotlin.test) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b911af..0cfbcf7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] shadow = "8.3.9" -kotlin = "2.0.21" -kotlinx = "1.8.1" +kotlin = "2.3.0" +kotlinx = "1.9.0" picocli = "4.7.7" morphe-patcher = "1.0.1" morphe-library = "1.0.1" @@ -9,6 +9,7 @@ morphe-library = "1.0.1" [libraries] 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" } picocli = { module = "info.picocli:picocli", version.ref = "picocli" } morphe-patcher = { module = "app.morphe:morphe-patcher", version.ref = "morphe-patcher" } morphe-library = { module = "app.morphe:morphe-library-jvm", version.ref = "morphe-library" } @@ -16,3 +17,4 @@ morphe-library = { module = "app.morphe:morphe-library-jvm", version.ref = "morp [plugins] shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 44ffe51..c730844 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -1,5 +1,11 @@ package app.morphe.cli.command +import app.morphe.cli.command.model.FailedPatch +import app.morphe.cli.command.model.PatchingResult +import app.morphe.cli.command.model.PatchingStep +import app.morphe.cli.command.model.PatchingStepResult +import app.morphe.cli.command.model.addStepResult +import app.morphe.cli.command.model.toSerializablePatch import app.morphe.library.ApkUtils import app.morphe.library.ApkUtils.applyTo import app.morphe.library.installation.installer.* @@ -9,6 +15,9 @@ import app.morphe.patcher.PatcherConfig import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar import kotlinx.coroutines.runBlocking +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToStream import picocli.CommandLine import picocli.CommandLine.ArgGroup import picocli.CommandLine.Help.Visibility.ALWAYS @@ -19,6 +28,7 @@ import java.io.PrintWriter import java.io.StringWriter import java.util.logging.Logger +@OptIn(ExperimentalSerializationApi::class) @CommandLine.Command( name = "patch", description = ["Patch an APK file."], @@ -115,6 +125,17 @@ internal object PatchCommand : Runnable { this.outputFilePath = outputFilePath?.absoluteFile } + private var patchingResultOutputFilePath: File? = null + + @CommandLine.Option( + names = ["-r", "--result-file"], + description = ["Path to save the patching result file to"], + ) + @Suppress("unused") + private fun setPatchingResultOutputFilePath(outputFilePath: File?) { + this.patchingResultOutputFilePath = outputFilePath?.absoluteFile + } + @CommandLine.Option( names = ["-i", "--install"], description = ["Serial of the ADB device to install to. If not specified, the first connected device will be used."], @@ -233,7 +254,7 @@ internal object PatchCommand : Runnable { description = ["Disable signing of the final apk."], ) private var unsigned: Boolean = false - + override fun run() { // region Setup @@ -291,88 +312,138 @@ internal object PatchCommand : Runnable { val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher") - val (packageName, patcherResult) = Patcher( - PatcherConfig( - apk, - patcherTemporaryFilesPath, - aaptBinaryPath?.path, - patcherTemporaryFilesPath.absolutePath, - ), - ).use { patcher -> - val packageName = patcher.context.packageMetadata.packageName - val packageVersion = patcher.context.packageMetadata.packageVersion - - val filteredPatches = patches.filterPatchSelection(packageName, packageVersion) - - logger.info("Setting patch options") - - val patchesList = patches.toList() - selection.filter { it.enabled != null }.associate { - val enabledSelection = it.enabled!! - - (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to - enabledSelection.options - }.let(filteredPatches::setOptions) - - patcher += filteredPatches + val patchingResult = PatchingResult() + + try { + val (packageName, patcherResult) = Patcher( + PatcherConfig( + apk, + patcherTemporaryFilesPath, + aaptBinaryPath?.path, + patcherTemporaryFilesPath.absolutePath, + ), + ).use { patcher -> + val packageName = patcher.context.packageMetadata.packageName + val packageVersion = patcher.context.packageMetadata.packageVersion + + patchingResult.packageName = packageName + patchingResult.packageVersion = packageVersion + + val filteredPatches = patches.filterPatchSelection(packageName, packageVersion) + + logger.info("Setting patch options") + + val patchesList = patches.toList() + selection.filter { it.enabled != null }.associate { + val enabledSelection = it.enabled!! + + (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to + enabledSelection.options + }.let(filteredPatches::setOptions) + + patcher += filteredPatches + + // Execute patches. + patchingResult.addStepResult( + PatchingStep.PATCHING, + { + runBlocking { + patcher().collect { patchResult -> + patchResult.exception?.let { exception -> + StringWriter().use { writer -> + exception.printStackTrace(PrintWriter(writer)) + + logger.severe("\"${patchResult.patch}\" failed:\n$writer") + + patchingResult.failedPatches.add( + FailedPatch( + patchResult.patch.toSerializablePatch(), + writer.toString() + ) + ) + patchingResult.success = false + } + } ?: patchResult.patch.let { + patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) + logger.info("\"${patchResult.patch}\" succeeded") + } + } + } + } + ) - // Execute patches. - runBlocking { - patcher().collect { patchResult -> - val exception = patchResult.exception - ?: return@collect logger.info("\"${patchResult.patch}\" succeeded") + patcher.context.packageMetadata.packageName to patcher.get() + } - StringWriter().use { writer -> - exception.printStackTrace(PrintWriter(writer)) + // region Save. - logger.severe("\"${patchResult.patch}\" failed:\n$writer") + apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply { + patchingResult.addStepResult( + PatchingStep.REBUILDING, + { + patcherResult.applyTo(this) } + ) + }.let { patchedApkFile -> + if (!mount && !unsigned) { + patchingResult.addStepResult( + PatchingStep.SIGNING, + { + ApkUtils.signApk( + patchedApkFile, + outputFilePath, + signer, + ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + keyStoreEntryAlias, + keyStoreEntryPassword, + ), + ) + } + ) + } else { + patchedApkFile.copyTo(outputFilePath, overwrite = true) } } - - patcher.context.packageMetadata.packageName to patcher.get() - } - - // region Save. - - apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply { - patcherResult.applyTo(this) - }.let { patchedApkFile -> - if (!mount && !unsigned) { - ApkUtils.signApk( - patchedApkFile, - outputFilePath, - signer, - ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - keyStoreEntryAlias, - keyStoreEntryPassword, - ), + logger.info("Saved to $outputFilePath") + + // endregion + + // region Install. + + deviceSerial?.let { + patchingResult.addStepResult( + PatchingStep.INSTALLING, + { + runBlocking { + val result = installer!!.install(Installer.Apk(outputFilePath, packageName)) + when (result) { + RootInstallerResult.FAILURE -> { + logger.severe("Failed to mount the patched APK file") + throw IllegalStateException("Failed to mount the patched APK file") + } + is AdbInstallerResult.Failure -> { + logger.severe(result.exception.toString()) + throw result.exception + } + else -> logger.info("Installed the patched APK file") + } + } + } ) - } else { - patchedApkFile.copyTo(outputFilePath, overwrite = true) } - } - - logger.info("Saved to $outputFilePath") - - // endregion - // region Install. - - deviceSerial?.let { - runBlocking { - when (val result = installer!!.install(Installer.Apk(outputFilePath, packageName))) { - RootInstallerResult.FAILURE -> logger.severe("Failed to mount the patched APK file") - is AdbInstallerResult.Failure -> logger.severe(result.exception.toString()) - else -> logger.info("Installed the patched APK file") + // endregion + } finally { + patchingResultOutputFilePath?.let { outputFile -> + outputFile.outputStream().use { outputStream -> + Json.encodeToStream(patchingResult, outputStream) } + logger.info("Patching result saved to $outputFile") } } - // endregion - if (purge) { logger.info("Purging temporary files") purge(temporaryFilesPath) diff --git a/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt b/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt new file mode 100644 index 0000000..3b80ce4 --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt @@ -0,0 +1,11 @@ +package app.morphe.cli.command.model + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable + +@ExperimentalSerializationApi +@Serializable +data class FailedPatch( + val patch: SerializablePatch, + val reason: String +) diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt new file mode 100644 index 0000000..2ffaaf2 --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt @@ -0,0 +1,43 @@ +package app.morphe.cli.command.model + +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable + +@ExperimentalSerializationApi +@Serializable +data class PatchingResult( + var packageName: String? = null, + var packageVersion: String? = null, + var success: Boolean = true, + @EncodeDefault val patchingSteps: MutableList = mutableListOf(), + @EncodeDefault val appliedPatches: MutableList = mutableListOf(), + @EncodeDefault val failedPatches: MutableList = mutableListOf() +) + +@ExperimentalSerializationApi +fun PatchingResult.addStepResult( + step: PatchingStep, + block: () -> R, +): R { + try { + val result = block() + this.patchingSteps.add( + PatchingStepResult( + step = step, + success = true + ) + ) + return result + } catch (e: Exception) { + this.success = false + this.patchingSteps.add( + PatchingStepResult( + step = step, + success = false, + message = e.toString() + ) + ) + throw e + } +} diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt new file mode 100644 index 0000000..8f521da --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt @@ -0,0 +1,8 @@ +package app.morphe.cli.command.model + +enum class PatchingStep { + PATCHING, + REBUILDING, + SIGNING, + INSTALLING +} \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt new file mode 100644 index 0000000..419ba2f --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt @@ -0,0 +1,12 @@ +package app.morphe.cli.command.model + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable + +@ExperimentalSerializationApi +@Serializable +data class PatchingStepResult( + val step: PatchingStep, + val success: Boolean, + val message: String? = null +) diff --git a/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt b/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt new file mode 100644 index 0000000..fb484cd --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt @@ -0,0 +1,102 @@ +package app.morphe.cli.command.model + +import app.morphe.patcher.patch.Patch +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject + +@ExperimentalSerializationApi +@Serializable(with = PatchSerializer::class) +data class SerializablePatch( + val name: String? = null, + val index: Int? = null, + val options: Map = emptyMap() +) + +@ExperimentalSerializationApi +fun Patch<*>.toSerializablePatch(): SerializablePatch { + return SerializablePatch( + name = this.name, + options = this.options.mapValues { PatchSerializer.serializeValue(it.value.value) } + ) +} + +@ExperimentalSerializationApi +object PatchSerializer : KSerializer { + fun serializeValue(value: Any?): JsonElement { + return when (value) { + null -> JsonNull + is JsonElement -> value + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + is List<*> -> { + buildJsonArray { + value.forEach { item -> + add(serializeValue(item)) + } + } + } + is Map<*, *> -> { + buildJsonObject { + value.forEach { + require(it.key is String) { + "Map keys must be of type String for serialization, but found: ${it.key?.let { k -> k::class }}" + } + put(it.key as String, serializeValue(it.value)) + } + } + } + else -> JsonPrimitive(value.toString()) + } + } + + override fun serialize(encoder: Encoder, value: SerializablePatch) { + require(encoder is JsonEncoder) + + val jsonElement = buildJsonObject { + require(value.name != null || value.index != null) { + "Either name or index must be provided for a Patch." + } + + if (value.name != null) { + put("name", JsonPrimitive(value.name)) + } else { + put("index", JsonPrimitive(value.index)) + } + + if (value.options.isNotEmpty()) { + put("options", buildJsonArray { + value.options.forEach { (key, optionValue) -> + add(buildJsonObject { + put("key", JsonPrimitive(key)) + put("value", optionValue) + }) + } + }) + } + } + encoder.encodeJsonElement(jsonElement) + } + + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Patch") { + element("name") + element("index") + element>("options") + } + + override fun deserialize(decoder: Decoder): SerializablePatch { + TODO("Not yet implemented") + } +} From 85978736548d65571b78e06365962d9d3e82029f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 11 Jan 2026 22:11:59 +0000 Subject: [PATCH 2/2] chore: Release v1.2.0-dev.1 [skip ci] # [1.2.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.1.0...v1.2.0-dev.1) (2026-01-11) ### Features * Add ability to write patching results to JSON file ([#25](https://github.com/MorpheApp/morphe-cli/issues/25)) ([304b3ea](https://github.com/MorpheApp/morphe-cli/commit/304b3ea4a9ed266d4a02f290dfda24218f3b098a)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b353d..a19b972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.2.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.1.0...v1.2.0-dev.1) (2026-01-11) + + +### Features + +* Add ability to write patching results to JSON file ([#25](https://github.com/MorpheApp/morphe-cli/issues/25)) ([304b3ea](https://github.com/MorpheApp/morphe-cli/commit/304b3ea4a9ed266d4a02f290dfda24218f3b098a)) + # [1.1.0](https://github.com/MorpheApp/morphe-cli/compare/v1.0.0...v1.1.0) (2026-01-10) diff --git a/gradle.properties b/gradle.properties index 24b8e24..9d4ceb0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.1.0 +version = 1.2.0-dev.1