diff --git a/CHANGELOG.md b/CHANGELOG.md index 64563e5..52753a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [1.6.4-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.6.4-dev.1...v1.6.4-dev.2) (2026-03-31) + + +### Bug Fixes + +* Use same default keystore values as Morphe Manager ([#96](https://github.com/MorpheApp/morphe-cli/issues/96)) ([2d70c01](https://github.com/MorpheApp/morphe-cli/commit/2d70c016f293ba382266fb5c800073f763d633d8)) + +## [1.6.4-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.6.3...v1.6.4-dev.1) (2026-03-30) + + +### Bug Fixes + +* Use patcher implementation of strip libs ([#83](https://github.com/MorpheApp/morphe-cli/issues/83)) ([43f50ea](https://github.com/MorpheApp/morphe-cli/commit/43f50ea133088090648fa318047626f3166b8639)), closes [#80](https://github.com/MorpheApp/morphe-cli/issues/80) [#82](https://github.com/MorpheApp/morphe-cli/issues/82) + ## [1.6.3](https://github.com/MorpheApp/morphe-cli/compare/v1.6.2...v1.6.3) (2026-03-28) diff --git a/docs/0_prerequisites.md b/docs/0_prerequisites.md index 8040da2..9ac1c1c 100644 --- a/docs/0_prerequisites.md +++ b/docs/0_prerequisites.md @@ -4,9 +4,8 @@ To use Morphe CLI, you will need to fulfill specific requirements. ## 🤝 Requirements -- Java Runtime Environment 11 ([Azul Zulu JRE](https://www.azul.com/downloads/?version=java-11-lts&package=jre#zulu) or [OpenJDK](https://jdk.java.net/archive/)) +- Java Runtime Environment 17 - [Azul Zulu JRE](https://www.azul.com/downloads/?version=java-17-lts&package=jre#zulu) or [OpenJDK](https://jdk.java.net/archive/) - [Android Debug Bridge (ADB)](https://developer.android.com/studio/command-line/adb) if you want to install the patched APK file on your device -- x86 or x86-64 (For [other architectures](https://github.com/Morphe/Morphe-manager/tree/main/android/app/src/main/jniLibs) use the `--custom-aapt2-binary` option) ## ⏭️ Whats next diff --git a/gradle.properties b/gradle.properties index 86aae0f..91949fa 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.6.3 +version = 1.6.4-dev.2 diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 31efd72..4f74c7c 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -19,18 +19,28 @@ import app.morphe.cli.command.model.mergeWith import app.morphe.cli.command.model.toPatchBundle import app.morphe.cli.command.model.toSerializablePatch import app.morphe.cli.command.model.withUpdatedBundle -import app.morphe.engine.ApkLibraryStripper +import app.morphe.engine.PatchEngine +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD +import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_ALIAS +import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_PASSWORD import app.morphe.engine.UpdateChecker -import app.morphe.patcher.apk.ApkUtils -import app.morphe.patcher.apk.ApkUtils.applyTo -import app.morphe.library.installation.installer.* +import app.morphe.library.installation.installer.AdbInstaller +import app.morphe.library.installation.installer.AdbInstallerResult +import app.morphe.library.installation.installer.AdbRootInstaller +import app.morphe.library.installation.installer.DeviceNotFoundException +import app.morphe.library.installation.installer.Installer +import app.morphe.library.installation.installer.RootInstallerResult import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig import app.morphe.patcher.apk.ApkMerger +import app.morphe.patcher.apk.ApkUtils +import app.morphe.patcher.apk.ApkUtils.applyTo import app.morphe.patcher.logging.toMorpheLogger import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar import app.morphe.patcher.patch.setOptions +import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -196,13 +206,13 @@ internal object PatchCommand : Callable { description = ["Alias of the private key and certificate pair keystore entry."], showDefaultValue = ALWAYS, ) - private var keyStoreEntryAlias = "Morphe Key" + private var keyStoreEntryAlias = PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS @CommandLine.Option( names = ["--keystore-entry-password"], description = ["Password of the keystore entry."], ) - private var keyStoreEntryPassword = "" // Empty password by default + private var keyStoreEntryPassword = PatchEngine.Config.DEFAULT_KEYSTORE_PASSWORD @CommandLine.Option( names = ["--signer"], @@ -286,12 +296,26 @@ internal object PatchCommand : Callable { ) private var unsigned: Boolean = false + private var keepArchitectures: Set = emptySet() @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() + @Suppress("unused") + private fun setStripLibs(architectures: List) { + this.keepArchitectures = architectures.map { arch -> + CpuArchitecture.valueOfOrNull(arch.trim()) + ?: throw CommandLine.ParameterException( + spec.commandLine(), + "Invalid architecture \"$arch\" in --striplibs. Valid values are: ${ + CpuArchitecture.entries.joinToString( + ", " + ) { it.arch } + }", + ) + }.toSet() + } @CommandLine.Option( names = ["--continue-on-error"], @@ -460,6 +484,7 @@ internal object PatchCommand : Callable { aaptBinaryPath?.path, patcherTemporaryFilesPath.absolutePath, if (aaptBinaryPath != null) { false } else { !forceApktool }, + keepArchitectures ), ).use { patcher -> val packageName = patcher.context.packageMetadata.packageName @@ -646,33 +671,39 @@ internal object PatchCommand : Callable { 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( PatchingStep.SIGNING, { - ApkUtils.signApk( - patchedApkFile, - outputFilePath, - signer, - ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - keyStoreEntryAlias, - keyStoreEntryPassword, - ), - ) + fun signApk(alias: String, password: String) { + ApkUtils.signApk( + patchedApkFile, + outputFilePath, + signer, + ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + alias, + password, + ) + ) + } + try { + signApk(keyStoreEntryAlias, keyStoreEntryPassword) + } catch (e: Exception){ + // Retry with legacy keystore defaults. + if (keyStoreEntryAlias == DEFAULT_KEYSTORE_ALIAS && + keyStoreEntryPassword == DEFAULT_KEYSTORE_PASSWORD && + keystoreFilePath.exists() + ) { + logger.info("Using legacy keystore credentials") + + signApk(LEGACY_KEYSTORE_ALIAS, LEGACY_KEYSTORE_PASSWORD) + } else { + throw e + } + } } ) } else { 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 cef96eb..8f521da 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt @@ -3,7 +3,6 @@ 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/engine/ApkLibraryStripper.kt b/src/main/kotlin/app/morphe/engine/ApkLibraryStripper.kt deleted file mode 100644 index 3970b78..0000000 --- a/src/main/kotlin/app/morphe/engine/ApkLibraryStripper.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-cli - */ - -package app.morphe.engine - -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 - } -} diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index 72f6d45..ec6c9fa 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -16,6 +16,7 @@ import app.morphe.patcher.apk.ApkUtils.applyTo import app.morphe.patcher.logging.toMorpheLogger import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.setOptions +import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -26,13 +27,15 @@ import java.io.StringWriter import java.nio.file.Files import java.util.logging.Logger -/** +/* * Single patching pipeline shared by CLI and GUI. (Eventually. Right now we are still having 2 pipelines) */ + + object PatchEngine { enum class PatchStep { - PATCHING, REBUILDING, STRIPPING_LIBS, SIGNING + PATCHING, REBUILDING, SIGNING } data class StepResult(val step: PatchStep, val success: Boolean, val error: String? = null) @@ -49,11 +52,18 @@ object PatchEngine { val unsigned: Boolean = false, val signerName: String = "Morphe", val keystoreDetails: ApkUtils.KeyStoreDetails? = null, - val architecturesToKeep: List = emptyList(), + val architecturesToKeep: Set = emptySet(), val aaptBinaryPath: File? = null, val tempDir: File? = null, val failOnError: Boolean = true, - ) + ) { + companion object { + internal const val DEFAULT_KEYSTORE_ALIAS = "Morphe" + internal const val DEFAULT_KEYSTORE_PASSWORD = "Morphe" + internal const val LEGACY_KEYSTORE_ALIAS = "Morphe Key" + internal const val LEGACY_KEYSTORE_PASSWORD = "" + } + } data class Result( val success: Boolean, @@ -113,6 +123,8 @@ object PatchEngine { patcherTempDir, config.aaptBinaryPath?.path, patcherTempDir.absolutePath, + useArsclib = true, + keepArchitectures = config.architecturesToKeep ) Patcher(patcherConfig).use { patcher -> @@ -207,39 +219,45 @@ object PatchEngine { currentCoroutineContext().ensureActive() - // 7. Strip libs (if configured) - if (config.architecturesToKeep.isNotEmpty()) { - onProgress("Stripping native libraries...") - try { - ApkLibraryStripper.stripLibraries(rebuiltApk, config.architecturesToKeep) { - onProgress(it) - } - stepResults.add(StepResult(PatchStep.STRIPPING_LIBS, true)) - } catch (e: Exception) { - stepResults.add(StepResult(PatchStep.STRIPPING_LIBS, false, e.toString())) - return earlyResult() - } - } - - currentCoroutineContext().ensureActive() - - // 8. Sign APK (unless unsigned) + // 7. Sign APK (unless unsigned) val tempOutput = File(tempDir, config.outputApk.name) if (!config.unsigned) { onProgress("Signing APK...") try { + fun signApk(details: ApkUtils.KeyStoreDetails) { + ApkUtils.signApk( + rebuiltApk, + tempOutput, + config.signerName, + details, + ) + } + val keystoreDetails = config.keystoreDetails ?: ApkUtils.KeyStoreDetails( File(tempDir, "morphe.keystore"), null, - "Morphe Key", - "", - ) - ApkUtils.signApk( - rebuiltApk, - tempOutput, - config.signerName, - keystoreDetails, + Config.DEFAULT_KEYSTORE_ALIAS, + Config.DEFAULT_KEYSTORE_PASSWORD, ) + + try { + signApk(keystoreDetails) + } catch (e: Exception){ + // Retry with legacy keystore defaults. + if (config.keystoreDetails == null && keystoreDetails.keyStore.exists()) { + logger.info("Using legacy keystore credentials") + + val legacyKeystoreDetails = ApkUtils.KeyStoreDetails( + keystoreDetails.keyStore, + null, + Config.LEGACY_KEYSTORE_ALIAS, + Config.LEGACY_KEYSTORE_PASSWORD, + ) + signApk(legacyKeystoreDetails) + } else { + throw e + } + } stepResults.add(StepResult(PatchStep.SIGNING, true)) } catch (e: Exception) { stepResults.add(StepResult(PatchStep.SIGNING, false, e.toString())) @@ -249,7 +267,7 @@ object PatchEngine { rebuiltApk.copyTo(tempOutput, overwrite = true) } - // 9. Copy to final output + // 8. Copy to final output config.outputApk.parentFile?.mkdirs() tempOutput.copyTo(config.outputApk, overwrite = true) diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt index 52622b5..b2eadb3 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -5,6 +5,7 @@ package app.morphe.gui.data.model +import app.morphe.patcher.resource.CpuArchitecture import kotlinx.serialization.Serializable /** @@ -85,6 +86,6 @@ data class PatchConfig( val disabledPatches: List = emptyList(), val patchOptions: Map = emptyMap(), val useExclusiveMode: Boolean = false, - val striplibs: List = emptyList(), + val keepArchitectures: Set = emptySet(), val continueOnError: Boolean = false ) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt index 83697c6..314d384 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.launch import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import app.morphe.gui.data.repository.PatchRepository +import app.morphe.patcher.resource.CpuArchitecture import java.io.File class PatchSelectionViewModel( @@ -199,9 +200,9 @@ class PatchSelectionViewModel( // Only set riplibs if user deselected any architecture (keeps = selected ones) val striplibs = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { - _uiState.value.selectedArchitectures.toList() + _uiState.value.selectedArchitectures.map { CpuArchitecture.valueOf(it) }.toSet() } else { - emptyList() + emptySet() } return PatchConfig( @@ -211,7 +212,7 @@ class PatchSelectionViewModel( enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, useExclusiveMode = true, - striplibs = striplibs, + keepArchitectures = striplibs, continueOnError = continueOnError ) } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreenModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreenModel.kt index 02aab2d..ed85fb8 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreenModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreenModel.kt @@ -59,7 +59,7 @@ class PatchingScreenModel( disabledPatches = config.disabledPatches, options = config.patchOptions, exclusiveMode = config.useExclusiveMode, - striplibs = config.striplibs, + keepArchitectures = config.keepArchitectures, continueOnError = config.continueOnError, onProgress = { message -> parseAndAddLog(message) diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index c60ac87..47d832e 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -11,6 +11,7 @@ import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchOption import app.morphe.gui.data.model.PatchOptionType import app.morphe.patcher.patch.loadPatchesFromJar +import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -82,7 +83,7 @@ class PatchService { disabledPatches: List = emptyList(), options: Map = emptyMap(), exclusiveMode: Boolean = false, - striplibs: List = emptyList(), + keepArchitectures: Set = emptySet(), continueOnError: Boolean = false, onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { @@ -122,7 +123,7 @@ class PatchService { exclusiveMode = exclusiveMode, forceCompatibility = true, patchOptions = patchOptions, - architecturesToKeep = striplibs, + architecturesToKeep = keepArchitectures, failOnError = !continueOnError, )