Skip to content
Merged
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: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
[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"

[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" }

[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" }
211 changes: 141 additions & 70 deletions src/main/kotlin/app/morphe/cli/command/PatchCommand.kt
Original file line number Diff line number Diff line change
@@ -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.*
Expand All @@ -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
Expand All @@ -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."],
Expand Down Expand Up @@ -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."],
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt
Original file line number Diff line number Diff line change
@@ -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
)
43 changes: 43 additions & 0 deletions src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt
Original file line number Diff line number Diff line change
@@ -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<PatchingStepResult> = mutableListOf(),
@EncodeDefault val appliedPatches: MutableList<SerializablePatch> = mutableListOf(),
@EncodeDefault val failedPatches: MutableList<FailedPatch> = mutableListOf()
)

@ExperimentalSerializationApi
fun <R> 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
}
}
8 changes: 8 additions & 0 deletions src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.morphe.cli.command.model

enum class PatchingStep {
PATCHING,
REBUILDING,
SIGNING,
INSTALLING
}
12 changes: 12 additions & 0 deletions src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt
Original file line number Diff line number Diff line change
@@ -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
)
Loading
Loading