diff --git a/CHANGELOG.md b/CHANGELOG.md index d9f41a1..53bfee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +# [1.4.0-dev.5](https://github.com/MorpheApp/morphe-cli/compare/v1.4.0-dev.4...v1.4.0-dev.5) (2026-02-14) + + +### Bug Fixes + +* Do not log patch name more than once if disabled ([#49](https://github.com/MorpheApp/morphe-cli/issues/49)) ([b980bb8](https://github.com/MorpheApp/morphe-cli/commit/b980bb8e0b3bf8eb4c7af1fe289ff1b63c437fa3)) + +# [1.4.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.4.0-dev.3...v1.4.0-dev.4) (2026-02-14) + + +### Bug Fixes + +* Allow enabling/disabling patches using case insensitive patch names ([#48](https://github.com/MorpheApp/morphe-cli/issues/48)) ([03a280a](https://github.com/MorpheApp/morphe-cli/commit/03a280abea6c9187eec22548707eb889b0252c3f)) + +# [1.4.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.4.0-dev.2...v1.4.0-dev.3) (2026-02-12) + + +### Features + +* Add `--continue-on-error` argument, return non zero exit code if patching fails ([#47](https://github.com/MorpheApp/morphe-cli/issues/47)) ([255646b](https://github.com/MorpheApp/morphe-cli/commit/255646b250237087ab7d7f9733daa6751b7e4016)) + +# [1.4.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.4.0-dev.1...v1.4.0-dev.2) (2026-02-10) + + +### Features + +* Add `--striplibs` argument to strip unwanted architectures ([#46](https://github.com/MorpheApp/morphe-cli/issues/46)) ([7442d94](https://github.com/MorpheApp/morphe-cli/commit/7442d942d392b3e1e9ce959c30db8460bffee8d6)) + +# [1.4.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.3.0...v1.4.0-dev.1) (2026-02-07) + + +### Features + +* Support patching APKM bundles ([#40](https://github.com/MorpheApp/morphe-cli/issues/40)) ([bfe43d0](https://github.com/MorpheApp/morphe-cli/commit/bfe43d0b747d0e336a3f36f048e85907a140f1fc)) + # [1.3.0](https://github.com/MorpheApp/morphe-cli/compare/v1.2.0...v1.3.0) (2026-02-04) diff --git a/NOTICE b/NOTICE index 5d5a173..77a221f 100644 --- a/NOTICE +++ b/NOTICE @@ -8,6 +8,14 @@ outside this repository, and do not change the terms of the GPLv3 license. For the full license text, see the LICENSE file or: https://www.gnu.org/licenses/gpl-3.0.html +7b. Attribution Requirement +--------------------------- + +Any distributed source code that incorporates Morphe CLI, +including modified versions and derivative works, must retain this NOTICE file. + +https://morphe.software + 7c. Project Name Restriction ---------------------------- diff --git a/build.gradle.kts b/build.gradle.kts index 6474d12..7fb4ed5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,12 +32,33 @@ repositories { maven { url = uri("https://jitpack.io") } } +val apkEditorLib by configurations.creating + +val strippedApkEditorLib by tasks.registering(org.gradle.jvm.tasks.Jar::class) { + archiveFileName.set("APKEditor-cli.jar") + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + doFirst { + from(apkEditorLib.resolve().map { zipTree(it) }) + } + exclude( + "org/xmlpull/**", + "antlr/**", + "org/antlr/**", + "com/beust/jcommander/**", + "javax/annotation/**", + "smali.properties", + "baksmali.properties" + ) +} + dependencies { implementation(libs.morphe.patcher) implementation(libs.morphe.library) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.picocli) + apkEditorLib(files("$rootDir/libs/APKEditor-1.4.7.jar")) + implementation(files(strippedApkEditorLib)) testImplementation(libs.kotlin.test) } @@ -65,7 +86,11 @@ tasks { } shadowJar { - exclude("/prebuilt/linux/aapt", "/prebuilt/windows/aapt.exe", "/prebuilt/*/aapt_*") + exclude( + "/prebuilt/linux/aapt", + "/prebuilt/windows/aapt.exe", + "/prebuilt/*/aapt_*", + ) minimize { exclude(dependency("org.bouncycastle:.*")) exclude(dependency("app.morphe:morphe-patcher")) diff --git a/gradle.properties b/gradle.properties index f621b40..764c872 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.3.0 +version = 1.4.0-dev.5 diff --git a/libs/APKEditor-1.4.7.jar b/libs/APKEditor-1.4.7.jar new file mode 100644 index 0000000..f1a0d81 Binary files /dev/null and b/libs/APKEditor-1.4.7.jar differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 3afed46..ead101f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,20 @@ rootProject.name = "morphe-cli" + +// Include morphe-patcher and morphe-library as composite builds if they exist locally +val morphePatcherDir = file("../morphe-patcher") +if (morphePatcherDir.exists()) { + includeBuild(morphePatcherDir) { + dependencySubstitution { + substitute(module("app.morphe:morphe-patcher")).using(project(":")) + } + } +} + +val morpheLibraryDir = file("../morphe-library") +if (morpheLibraryDir.exists()) { + includeBuild(morpheLibraryDir) { + dependencySubstitution { + substitute(module("app.morphe:morphe-library")).using(project(":")) + } + } +} diff --git a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt index d8eff33..01f481c 100644 --- a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt @@ -5,11 +5,13 @@ import app.morphe.library.logging.Logger import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.IVersionProvider -import java.util.* +import java.util.Properties +import kotlin.system.exitProcess fun main(args: Array) { Logger.setDefault() - CommandLine(MainCommand).execute(*args).let(System::exit) + val exitCode = CommandLine(MainCommand).execute(*args) + exitProcess(exitCode) } private object CLIVersionProvider : IVersionProvider { @@ -37,6 +39,6 @@ private object CLIVersionProvider : IVersionProvider { ListPatchesCommand::class, ListCompatibleVersions::class, UtilityCommand::class, - ], + ] ) private object MainCommand diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index c730844..56526e8 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -3,9 +3,9 @@ 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.gui.util.ApkLibraryStripper import app.morphe.library.ApkUtils import app.morphe.library.ApkUtils.applyTo import app.morphe.library.installation.installer.* @@ -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.apkeditor.merge.Merger +import com.reandroid.apkeditor.merge.MergerOptions import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -26,6 +28,7 @@ import picocli.CommandLine.Spec import java.io.File import java.io.PrintWriter import java.io.StringWriter +import java.util.concurrent.Callable import java.util.logging.Logger @OptIn(ExperimentalSerializationApi::class) @@ -33,7 +36,11 @@ import java.util.logging.Logger name = "patch", description = ["Patch an APK file."], ) -internal object PatchCommand : Runnable { +internal object PatchCommand : Callable { + + private const val EXIT_CODE_SUCCESS = 0 + private const val EXIT_CODE_ERROR = 1 + private val logger = Logger.getLogger(this::class.java.name) @Spec @@ -255,12 +262,26 @@ internal object PatchCommand : Runnable { ) private var unsigned: Boolean = false - override fun run() { + @CommandLine.Option( + names = ["--striplibs"], + description = ["Architectures to keep, comma-separated (e.g. arm64-v8a,x86). Strips all other native architectures."], + split = ",", + ) + private var striplibs: List = emptyList() + + @CommandLine.Option( + names = ["--continue-on-error"], + description = ["Continue patching even if a patch fails. By default, patching stops on the first error."], + showDefaultValue = ALWAYS, + ) + private var continueOnError: Boolean = false + + override fun call(): Int { // region Setup val outputFilePath = outputFilePath ?: File("").absoluteFile.resolve( - "${apk.nameWithoutExtension}-patched.${apk.extension}", + "${apk.nameWithoutExtension}-patched.apk", ) val temporaryFilesPath = @@ -281,7 +302,7 @@ internal object PatchCommand : Runnable { } else { AdbInstaller(deviceSerial) } - } catch (e: DeviceNotFoundException) { + } catch (_: DeviceNotFoundException) { if (deviceSerial?.isNotEmpty() == true) { logger.severe( "Device with serial $deviceSerial not found to install to. " + @@ -294,7 +315,7 @@ internal object PatchCommand : Runnable { ) } - return + return EXIT_CODE_ERROR } } else { null @@ -302,22 +323,44 @@ internal object PatchCommand : Runnable { // endregion - // region Load patches + val patchingResult = PatchingResult() + var mergedApkToCleanup: File? = null + + try { + // region Load patches - logger.info("Loading patches") + logger.info("Loading patches") - val patches = loadPatchesFromJar(patchesFiles) + val patches = loadPatchesFromJar(patchesFiles) - // endregion + // endregion - val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher") + val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher") - val patchingResult = PatchingResult() + // Checking if the file is in apkm format (like reddit) + val inputApk = if (apk.extension.equals("apkm", ignoreCase = true)) { + logger.info("Merging APKM bundle") + + // Save merged APK to output directory (will be cleaned up after patching) + val outputApk = outputFilePath.parentFile.resolve("${apk.nameWithoutExtension}-merged.apk") + + // Use APKEditor's Merger directly (handles extraction and merging) + val mergerOptions = MergerOptions().apply { + inputFile = apk // Original APKM file + outputFile = outputApk + cleanMeta = true + } + Merger(mergerOptions).run() + + mergedApkToCleanup = outputApk + outputApk + } else { + apk + } - try { val (packageName, patcherResult) = Patcher( PatcherConfig( - apk, + inputApk, patcherTemporaryFilesPath, aaptBinaryPath?.path, patcherTemporaryFilesPath.absolutePath, @@ -337,8 +380,11 @@ internal object PatchCommand : Runnable { selection.filter { it.enabled != null }.associate { val enabledSelection = it.enabled!! - (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to - enabledSelection.options + val resolvedName = enabledSelection.selector.name?.let { userInput -> + patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true) }?.name ?: userInput + } ?: patchesList[enabledSelection.selector.index!!].name!! + + resolvedName to enabledSelection.options }.let(filteredPatches::setOptions) patcher += filteredPatches @@ -362,6 +408,13 @@ internal object PatchCommand : Runnable { ) ) patchingResult.success = false + + if (!continueOnError) { + throw PatchFailedException( + "\"${patchResult.patch}\" failed", + exception + ) + } } } ?: patchResult.patch.let { patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) @@ -377,13 +430,24 @@ 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, { patcherResult.applyTo(this) } ) + }.also { rebuiltApk -> + if (striplibs.isNotEmpty()) { + patchingResult.addStepResult( + PatchingStep.STRIPPING_LIBS, + { + ApkLibraryStripper.stripLibraries(rebuiltApk, striplibs) { msg -> + logger.info(msg) + } + } + ) + } }.let { patchedApkFile -> if (!mount && !unsigned) { patchingResult.addStepResult( @@ -406,6 +470,7 @@ internal object PatchCommand : Runnable { patchedApkFile.copyTo(outputFilePath, overwrite = true) } } + logger.info("Saved to $outputFilePath") // endregion @@ -435,6 +500,17 @@ internal object PatchCommand : Runnable { } // endregion + } catch (e: PatchFailedException) { + logger.severe("Patching aborted: ${e.message}") + logger.info( + "Use --continue-on-error to skip failed patches and continue patching" + ) + return EXIT_CODE_ERROR + } catch (e: Exception) { + // Should never happen. + logger.severe("An unexpected error occurred: ${e.message}") + e.printStackTrace() + return EXIT_CODE_ERROR } finally { patchingResultOutputFilePath?.let { outputFile -> outputFile.outputStream().use { outputStream -> @@ -442,12 +518,21 @@ internal object PatchCommand : Runnable { } logger.info("Patching result saved to $outputFile") } - } - if (purge) { - logger.info("Purging temporary files") - purge(temporaryFilesPath) + if (purge) { + logger.info("Purging temporary files") + purge(temporaryFilesPath) + } + + // Clean up merged APK if we created one from APKM + mergedApkToCleanup?.let { + if (!it.delete()) { + logger.warning("Could not clean up merged APK: ${it.path}") + } + } } + + return EXIT_CODE_SUCCESS } /** @@ -462,22 +547,19 @@ internal object PatchCommand : Runnable { packageVersion: String, ): Set> = buildSet { val enabledPatchesByName = - selection.mapNotNull { it.enabled?.selector?.name }.toSet() + selection.mapNotNull { it.enabled?.selector?.name?.lowercase() }.toSet() val enabledPatchesByIndex = selection.mapNotNull { it.enabled?.selector?.index }.toSet() val disabledPatches = - selection.mapNotNull { it.disable?.selector?.name }.toSet() + selection.mapNotNull { it.disable?.selector?.name?.lowercase() }.toSet() val disabledPatchesByIndex = selection.mapNotNull { it.disable?.selector?.index }.toSet() this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) -> val patchName = patch.name!! - val isManuallyDisabled = patchName in disabledPatches || i in disabledPatchesByIndex - if (isManuallyDisabled) return@patchLoop logger.info("\"$patchName\" disabled manually") - - // Make sure the patch is compatible with the supplied APK files package name and version. + // Check package compatibility first to avoid duplicate logs for multi-app patches. patch.compatiblePackages?.let { packages -> packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) -> if (versions?.isEmpty() == true) { @@ -505,8 +587,11 @@ internal object PatchCommand : Runnable { return@let } ?: logger.fine("\"$patchName\" has no package constraints") + val isManuallyDisabled = patchName.lowercase() in disabledPatches || i in disabledPatchesByIndex + if (isManuallyDisabled) return@patchLoop logger.info("\"$patchName\" disabled manually") + val isEnabled = !exclusive && patch.use - val isManuallyEnabled = patchName in enabledPatchesByName || i in enabledPatchesByIndex + val isManuallyEnabled = patchName.lowercase() in enabledPatchesByName || i in enabledPatchesByIndex if (!(isEnabled || isManuallyEnabled)) { return@patchLoop logger.info("\"$patchName\" disabled") @@ -528,3 +613,5 @@ internal object PatchCommand : Runnable { logger.info(result) } } + +private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt index 8f521da..cef96eb 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt @@ -3,6 +3,7 @@ package app.morphe.cli.command.model enum class PatchingStep { PATCHING, REBUILDING, + STRIPPING_LIBS, SIGNING, INSTALLING } \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/gui/util/ApkLibraryStripper.kt b/src/main/kotlin/app/morphe/gui/util/ApkLibraryStripper.kt new file mode 100644 index 0000000..53dfbf4 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/ApkLibraryStripper.kt @@ -0,0 +1,109 @@ +package app.morphe.gui.util + +import java.io.File +import java.util.logging.Logger +import java.util.zip.ZipFile +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Strips native libraries from an APK, keeping only specified architectures. + */ +object ApkLibraryStripper { + + private val VALID_ARCHITECTURES = setOf( + "armeabi-v7a", + "arm64-v8a", + "x86", + "x86_64", + // Old obsolete architectures. Only found in Android 6.0 and earlier. + "armeabi", + "mips", + "mips64", + ) + + /** + * Validates that all requested architectures are known. + * Throws IllegalArgumentException if any are invalid. + */ + private fun validateArchitectures(architectures: List) { + // Error on no recognizable architectures. + require(architectures.isNotEmpty() && architectures.any { it in VALID_ARCHITECTURES }) { + "No valid architectures specified with --striplibs: $architectures " + + "Valid architectures are: $VALID_ARCHITECTURES" + } + + // Warn on unrecognizable. + val invalid = architectures.filter { it !in VALID_ARCHITECTURES } + if (invalid.isNotEmpty()) { + Logger.getLogger(this::class.java.name).warning( + "Ignoring unrecognized --striplibs architecture: '$invalid' " + + "Valid architectures are: $VALID_ARCHITECTURES" + ) + } + } + + /** + * Strips native libraries from an APK file, keeping only the specified architectures. + * + * @param apkFile The APK file to strip libraries from (modified in-place). + * @param architecturesToKeep List of architectures to keep (e.g., ["arm64-v8a"]). + * @param onProgress Optional callback for progress updates. + */ + fun stripLibraries(apkFile: File, architecturesToKeep: List, onProgress: (String) -> Unit = {}) { + validateArchitectures(architecturesToKeep) + + val keepSet = architecturesToKeep.toSet() + val tempFile = File(apkFile.parentFile, "${apkFile.name}.tmp") + + var strippedCount = 0 + + ZipFile(apkFile).use { zip -> + ZipOutputStream(tempFile.outputStream().buffered()).use { zos -> + val entries = zip.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + + if (shouldStripEntry(entry.name, keepSet)) { + strippedCount++ + continue + } + + val newEntry = ZipEntry(entry.name).apply { + if (entry.method == ZipEntry.STORED) { + method = ZipEntry.STORED + size = entry.size + compressedSize = entry.compressedSize + crc = entry.crc + } + entry.extra?.let { extra = it } + } + + zos.putNextEntry(newEntry) + zip.getInputStream(entry).use { it.copyTo(zos) } + zos.closeEntry() + } + } + } + + onProgress("Kept $architecturesToKeep, stripped $strippedCount native library files") + + // Replace original with stripped version + apkFile.delete() + tempFile.renameTo(apkFile) + } + + /** + * Returns true if the ZIP entry should be stripped (is a native lib for an architecture not in the keep set). + */ + private fun shouldStripEntry(entryName: String, keepSet: Set): Boolean { + if (!entryName.startsWith("lib/")) return false + + // Entry format: lib//libname.so + val parts = entryName.removePrefix("lib/").split("/", limit = 2) + if (parts.size < 2) return false + + val arch = parts[0] + return arch !in keepSet + } +}