Skip to content

Commit 304b3ea

Browse files
authored
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).
1 parent 9cf9326 commit 304b3ea

8 files changed

Lines changed: 323 additions & 72 deletions

File tree

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/libs.versions.toml

Lines changed: 4 additions & 2 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"
66
morphe-patcher = "1.0.1"
77
morphe-library = "1.0.1"
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: 141 additions & 70 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
@@ -19,6 +28,7 @@ import java.io.PrintWriter
1928
import java.io.StringWriter
2029
import java.util.logging.Logger
2130

31+
@OptIn(ExperimentalSerializationApi::class)
2232
@CommandLine.Command(
2333
name = "patch",
2434
description = ["Patch an APK file."],
@@ -115,6 +125,17 @@ internal object PatchCommand : Runnable {
115125
this.outputFilePath = outputFilePath?.absoluteFile
116126
}
117127

128+
private var patchingResultOutputFilePath: File? = null
129+
130+
@CommandLine.Option(
131+
names = ["-r", "--result-file"],
132+
description = ["Path to save the patching result file to"],
133+
)
134+
@Suppress("unused")
135+
private fun setPatchingResultOutputFilePath(outputFilePath: File?) {
136+
this.patchingResultOutputFilePath = outputFilePath?.absoluteFile
137+
}
138+
118139
@CommandLine.Option(
119140
names = ["-i", "--install"],
120141
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 {
233254
description = ["Disable signing of the final apk."],
234255
)
235256
private var unsigned: Boolean = false
236-
257+
237258
override fun run() {
238259
// region Setup
239260

@@ -291,88 +312,138 @@ internal object PatchCommand : Runnable {
291312

292313
val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher")
293314

294-
val (packageName, patcherResult) = Patcher(
295-
PatcherConfig(
296-
apk,
297-
patcherTemporaryFilesPath,
298-
aaptBinaryPath?.path,
299-
patcherTemporaryFilesPath.absolutePath,
300-
),
301-
).use { patcher ->
302-
val packageName = patcher.context.packageMetadata.packageName
303-
val packageVersion = patcher.context.packageMetadata.packageVersion
304-
305-
val filteredPatches = patches.filterPatchSelection(packageName, packageVersion)
306-
307-
logger.info("Setting patch options")
308-
309-
val patchesList = patches.toList()
310-
selection.filter { it.enabled != null }.associate {
311-
val enabledSelection = it.enabled!!
312-
313-
(enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to
314-
enabledSelection.options
315-
}.let(filteredPatches::setOptions)
316-
317-
patcher += filteredPatches
315+
val patchingResult = PatchingResult()
316+
317+
try {
318+
val (packageName, patcherResult) = Patcher(
319+
PatcherConfig(
320+
apk,
321+
patcherTemporaryFilesPath,
322+
aaptBinaryPath?.path,
323+
patcherTemporaryFilesPath.absolutePath,
324+
),
325+
).use { patcher ->
326+
val packageName = patcher.context.packageMetadata.packageName
327+
val packageVersion = patcher.context.packageMetadata.packageVersion
328+
329+
patchingResult.packageName = packageName
330+
patchingResult.packageVersion = packageVersion
331+
332+
val filteredPatches = patches.filterPatchSelection(packageName, packageVersion)
333+
334+
logger.info("Setting patch options")
335+
336+
val patchesList = patches.toList()
337+
selection.filter { it.enabled != null }.associate {
338+
val enabledSelection = it.enabled!!
339+
340+
(enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to
341+
enabledSelection.options
342+
}.let(filteredPatches::setOptions)
343+
344+
patcher += filteredPatches
345+
346+
// Execute patches.
347+
patchingResult.addStepResult(
348+
PatchingStep.PATCHING,
349+
{
350+
runBlocking {
351+
patcher().collect { patchResult ->
352+
patchResult.exception?.let { exception ->
353+
StringWriter().use { writer ->
354+
exception.printStackTrace(PrintWriter(writer))
355+
356+
logger.severe("\"${patchResult.patch}\" failed:\n$writer")
357+
358+
patchingResult.failedPatches.add(
359+
FailedPatch(
360+
patchResult.patch.toSerializablePatch(),
361+
writer.toString()
362+
)
363+
)
364+
patchingResult.success = false
365+
}
366+
} ?: patchResult.patch.let {
367+
patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch())
368+
logger.info("\"${patchResult.patch}\" succeeded")
369+
}
370+
}
371+
}
372+
}
373+
)
318374

319-
// Execute patches.
320-
runBlocking {
321-
patcher().collect { patchResult ->
322-
val exception = patchResult.exception
323-
?: return@collect logger.info("\"${patchResult.patch}\" succeeded")
375+
patcher.context.packageMetadata.packageName to patcher.get()
376+
}
324377

325-
StringWriter().use { writer ->
326-
exception.printStackTrace(PrintWriter(writer))
378+
// region Save.
327379

328-
logger.severe("\"${patchResult.patch}\" failed:\n$writer")
380+
apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply {
381+
patchingResult.addStepResult(
382+
PatchingStep.REBUILDING,
383+
{
384+
patcherResult.applyTo(this)
329385
}
386+
)
387+
}.let { patchedApkFile ->
388+
if (!mount && !unsigned) {
389+
patchingResult.addStepResult(
390+
PatchingStep.SIGNING,
391+
{
392+
ApkUtils.signApk(
393+
patchedApkFile,
394+
outputFilePath,
395+
signer,
396+
ApkUtils.KeyStoreDetails(
397+
keystoreFilePath,
398+
keyStorePassword,
399+
keyStoreEntryAlias,
400+
keyStoreEntryPassword,
401+
),
402+
)
403+
}
404+
)
405+
} else {
406+
patchedApkFile.copyTo(outputFilePath, overwrite = true)
330407
}
331408
}
332-
333-
patcher.context.packageMetadata.packageName to patcher.get()
334-
}
335-
336-
// region Save.
337-
338-
apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply {
339-
patcherResult.applyTo(this)
340-
}.let { patchedApkFile ->
341-
if (!mount && !unsigned) {
342-
ApkUtils.signApk(
343-
patchedApkFile,
344-
outputFilePath,
345-
signer,
346-
ApkUtils.KeyStoreDetails(
347-
keystoreFilePath,
348-
keyStorePassword,
349-
keyStoreEntryAlias,
350-
keyStoreEntryPassword,
351-
),
409+
logger.info("Saved to $outputFilePath")
410+
411+
// endregion
412+
413+
// region Install.
414+
415+
deviceSerial?.let {
416+
patchingResult.addStepResult(
417+
PatchingStep.INSTALLING,
418+
{
419+
runBlocking {
420+
val result = installer!!.install(Installer.Apk(outputFilePath, packageName))
421+
when (result) {
422+
RootInstallerResult.FAILURE -> {
423+
logger.severe("Failed to mount the patched APK file")
424+
throw IllegalStateException("Failed to mount the patched APK file")
425+
}
426+
is AdbInstallerResult.Failure -> {
427+
logger.severe(result.exception.toString())
428+
throw result.exception
429+
}
430+
else -> logger.info("Installed the patched APK file")
431+
}
432+
}
433+
}
352434
)
353-
} else {
354-
patchedApkFile.copyTo(outputFilePath, overwrite = true)
355435
}
356-
}
357-
358-
logger.info("Saved to $outputFilePath")
359-
360-
// endregion
361436

362-
// region Install.
363-
364-
deviceSerial?.let {
365-
runBlocking {
366-
when (val result = installer!!.install(Installer.Apk(outputFilePath, packageName))) {
367-
RootInstallerResult.FAILURE -> logger.severe("Failed to mount the patched APK file")
368-
is AdbInstallerResult.Failure -> logger.severe(result.exception.toString())
369-
else -> logger.info("Installed the patched APK file")
437+
// endregion
438+
} finally {
439+
patchingResultOutputFilePath?.let { outputFile ->
440+
outputFile.outputStream().use { outputStream ->
441+
Json.encodeToStream(patchingResult, outputStream)
370442
}
443+
logger.info("Patching result saved to $outputFile")
371444
}
372445
}
373446

374-
// endregion
375-
376447
if (purge) {
377448
logger.info("Purging temporary files")
378449
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+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package app.morphe.cli.command.model
2+
3+
import kotlinx.serialization.EncodeDefault
4+
import kotlinx.serialization.ExperimentalSerializationApi
5+
import kotlinx.serialization.Serializable
6+
7+
@ExperimentalSerializationApi
8+
@Serializable
9+
data class PatchingResult(
10+
var packageName: String? = null,
11+
var packageVersion: String? = null,
12+
var success: Boolean = true,
13+
@EncodeDefault val patchingSteps: MutableList<PatchingStepResult> = mutableListOf(),
14+
@EncodeDefault val appliedPatches: MutableList<SerializablePatch> = mutableListOf(),
15+
@EncodeDefault val failedPatches: MutableList<FailedPatch> = mutableListOf()
16+
)
17+
18+
@ExperimentalSerializationApi
19+
fun <R> PatchingResult.addStepResult(
20+
step: PatchingStep,
21+
block: () -> R,
22+
): R {
23+
try {
24+
val result = block()
25+
this.patchingSteps.add(
26+
PatchingStepResult(
27+
step = step,
28+
success = true
29+
)
30+
)
31+
return result
32+
} catch (e: Exception) {
33+
this.success = false
34+
this.patchingSteps.add(
35+
PatchingStepResult(
36+
step = step,
37+
success = false,
38+
message = e.toString()
39+
)
40+
)
41+
throw e
42+
}
43+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package app.morphe.cli.command.model
2+
3+
enum class PatchingStep {
4+
PATCHING,
5+
REBUILDING,
6+
SIGNING,
7+
INSTALLING
8+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
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 PatchingStepResult(
9+
val step: PatchingStep,
10+
val success: Boolean,
11+
val message: String? = null
12+
)

0 commit comments

Comments
 (0)