From 133b036ce274eb0332072023616eb89602f6e3cb Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:46:17 +0530 Subject: [PATCH 1/4] potential apkm fix --- .gitignore | 1 + build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + .../app/morphe/cli/command/PatchCommand.kt | 56 ++++++++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index e5cbb64..402927a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ # Local configuration file (sdk path, etc) local.properties +gradle.properties # Log/OS Files *.log diff --git a/build.gradle.kts b/build.gradle.kts index 6474d12..4788feb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.picocli) + implementation(libs.arsclib) testImplementation(libs.kotlin.test) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0cfbcf7..525190d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,8 +5,10 @@ kotlinx = "1.9.0" picocli = "4.7.7" morphe-patcher = "1.0.1" morphe-library = "1.0.1" +arsclib = "1.3.8" [libraries] +arsclib = { module = "io.github.reandroid:ARSCLib", version.ref = "arsclib" } 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" } diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index c730844..107f31c 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -14,6 +14,8 @@ import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar +import com.reandroid.apk.ApkBundle +import java.util.zip.ZipFile import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -312,12 +314,54 @@ internal object PatchCommand : Runnable { val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher") + // Checking if the file is in apkm format (like reddit) + var mergedApkToCleanup: File? = null + val inputApk = if (apk.extension.equals("apkm", ignoreCase = true)) { + logger.info("Detected APKM file, converting to APK...") + temporaryFilesPath.mkdirs() + + // Extract APKM to temp directory + val extractedDir = temporaryFilesPath.resolve("apkm_extracted") + extractedDir.mkdirs() + + ZipFile(apk).use { zip -> + zip.entries().asSequence().forEach { entry -> + val outFile = extractedDir.resolve(entry.name) + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + zip.getInputStream(entry).use { input -> + outFile.outputStream().use { output -> + input.copyTo(output) + } + } + } + } + } + + // Save merged APK to output directory (outside temp structure for testing, but ideally this is in the temp folder too and gets cleaned) + val outputApk = outputFilePath.parentFile.resolve("${apk.nameWithoutExtension}-merged.apk") + + // Use ARSCLib to merge split APKs + val bundle = ApkBundle() + bundle.loadApkDirectory(extractedDir) + val mergedModule = bundle.mergeModules() + mergedModule.writeApk(outputApk) + + logger.info("Conversion complete: ${outputApk.path}") + mergedApkToCleanup = outputApk + outputApk + } else { + apk + } + val patchingResult = PatchingResult() try { val (packageName, patcherResult) = Patcher( PatcherConfig( - apk, + inputApk, patcherTemporaryFilesPath, aaptBinaryPath?.path, patcherTemporaryFilesPath.absolutePath, @@ -377,7 +421,7 @@ internal object PatchCommand : Runnable { // region Save. - apk.copyTo(temporaryFilesPath.resolve(apk.name), overwrite = true).apply { + inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { patchingResult.addStepResult( PatchingStep.REBUILDING, { @@ -406,6 +450,7 @@ internal object PatchCommand : Runnable { patchedApkFile.copyTo(outputFilePath, overwrite = true) } } + logger.info("Saved to $outputFilePath") // endregion @@ -448,6 +493,13 @@ internal object PatchCommand : Runnable { logger.info("Purging temporary files") purge(temporaryFilesPath) } + + // Clean up merged APK if we created one from APKM + mergedApkToCleanup?.let { + if (it.delete()) { + logger.info("Cleaned up merged APK: ${it.path}") + } + } } /** From 1c1d66521ea0a732b05aff001c8881718a76dfcc Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:54:32 +0530 Subject: [PATCH 2/4] Update PatchCommand.kt --- .../app/morphe/cli/command/PatchCommand.kt | 199 ++++++------------ 1 file changed, 68 insertions(+), 131 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 107f31c..550e015 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -30,7 +30,6 @@ 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."], @@ -127,17 +126,6 @@ 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."], @@ -356,139 +344,88 @@ internal object PatchCommand : Runnable { apk } - val patchingResult = PatchingResult() - - try { - val (packageName, patcherResult) = Patcher( - PatcherConfig( - inputApk, - 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") - } - } - } - } - ) + val (packageName, patcherResult) = Patcher( + PatcherConfig( + inputApk, + patcherTemporaryFilesPath, + aaptBinaryPath?.path, + patcherTemporaryFilesPath.absolutePath, + ), + ).use { patcher -> + val packageName = patcher.context.packageMetadata.packageName + val packageVersion = patcher.context.packageMetadata.packageVersion - patcher.context.packageMetadata.packageName to patcher.get() - } + 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!! - // region Save. + (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to + enabledSelection.options + }.let(filteredPatches::setOptions) - inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { - patchingResult.addStepResult( - PatchingStep.REBUILDING, - { - patcherResult.applyTo(this) + patcher += filteredPatches + + // Execute patches. + runBlocking { + patcher().collect { patchResult -> + val exception = patchResult.exception + ?: return@collect logger.info("\"${patchResult.patch}\" succeeded") + + StringWriter().use { writer -> + exception.printStackTrace(PrintWriter(writer)) + + logger.severe("\"${patchResult.patch}\" failed:\n$writer") } - ) - }.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) } } - 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") - } - } - } + patcher.context.packageMetadata.packageName to patcher.get() + } + + // region Save. + + inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { + patcherResult.applyTo(this) + }.let { patchedApkFile -> + if (!mount && !unsigned) { + ApkUtils.signApk( + patchedApkFile, + outputFilePath, + signer, + ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + keyStoreEntryAlias, + keyStoreEntryPassword, + ), ) + } else { + patchedApkFile.copyTo(outputFilePath, overwrite = true) } + } + + logger.info("Saved to $outputFilePath") + + // endregion + + // region Install. - // endregion - } finally { - patchingResultOutputFilePath?.let { outputFile -> - outputFile.outputStream().use { outputStream -> - Json.encodeToStream(patchingResult, outputStream) + 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") } - logger.info("Patching result saved to $outputFile") } } + // endregion + if (purge) { logger.info("Purging temporary files") purge(temporaryFilesPath) From e24d7814e600dbd7b864739f68f38eb8dfa4e519 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 1 Feb 2026 03:25:05 +0530 Subject: [PATCH 3/4] Update .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 402927a..e5cbb64 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ build/ # Local configuration file (sdk path, etc) local.properties -gradle.properties # Log/OS Files *.log From d64cd46ecf6998689d472215a75c7f0a208d0da1 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 1 Feb 2026 09:23:41 +0530 Subject: [PATCH 4/4] Update PatchCommand.kt --- .../app/morphe/cli/command/PatchCommand.kt | 199 ++++++++++++------ 1 file changed, 131 insertions(+), 68 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 550e015..107f31c 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -30,6 +30,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."], @@ -126,6 +127,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."], @@ -344,88 +356,139 @@ internal object PatchCommand : Runnable { apk } - val (packageName, patcherResult) = Patcher( - PatcherConfig( - inputApk, - 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( + inputApk, + 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") + inputApk.copyTo(temporaryFilesPath.resolve(inputApk.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. - - inputApk.copyTo(temporaryFilesPath.resolve(inputApk.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)