From ed8918eb8d26634f717e09b58aa174c1d1353a07 Mon Sep 17 00:00:00 2001 From: Eric Ahn Date: Fri, 17 Apr 2026 19:00:00 -0700 Subject: [PATCH 01/14] feat: Faster dex rebuilding + reduced memory requirements (#108) Reduces memory requirements for bytecode patches and speeds up dex rebuilding. Adds flag `--bytecode-mode` which can be set to * `FULL` - the legacy behavior, rebuilds all dex files, requires the most time and memory * `STRIP_FAST` - default, fastest with least memory requirements but produces bigger APK files * `STRIP_SAFE` - faster than `FULL` with somewhat reduced memory requirements, not as performant as `STRIP_FAST` --------- Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- .../app/morphe/cli/command/PatchCommand.kt | 101 ++++++++++++++---- .../kotlin/app/morphe/engine/PatchEngine.kt | 4 +- 3 files changed, 84 insertions(+), 23 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a0ab91..b62ffd4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ kotlin = "2.3.20" # CLI picocli = "4.7.7" arsclib = "9696ffecda" -morphe-patcher = "1.3.3" +morphe-patcher = "1.4.1" morphe-library = "1.3.0" # Compose Desktop diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index f589fd6..c5ecc41 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -8,17 +8,7 @@ package app.morphe.cli.command -import app.morphe.cli.command.model.FailedPatch -import app.morphe.cli.command.model.PatchBundle -import app.morphe.cli.command.model.PatchingResult -import app.morphe.cli.command.model.PatchingStep -import app.morphe.cli.command.model.addStepResult -import app.morphe.cli.command.model.deserializeOptionValue -import app.morphe.cli.command.model.findMatchingBundle -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.cli.command.model.* 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 @@ -26,17 +16,15 @@ import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME 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.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.library.installation.installer.* 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.dex.BytecodeMode +import app.morphe.patcher.dex.NoOpDexVerifier +import app.morphe.patcher.dex.SdkDexVerifier import app.morphe.patcher.logging.toMorpheLogger import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar @@ -271,7 +259,7 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["--custom-aapt2-binary"], - description = ["Path to a custom AAPT binary to compile resources with. Only valid when --use-arsclib is not specified."], + description = ["apktool is deprecated. This parameter has no effect and will be removed in a future release."], ) @Suppress("unused") private fun setAaptBinaryPath(aaptBinaryPath: File) { @@ -286,7 +274,7 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["--force-apktool"], - description = ["Use apktool instead of arsclib to compile resources. Implied if --custom-aapt2-binary is specified."], + description = ["apktool is deprecated. This parameter has no effect and will be removed in a future release."], showDefaultValue = ALWAYS, ) private var forceApktool: Boolean = false @@ -318,6 +306,73 @@ internal object PatchCommand : Callable { }.toSet() } + private var bytecodeMode: BytecodeMode = BytecodeMode.STRIP_FAST + @CommandLine.Option( + names = ["--bytecode-mode"], + description = ["Set bytecode mode. Valid options are FULL, STRIP_SAFE, and STRIP_FAST (the default)."], + showDefaultValue = ALWAYS, + ) + @Suppress("unused") + private fun setBytecodeMode(desiredBytecodeMode: String) { + this.bytecodeMode = try { + BytecodeMode.valueOf(desiredBytecodeMode) + } catch (e: IllegalArgumentException) { + throw CommandLine.ParameterException( + spec.commandLine(), + "Invalid bytecode mode \"$desiredBytecodeMode\" in --bytecode-mode. Valid values are: FULL, STRIP_SAFE, STRIP_FAST", + ) + } + } + + @CommandLine.Option( + names = ["--verify-with-sdk"], + description = ["Verify the patched DEX and APK files using the provided Android SDK. If not specified, the patched files will not be verified."], + fallbackValue = "", + arity = "0..1", + ) + @Suppress("unused") + private fun setSdkToolsPath(sdkToolsPath: File?) { + if (sdkToolsPath != null && sdkToolsPath.path.isNotEmpty()) { + if (!sdkToolsPath.isDirectory) { + throw CommandLine.ParameterException( + spec.commandLine(), + "SDK path passed to --verify-with-sdk must be a directory.", + ) + } + this.sdkToolsPath = sdkToolsPath + return + } + + // Try environment variables first. + val envPath = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") + if (envPath != null) { + val envDir = File(envPath) + if (envDir.isDirectory) { + this.sdkToolsPath = envDir + return + } + } + + // Infer default path based on OS. + val userHome = System.getProperty("user.home") + val osName = System.getProperty("os.name").lowercase() + val defaultPath = when { + osName.contains("win") -> File("$userHome/AppData/Local/Android/Sdk") + osName.contains("mac") -> File("$userHome/Library/Android/sdk") + else -> File("$userHome/Android/Sdk") + } + + if (defaultPath.isDirectory) { + this.sdkToolsPath = defaultPath + } else { + throw CommandLine.ParameterException( + spec.commandLine(), + "Could not find Android SDK. Set ANDROID_HOME or pass a path to --verify-with-sdk.", + ) + } + } + private var sdkToolsPath: File? = null + @CommandLine.Option( names = ["--continue-on-error"], description = ["Continue patching even if a patch fails. By default, patching stops on the first error."], @@ -395,6 +450,7 @@ internal object PatchCommand : Callable { val patchingResult = PatchingResult() var mergedApkToCleanup: File? = null + val verifier = if (sdkToolsPath == null) NoOpDexVerifier else SdkDexVerifier(sdkToolsPath!!) // Lightweight snapshot of patch metadata for use in finally block (auto-update). // Lightweight snapshot of current bundle metadata for use in finally block (auto-update). @@ -484,8 +540,10 @@ internal object PatchCommand : Callable { patcherTemporaryFilesPath, aaptBinaryPath?.path, patcherTemporaryFilesPath.absolutePath, - if (aaptBinaryPath != null) { false } else { !forceApktool }, - keepArchitectures + useArsclib = if (aaptBinaryPath != null) { false } else { !forceApktool }, + keepArchitectures = keepArchitectures, + useBytecodeMode = bytecodeMode, + verifier = verifier ), ).use { patcher -> val packageName = patcher.context.packageMetadata.packageName @@ -710,6 +768,7 @@ internal object PatchCommand : Callable { } else { patchedApkFile.copyTo(outputFilePath, overwrite = true) } + verifier.verifyApkFile(outputFilePath) } logger.info("Saved to $outputFilePath") diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index 31f7aa2..de53b55 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -13,6 +13,7 @@ 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.dex.BytecodeMode import app.morphe.patcher.logging.toMorpheLogger import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.setOptions @@ -125,7 +126,8 @@ object PatchEngine { config.aaptBinaryPath?.path, patcherTempDir.absolutePath, useArsclib = true, - keepArchitectures = config.architecturesToKeep + keepArchitectures = config.architecturesToKeep, + useBytecodeMode = BytecodeMode.STRIP_FAST ) Patcher(patcherConfig).use { patcher -> From c0dfc4b8db7c81099d4abfdd5555a034014ddf78 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 18 Apr 2026 02:02:31 +0000 Subject: [PATCH 02/14] chore: Release v1.8.0-dev.1 [skip ci] # [1.8.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.7.0...v1.8.0-dev.1) (2026-04-18) ### Features * Faster dex rebuilding + reduced memory requirements ([#108](https://github.com/MorpheApp/morphe-cli/issues/108)) ([ed8918e](https://github.com/MorpheApp/morphe-cli/commit/ed8918eb8d26634f717e09b58aa174c1d1353a07)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f831960..fbea80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.8.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.7.0...v1.8.0-dev.1) (2026-04-18) + + +### Features + +* Faster dex rebuilding + reduced memory requirements ([#108](https://github.com/MorpheApp/morphe-cli/issues/108)) ([ed8918e](https://github.com/MorpheApp/morphe-cli/commit/ed8918eb8d26634f717e09b58aa174c1d1353a07)) + # [1.7.0](https://github.com/MorpheApp/morphe-cli/compare/v1.6.3...v1.7.0) (2026-04-16) diff --git a/gradle.properties b/gradle.properties index d91e7c3..3eca981 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.7.0 +version = 1.8.0-dev.1 From a9a2402209bd0ddd273c0aa5185888b74c69d118 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:26:23 +0530 Subject: [PATCH 03/14] fix: Include Notice and License files in shadow jar release (#113) --- build.gradle.kts | 46 ++++++- buildSrc/build.gradle.kts | 17 +++ .../src/main/java/NoticeMergeTransformer.java | 115 ++++++++++++++++++ gradle/libs.versions.toml | 2 - 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/java/NoticeMergeTransformer.java diff --git a/build.gradle.kts b/build.gradle.kts index 923da53..3e9f2dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,9 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose) - alias(libs.plugins.shadow) + // Shadow plugin is provided by buildSrc to enable the custom NoticeMergeTransformer. + // Applied without a version here; the version is pinned in buildSrc/build.gradle.kts. + id("com.gradleup.shadow") application `maven-publish` signing @@ -138,6 +140,12 @@ tasks { filesMatching("**/*.properties") { expand("projectVersion" to project.version) } + // Bundle the project's NOTICE (GPL 7b/7c) and LICENSE into META-INF so they + // land in the main JAR before the Shadow merge. Source of truth stays at the + // repo root — these are copied at build time, not duplicated in source control. + from(arrayOf(rootProject.file("NOTICE"), rootProject.file("LICENSE"))) { + into("META-INF") + } } // ------------------------------------------------------------------------- @@ -149,6 +157,32 @@ tasks { "/prebuilt/windows/aapt.exe", "/prebuilt/*/aapt_*", ) + + // NOTICE/LICENSE handling: + // * Global strategy is EXCLUDE (first-wins) so duplicates at non-transformed + // paths — including native libs like libskiko-*.dylib — are deduplicated. + // INCLUDE globally would double-pack every colliding resource and bloat the + // JAR by tens of MB. + // * For META-INF/NOTICE* paths specifically, strategy is flipped to INCLUDE + // via filesMatching below so all dep NOTICEs reach NoticeMergeTransformer + // (Shadow drops duplicates before transformers run under EXCLUDE — see + // ShadowJar.kt Kdoc). + // * Root /NOTICE and /LICENSE — our project's files, added below via from(). + // With EXCLUDE, the first occurrence wins. Dep JARs with root-level NOTICE/ + // LICENSE lose because our from() block is declared before Shadow processes + // dependency configurations. + // * META-INF/LICENSE — our GPL LICENSE, placed via processResources so it + // lands in the main JAR ahead of dep copies. Dep LICENSE files at unique + // paths (META-INF/androidx/**/LICENSE.txt, etc.) are preserved untouched. + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + filesMatching(listOf( + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/NOTICE.md", + )) { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } + from(rootProject.file("NOTICE"), rootProject.file("LICENSE")) minimize { exclude(dependency("org.bouncycastle:.*")) exclude(dependency("app.morphe:morphe-patcher")) @@ -163,6 +197,16 @@ tasks { } mergeServiceFiles() + + // Concatenate every META-INF/NOTICE (and .txt/.md variants) from all dep JARs + // plus our own into a single merged file. Satisfies Apache 2.0 §4(d) which + // requires preserving attribution NOTICEs of Apache-licensed dependencies. + // + // Shadow's built-in ApacheNoticeResourceTransformer hardcodes ASF-branded + // copyright text that cannot be fully disabled, which would falsely attribute + // this GPL project to the Apache Software Foundation. NoticeMergeTransformer + // (in buildSrc) is a minimal verbatim concatenator with no boilerplate. + transform(NoticeMergeTransformer::class.java) } distTar { diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..1dd0ee3 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + java +} + +repositories { + mavenCentral() + gradlePluginPortal() +} + +dependencies { + // Shadow is declared as implementation so the root project's build script + // gets these classes on its buildscript classpath at runtime. The root + // project must then apply the shadow plugin without a version to avoid + // the "plugin is already on the classpath" conflict. + implementation("com.gradleup.shadow:shadow-gradle-plugin:9.3.2") + implementation("org.jetbrains:annotations:24.1.0") +} diff --git a/buildSrc/src/main/java/NoticeMergeTransformer.java b/buildSrc/src/main/java/NoticeMergeTransformer.java new file mode 100644 index 0000000..a593321 --- /dev/null +++ b/buildSrc/src/main/java/NoticeMergeTransformer.java @@ -0,0 +1,115 @@ +import com.github.jengelman.gradle.plugins.shadow.transformers.CacheableTransformer; +import com.github.jengelman.gradle.plugins.shadow.transformers.ResourceTransformer; +import com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import javax.inject.Inject; +import org.apache.tools.zip.ZipEntry; +import org.apache.tools.zip.ZipOutputStream; +import org.gradle.api.file.FileTreeElement; +import org.gradle.api.model.ObjectFactory; +import org.gradle.api.provider.ListProperty; +import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.jetbrains.annotations.NotNull; + +/** + * Concatenates matched resource files across dependency JARs into a single + * output entry. Unlike Shadow's ApacheNoticeResourceTransformer this injects + * no ASF-branded boilerplate, which is required for a GPL project that must + * preserve third-party NOTICEs verbatim without falsely attributing itself + * to the Apache Software Foundation. + * + * Path matching is case-insensitive so NOTICE / NOTICE.txt / NOTICE.md are + * all merged into the same output file. Shadow processes main source set + * resources before dependency JARs, so when the project's NOTICE is shipped + * via processResources it naturally appears first in the merged output. + * + * Written in Java to avoid Kotlin metadata version conflicts between the + * buildSrc compiler and the Shadow plugin classes. + */ +@CacheableTransformer +public abstract class NoticeMergeTransformer implements ResourceTransformer { + + private static final String DEFAULT_SEPARATOR = + "\n\n----------------------------------------------------------------\n\n"; + private static final List DEFAULT_PATHS = Arrays.asList( + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/NOTICE.md" + ); + + private final ObjectFactory objectFactory; + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private int sectionCount = 0; + + @Inject + public NoticeMergeTransformer(ObjectFactory objectFactory) { + this.objectFactory = objectFactory; + getOutputPath().convention("META-INF/NOTICE"); + getMatchedPaths().convention(DEFAULT_PATHS); + getSeparator().convention(DEFAULT_SEPARATOR); + } + + @Input + public abstract Property getOutputPath(); + + @Input + public abstract ListProperty getMatchedPaths(); + + @Input + public abstract Property getSeparator(); + + @Internal + @NotNull + @Override + public ObjectFactory getObjectFactory() { + return objectFactory; + } + + @Override + public boolean canTransformResource(@NotNull FileTreeElement element) { + String path = element.getRelativePath().getPathString(); + for (String matched : getMatchedPaths().get()) { + if (matched.equalsIgnoreCase(path)) { + return true; + } + } + return false; + } + + @Override + public void transform(@NotNull TransformerContext context) throws IOException { + if (sectionCount > 0) { + buffer.write(getSeparator().get().getBytes(StandardCharsets.UTF_8)); + } + byte[] readBuf = new byte[8192]; + int n; + while ((n = context.getInputStream().read(readBuf)) != -1) { + buffer.write(readBuf, 0, n); + } + sectionCount++; + } + + @Override + public boolean hasTransformedResource() { + return sectionCount > 0; + } + + @Override + public void modifyOutputStream(@NotNull ZipOutputStream os, boolean preserveFileTimestamps) throws IOException { + ZipEntry entry = new ZipEntry(getOutputPath().get()); + if (!preserveFileTimestamps) { + entry.setTime(0); + } + os.putNextEntry(entry); + os.write(buffer.toByteArray()); + os.closeEntry(); + buffer.reset(); + sectionCount = 0; + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b62ffd4..38df6d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,5 @@ [versions] # Core -shadow = "9.3.2" junit = "5.11.0" kotlin = "2.3.20" @@ -84,4 +83,3 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose = { id = "org.jetbrains.compose", version.ref = "compose" } -shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } From ec53c30687acd2145c9f2b093124e7f919b2ac9a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 18 Apr 2026 08:59:14 +0000 Subject: [PATCH 04/14] chore: Release v1.8.0-dev.2 [skip ci] # [1.8.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.1...v1.8.0-dev.2) (2026-04-18) ### Bug Fixes * Include Notice and License files in shadow jar release ([#113](https://github.com/MorpheApp/morphe-cli/issues/113)) ([a9a2402](https://github.com/MorpheApp/morphe-cli/commit/a9a2402209bd0ddd273c0aa5185888b74c69d118)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbea80c..459e423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.8.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.1...v1.8.0-dev.2) (2026-04-18) + + +### Bug Fixes + +* Include Notice and License files in shadow jar release ([#113](https://github.com/MorpheApp/morphe-cli/issues/113)) ([a9a2402](https://github.com/MorpheApp/morphe-cli/commit/a9a2402209bd0ddd273c0aa5185888b74c69d118)) + # [1.8.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.7.0...v1.8.0-dev.1) (2026-04-18) diff --git a/gradle.properties b/gradle.properties index 3eca981..eafbbb1 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.8.0-dev.1 +version = 1.8.0-dev.2 From 77a1ddea595dd83331c9d0ce246620ebd64bcd22 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:20:37 +0200 Subject: [PATCH 05/14] feat: Add in-app dependency license viewer (#114) --- aboutlibraries/libraries/app.morphe.cli.json | 6 ++ build.gradle.kts | 20 +++++ gradle/libs.versions.toml | 10 ++- .../gui/ui/components/SettingsDialog.kt | 84 +++++++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 aboutlibraries/libraries/app.morphe.cli.json diff --git a/aboutlibraries/libraries/app.morphe.cli.json b/aboutlibraries/libraries/app.morphe.cli.json new file mode 100644 index 0000000..d9f6c82 --- /dev/null +++ b/aboutlibraries/libraries/app.morphe.cli.json @@ -0,0 +1,6 @@ +{ + "uniqueId": "app.morphe.cli", + "name": "Morphe CLI", + "developers": [{"name": "Morphe"}], + "licenses": ["GPL-3.0-only"] +} diff --git a/build.gradle.kts b/build.gradle.kts index 3e9f2dd..b159ac1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,13 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import com.mikepenz.aboutlibraries.plugin.DuplicateMode +import com.mikepenz.aboutlibraries.plugin.DuplicateRule plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose) + alias(libs.plugins.about.libraries) // Shadow plugin is provided by buildSrc to enable the custom NoticeMergeTransformer. // Applied without a version here; the version is pinned in buildSrc/build.gradle.kts. id("com.gradleup.shadow") @@ -109,12 +112,25 @@ dependencies { // -- APK Parsing (GUI) ------------------------------------------------- implementation(libs.apk.parser) + implementation(libs.about.libraries.core) + implementation(libs.about.libraries.m3) + // -- Testing ----------------------------------------------------------- testImplementation(libs.kotlin.test) testImplementation(libs.junit.params) testImplementation(libs.mockk) } +aboutLibraries { + collect { + configPath = file("aboutlibraries") + } + library { + duplicationMode = DuplicateMode.MERGE + duplicationRule = DuplicateRule.EXACT + } +} + // ============================================================================ // Tasks // ============================================================================ @@ -136,6 +152,10 @@ tasks { } processResources { + // Make sure the licenses are generated before the resources are processed + dependsOn("exportLibraryDefinitions") + from(layout.buildDirectory.file("generated/aboutLibraries/aboutlibraries.json")) + // Only expand properties files, not binary files like PNG/ICO filesMatching("**/*.properties") { expand("projectVersion" to project.version) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38df6d9..429ec90 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ morphe-patcher = "1.4.1" morphe-library = "1.3.0" # Compose Desktop -compose = "1.10.0" +compose = "1.10.3" # Networking ktor = "3.4.2" @@ -34,6 +34,9 @@ apk-parser = "2.6.10" # Testing mockk = "1.14.9" +# Libraries +about-libraries = "14.0.1" + [libraries] # Morphe Core arsclib = { module = "com.github.MorpheApp:ARSCLib", version.ref = "arsclib" } @@ -78,8 +81,13 @@ apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +# About Libraries +about-libraries-core = { group = "com.mikepenz", name = "aboutlibraries-compose-core", version.ref = "about-libraries" } +about-libraries-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "about-libraries" } + [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose = { id = "org.jetbrains.compose", version.ref = "compose" } +about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries" } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index 9d3c420..47a6801 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -52,6 +52,8 @@ import java.security.MessageDigest import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.UUID +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer @Composable fun SettingsDialog( @@ -84,6 +86,7 @@ fun SettingsDialog( val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) var showClearCacheConfirm by remember { mutableStateOf(false) } + var showLicensesDialog by remember { mutableStateOf(false) } var cacheCleared by remember { mutableStateOf(false) } var cacheClearFailed by remember { mutableStateOf(false) } var showAddSourceDialog by remember { mutableStateOf(false) } @@ -250,6 +253,16 @@ fun SettingsDialog( Spacer(Modifier.height(6.dp)) + ActionButton( + label = "VIEW LICENSES", + icon = Icons.Default.Description, + mono = mono, + borderColor = borderColor, + onClick = { showLicensesDialog = true } + ) + + Spacer(Modifier.height(6.dp)) + ActionButton( label = "OPEN APP DATA", icon = Icons.Default.FolderOpen, @@ -401,6 +414,10 @@ fun SettingsDialog( ) } + if (showLicensesDialog) { + LicensesDialog(onDismiss = { showLicensesDialog = false }) + } + editingSource?.let { source -> EditPatchSourceDialog( source = source, @@ -413,6 +430,73 @@ fun SettingsDialog( } } +@Composable +private fun LicensesDialog(onDismiss: () -> Unit) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + + val libraries = remember { + try { + val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("aboutlibraries.json") + val json = stream?.bufferedReader()?.use { it.readText() } + if (json != null) Libs.Builder().withJson(json).build() else null + } catch (e: Exception) { + Logger.error("Failed to load licenses", e) + null + } + } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.widthIn(max = 600.dp).heightIn(max = 600.dp), + title = { + Text( + "OPEN SOURCE LICENSES", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { + if (libraries != null) { + LibrariesContainer( + libraries = libraries, + modifier = Modifier.fillMaxSize() + ) + } else { + Text( + "Failed to load licenses.", + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) + ) { + Text( + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) +} + // ── Shared building blocks ── @Composable From 18daee3a00ba82d5adc7b1d3c4f5ff816a30495f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 18 Apr 2026 09:23:30 +0000 Subject: [PATCH 06/14] chore: Release v1.8.0-dev.3 [skip ci] # [1.8.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.2...v1.8.0-dev.3) (2026-04-18) ### Features * Add in-app dependency license viewer ([#114](https://github.com/MorpheApp/morphe-cli/issues/114)) ([77a1dde](https://github.com/MorpheApp/morphe-cli/commit/77a1ddea595dd83331c9d0ce246620ebd64bcd22)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 459e423..e254852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.8.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.2...v1.8.0-dev.3) (2026-04-18) + + +### Features + +* Add in-app dependency license viewer ([#114](https://github.com/MorpheApp/morphe-cli/issues/114)) ([77a1dde](https://github.com/MorpheApp/morphe-cli/commit/77a1ddea595dd83331c9d0ce246620ebd64bcd22)) + # [1.8.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.1...v1.8.0-dev.2) (2026-04-18) diff --git a/gradle.properties b/gradle.properties index eafbbb1..c788947 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.8.0-dev.2 +version = 1.8.0-dev.3 From efd0cf16dbac6625a54e2f82881bbe16c9e8acce Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:38:19 +0200 Subject: [PATCH 07/14] fix: Update to latest patcher --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 429ec90..cdc694c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlin = "2.3.20" # CLI picocli = "4.7.7" arsclib = "9696ffecda" -morphe-patcher = "1.4.1" +morphe-patcher = "1.4.2-dev.1" # TODO: Change to stable patcher morphe-library = "1.3.0" # Compose Desktop From 98af3993821bf4293ff1c6876e0b14d7084c9222 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 19 Apr 2026 19:49:19 +0000 Subject: [PATCH 08/14] chore: Release v1.8.0-dev.4 [skip ci] # [1.8.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.3...v1.8.0-dev.4) (2026-04-19) ### Bug Fixes * Update to latest patcher ([efd0cf1](https://github.com/MorpheApp/morphe-cli/commit/efd0cf16dbac6625a54e2f82881bbe16c9e8acce)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e254852..69e7ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.8.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.3...v1.8.0-dev.4) (2026-04-19) + + +### Bug Fixes + +* Update to latest patcher ([efd0cf1](https://github.com/MorpheApp/morphe-cli/commit/efd0cf16dbac6625a54e2f82881bbe16c9e8acce)) + # [1.8.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.2...v1.8.0-dev.3) (2026-04-18) diff --git a/gradle.properties b/gradle.properties index c788947..dacebd2 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.8.0-dev.3 +version = 1.8.0-dev.4 From 6d2bb94425e3fa040cc0d00261b77559d69e4470 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:41:06 +0530 Subject: [PATCH 09/14] fix: Re-enable strip libs feature (#117) --- gradle/libs.versions.toml | 2 +- .../app/morphe/gui/data/model/AppConfig.kt | 12 +- .../gui/data/repository/ConfigRepository.kt | 18 ++ .../kotlin/app/morphe/gui/di/AppModule.kt | 29 +- .../gui/ui/components/SettingsButton.kt | 22 +- .../gui/ui/components/SettingsDialog.kt | 134 +++++++-- .../ui/screens/home/components/ApkDropZone.kt | 217 -------------- .../screens/patches/PatchSelectionScreen.kt | 275 ++++++++++++------ .../patches/PatchSelectionViewModel.kt | 128 ++++++-- .../kotlin/app/morphe/gui/util/FileUtils.kt | 2 +- 10 files changed, 466 insertions(+), 373 deletions(-) delete mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkDropZone.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cdc694c..853d654 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlin = "2.3.20" # CLI picocli = "4.7.7" arsclib = "9696ffecda" -morphe-patcher = "1.4.2-dev.1" # TODO: Change to stable patcher +morphe-patcher = "1.4.2" morphe-library = "1.3.0" # Compose Desktop diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index 6bfae3c..73716f8 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -7,8 +7,9 @@ package app.morphe.gui.data.model import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD -import kotlinx.serialization.Serializable import app.morphe.gui.ui.theme.ThemePreference +import app.morphe.gui.util.FileUtils.ANDROID_ARCHITECTURES +import kotlinx.serialization.Serializable /** * Application configuration stored in config.json @@ -36,7 +37,14 @@ data class AppConfig( val keystorePath: String? = null, val keystorePassword: String? = null, val keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, - val keystoreEntryPassword: String = DEFAULT_KEYSTORE_PASSWORD + val keystoreEntryPassword: String = DEFAULT_KEYSTORE_PASSWORD, + // User's global keep-list for strip libs. Defaults to all common modern arches + // (equivalent to no stripping). Stripping is only applied when the APK contains + // an arch NOT in this set. See PatchSelectionViewModel.computeStripLibsStatus. + val keepArchitectures: Set = ANDROID_ARCHITECTURES, + // Persisted expand/collapse state for each section in the Settings dialog. + // Keyed by section title (e.g. "STRIP LIBS"). Missing key = section starts collapsed. + val collapsibleSectionStates: Map = emptyMap() ) { fun getThemePreference(): ThemePreference { return try { diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index 38ee7ba..3c7ce65 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -127,6 +127,24 @@ class ConfigRepository { saveConfig(current.copy(useSimplifiedMode = enabled)) } + /** + * Update the user's global keep-architectures list for strip libs. + */ + suspend fun setKeepArchitectures(keep: Set) { + val current = loadConfig() + saveConfig(current.copy(keepArchitectures = keep)) + } + + /** + * Persist the expand/collapse state of a Settings dialog section so it restores + * on next open instead of resetting to collapsed. + */ + suspend fun setCollapsibleSectionExpanded(id: String, expanded: Boolean) { + val current = loadConfig() + val updated = current.collapsibleSectionStates + (id to expanded) + saveConfig(current.copy(collapsibleSectionStates = updated)) + } + /** * Update keystore path only (used for auto-remember on first creation). */ diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index 3d3aa5d..3bcbaff 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -67,11 +67,34 @@ val appModule = module { } factory { params -> val psm = get() - PatchesViewModel(params.get(), params.get(), psm.getActiveRepositorySync(), get(), psm.getLocalFilePath(), psm) + PatchesViewModel( + params.get(), + params.get(), + psm.getActiveRepositorySync(), + get(), + psm.getLocalFilePath(), + psm + ) } factory { params -> val psm = get() - PatchSelectionViewModel(params.get(), params.get(), params.get(), params.get(), params.get(), get(), psm.getActiveRepositorySync(), psm.getLocalFilePath()) + PatchSelectionViewModel( + params.get(), + params.get(), + params.get(), + params.get(), + params.get(), + get(), + psm.getActiveRepositorySync(), + get(), + psm.getLocalFilePath() + ) + } + factory { params -> + PatchingViewModel( + params.get(), + get(), + get() + ) } - factory { params -> PatchingViewModel(params.get(), get(), get()) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index f649cca..fba99b0 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -46,7 +46,8 @@ import app.morphe.gui.ui.theme.LocalThemeState fun SettingsButton( modifier: Modifier = Modifier, allowCacheClear: Boolean = true, - isPatching: Boolean = false + isPatching: Boolean = false, + onDismiss: () -> Unit = {} ) { val corners = LocalMorpheCorners.current val themeState = LocalThemeState.current @@ -63,6 +64,8 @@ fun SettingsButton( var keystorePassword by remember { mutableStateOf(null) } var keystoreAlias by remember { mutableStateOf(DEFAULT_KEYSTORE_ALIAS) } var keystoreEntryPassword by remember { mutableStateOf(DEFAULT_KEYSTORE_PASSWORD) } + var keepArchitectures by remember { mutableStateOf>(emptySet()) } + var collapsibleSectionStates by remember { mutableStateOf>(emptyMap()) } LaunchedEffect(showSettingsDialog) { if (showSettingsDialog) { @@ -74,6 +77,8 @@ fun SettingsButton( keystorePassword = config.keystorePassword keystoreAlias = config.keystoreAlias keystoreEntryPassword = config.keystoreEntryPassword + keepArchitectures = config.keepArchitectures + collapsibleSectionStates = config.collapsibleSectionStates } } @@ -118,7 +123,10 @@ fun SettingsButton( onExpertModeChange = { enabled -> modeState.onChange(!enabled) }, - onDismiss = { showSettingsDialog = false }, + onDismiss = { + showSettingsDialog = false + onDismiss() + }, allowCacheClear = allowCacheClear, isPatching = isPatching, patchSources = patchSources, @@ -181,6 +189,16 @@ fun SettingsButton( entryPassword = entryPwd ) } + }, + keepArchitectures = keepArchitectures, + onKeepArchitecturesChange = { updated -> + keepArchitectures = updated + scope.launch { configRepository.setKeepArchitectures(updated) } + }, + collapsibleSectionStates = collapsibleSectionStates, + onCollapsibleSectionToggle = { id, expanded -> + collapsibleSectionStates = collapsibleSectionStates + (id to expanded) + scope.launch { configRepository.setCollapsibleSectionExpanded(id, expanded) } } ) } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index 47a6801..9653dce 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -78,7 +78,11 @@ fun SettingsDialog( keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, keystoreEntryPassword: String = DEFAULT_KEYSTORE_PASSWORD, onKeystorePathChange: (String?) -> Unit = {}, - onKeystoreCredentialsChange: (password: String?, alias: String, entryPassword: String) -> Unit = { _, _, _ -> } + onKeystoreCredentialsChange: (password: String?, alias: String, entryPassword: String) -> Unit = { _, _, _ -> }, + keepArchitectures: Set = emptySet(), + onKeepArchitecturesChange: (Set) -> Unit = {}, + collapsibleSectionStates: Map = emptyMap(), + onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> } ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -206,7 +210,22 @@ fun SettingsDialog( mono = mono, accentColor = accents.primary, borderColor = borderColor, - enabled = !isPatching + enabled = !isPatching, + expanded = collapsibleSectionStates["SIGNING"] == true, + onExpandedChange = { onCollapsibleSectionToggle("SIGNING", it) } + ) + + SettingsDivider(borderColor) + + // ── Strip Libs ── + StripLibsSection( + keepArchitectures = keepArchitectures, + onChange = onKeepArchitecturesChange, + mono = mono, + accentColor = accents.primary, + enabled = !isPatching, + expanded = collapsibleSectionStates["STRIP LIBS"] == true, + onExpandedChange = { onCollapsibleSectionToggle("STRIP LIBS", it) } ) SettingsDivider(borderColor) @@ -225,7 +244,9 @@ fun SettingsDialog( mono = mono, accentColor = accents.primary, borderColor = borderColor, - enabled = !isPatching + enabled = !isPatching, + expanded = collapsibleSectionStates["PATCH SOURCES"] == true, + onExpandedChange = { onCollapsibleSectionToggle("PATCH SOURCES", it) } ) SettingsDivider(borderColor) @@ -253,16 +274,6 @@ fun SettingsDialog( Spacer(Modifier.height(6.dp)) - ActionButton( - label = "VIEW LICENSES", - icon = Icons.Default.Description, - mono = mono, - borderColor = borderColor, - onClick = { showLicensesDialog = true } - ) - - Spacer(Modifier.height(6.dp)) - ActionButton( label = "OPEN APP DATA", icon = Icons.Default.FolderOpen, @@ -282,6 +293,16 @@ fun SettingsDialog( Spacer(Modifier.height(6.dp)) + ActionButton( + label = "VIEW LICENSES", + icon = Icons.Default.Description, + mono = mono, + borderColor = borderColor, + onClick = { showLicensesDialog = true } + ) + + Spacer(Modifier.height(6.dp)) + // Clear cache val cacheColor = when { cacheCleared -> MorpheColors.Teal @@ -518,11 +539,11 @@ private fun SectionLabel( private fun CollapsibleSection( title: String, mono: androidx.compose.ui.text.font.FontFamily, - initiallyExpanded: Boolean = false, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, content: @Composable () -> Unit ) { val corners = LocalMorpheCorners.current - var expanded by remember { mutableStateOf(initiallyExpanded) } val rotationAngle by androidx.compose.animation.core.animateFloatAsState( targetValue = if (expanded) -90f else 0f, animationSpec = androidx.compose.animation.core.tween(200) @@ -539,7 +560,7 @@ private fun CollapsibleSection( if (isHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f) else Color.Transparent ) - .clickable { expanded = !expanded } + .clickable { onExpandedChange(!expanded) } .padding(horizontal = 8.dp, vertical = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically @@ -691,12 +712,19 @@ private fun PatchSourcesSection( mono: androidx.compose.ui.text.font.FontFamily, accentColor: Color, borderColor: Color, - enabled: Boolean = true + enabled: Boolean = true, + expanded: Boolean = false, + onExpandedChange: (Boolean) -> Unit = {} ) { val corners = LocalMorpheCorners.current val alpha = if (enabled) 1f else 0.4f Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - CollapsibleSection("PATCH SOURCES", mono) { + CollapsibleSection( + title = "PATCH SOURCES", + mono = mono, + expanded = expanded, + onExpandedChange = onExpandedChange + ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = if (!enabled) "Disabled while patching" else "Select where patches are loaded from", @@ -1211,6 +1239,65 @@ private fun EditPatchSourceDialog( ) } +// ── Strip Libs Section ── + +/** + * Architectures exposed in the strip libs settings. Each entry has the + * patcher-facing value (matching CpuArchitecture.arch) and a short display name. + * Only modern arches are listed — legacy mips/armeabi are intentionally omitted. + */ +private val STRIP_LIBS_ARCHS = listOf( + "arm64-v8a" to "ARM 64-bit (most modern phones)", + "armeabi-v7a" to "ARM 32-bit (older phones)", + "x86_64" to "Intel 64-bit (emulators / Chromebooks)", + "x86" to "Intel 32-bit (legacy emulators)" +) + +@Composable +private fun StripLibsSection( + keepArchitectures: Set, + onChange: (Set) -> Unit, + mono: androidx.compose.ui.text.font.FontFamily, + accentColor: Color, + enabled: Boolean = true, + expanded: Boolean = false, + onExpandedChange: (Boolean) -> Unit = {} +) { + CollapsibleSection( + title = "STRIP LIBS", + mono = mono, + expanded = expanded, + onExpandedChange = onExpandedChange + ) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "Uncheck architectures you don't need. When patching, the output APK will keep only the architectures present in the APK AND in this list. If none overlap, nothing is stripped to avoid broken APKs.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + STRIP_LIBS_ARCHS.forEach { (arch, description) -> + val checked = arch in keepArchitectures + SettingToggleRow( + label = arch, + description = description, + checked = checked, + onCheckedChange = { keepIt -> + val updated = if (keepIt) keepArchitectures + arch + else keepArchitectures - arch + onChange(updated) + }, + accentColor = accentColor, + mono = mono, + enabled = enabled + ) + } + } + } +} + // ── Signing / Keystore Section ── @Composable @@ -1224,7 +1311,9 @@ private fun SigningSection( mono: androidx.compose.ui.text.font.FontFamily, accentColor: Color, borderColor: Color, - enabled: Boolean = true + enabled: Boolean = true, + expanded: Boolean = false, + onExpandedChange: (Boolean) -> Unit = {} ) { val corners = LocalMorpheCorners.current val alpha = if (enabled) 1f else 0.4f @@ -1241,7 +1330,12 @@ private fun SigningSection( val keystoreExists = keystoreFile?.exists() == true Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - CollapsibleSection("SIGNING", mono) { + CollapsibleSection( + title = "SIGNING", + mono = mono, + expanded = expanded, + onExpandedChange = onExpandedChange + ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = if (!enabled) "Disabled while patching" diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkDropZone.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkDropZone.kt deleted file mode 100644 index bd4c928..0000000 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkDropZone.kt +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-cli - */ - -package app.morphe.gui.ui.screens.home.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.draganddrop.dragAndDropTarget -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.DragAndDropEvent -import androidx.compose.ui.draganddrop.DragAndDropTarget -import androidx.compose.ui.draganddrop.awtTransferable -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import app.morphe.gui.ui.screens.home.ApkInfo -import java.awt.datatransfer.DataFlavor -import java.io.File - -@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) -@Composable -fun ApkDropZone( - apkInfo: ApkInfo?, - isDragHovering: Boolean, - onDragHoverChange: (Boolean) -> Unit, - onFilesDropped: (List) -> Unit, - onBrowseClick: () -> Unit, - onClearClick: () -> Unit, - modifier: Modifier = Modifier -) { - val borderColor = when { - apkInfo != null -> MaterialTheme.colorScheme.primary - isDragHovering -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.outline - } - - val backgroundColor = when { - apkInfo != null -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - isDragHovering -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) - else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - } - - val dragAndDropTarget = remember { - object : DragAndDropTarget { - override fun onStarted(event: DragAndDropEvent) { - onDragHoverChange(true) - } - - override fun onEnded(event: DragAndDropEvent) { - onDragHoverChange(false) - } - - override fun onExited(event: DragAndDropEvent) { - onDragHoverChange(false) - } - - override fun onEntered(event: DragAndDropEvent) { - onDragHoverChange(true) - } - - override fun onDrop(event: DragAndDropEvent): Boolean { - onDragHoverChange(false) - val transferable = event.awtTransferable - return try { - if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - @Suppress("UNCHECKED_CAST") - val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List - if (files.isNotEmpty()) { - onFilesDropped(files) - true - } else { - false - } - } else { - false - } - } catch (e: Exception) { - false - } - } - } - } - - Box( - modifier = modifier - .fillMaxWidth() - .height(200.dp) - .clip(RoundedCornerShape(16.dp)) - .border( - width = 2.dp, - color = borderColor, - shape = RoundedCornerShape(16.dp) - ) - .background(backgroundColor) - .dragAndDropTarget( - shouldStartDragAndDrop = { true }, - target = dragAndDropTarget - ), - contentAlignment = Alignment.Center - ) { - if (apkInfo != null) { - ApkSelectedContent( - apkInfo = apkInfo, - onClearClick = onClearClick - ) - } else { - DropZoneEmptyContent( - isDragHovering = isDragHovering, - onBrowseClick = onBrowseClick - ) - } - } -} - -@Composable -private fun DropZoneEmptyContent( - isDragHovering: Boolean, - onBrowseClick: () -> Unit -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(24.dp) - ) { - Text( - text = if (isDragHovering) "Drop here" else "Drag & drop APK file here", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - - Text( - text = "or", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Button( - onClick = onBrowseClick, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { - Text("Browse Files") - } - } -} - -@Composable -private fun ApkSelectedContent( - apkInfo: ApkInfo, - onClearClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp) - ) - - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = apkInfo.fileName, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = apkInfo.formattedSize, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = apkInfo.filePath, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - IconButton(onClick = onClearClick) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear selection", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt index fad0961..813d9ad 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -341,12 +341,15 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { DeviceIndicator() Spacer(modifier = Modifier.width(6.dp)) - SettingsButton(allowCacheClear = false) + SettingsButton( + allowCacheClear = false, + onDismiss = { viewModel.refreshStripLibsStatus() } + ) } // Command preview — collapsible if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { - val commandPreview = remember(uiState.selectedPatches, uiState.selectedArchitectures, cleanMode, continueOnError, keystorePath) { + val commandPreview = remember(uiState.selectedPatches, uiState.stripLibsStatus, cleanMode, continueOnError, keystorePath) { viewModel.getCommandPreview(cleanMode, continueOnError, keystorePath, keystorePassword, keystoreAlias, keystoreEntryPassword) } AnimatedVisibility( @@ -447,21 +450,15 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - // TODO: Enable the strip libs feature here after patcher's X issue is fixed. - // Architecture selector. Disabled for split APK bundles for now. Maybe we enable in the future? -// val isBundleFormat = viewModel.getApkPath().lowercase().let { it.endsWith(".apkm") || it.endsWith(".xapk") || it.endsWith(".apks") } -// val showArchSelector = !isBundleFormat && -// uiState.apkArchitectures.size > 1 && -// !(uiState.apkArchitectures.size == 1 && uiState.apkArchitectures[0] == "universal") -// if (showArchSelector) { -// item(key = "arch_selector") { -// ArchitectureSelectorCard( -// architectures = uiState.apkArchitectures, -// selectedArchitectures = uiState.selectedArchitectures, -// onToggleArchitecture = { viewModel.toggleArchitecture(it) } -// ) -// } -// } + // Strip-libs status banner. Purely informational — the user + // configures their keep-list in Settings, and this banner + // reports what will happen to native libs for the current APK. + val showBanner = uiState.stripLibsStatus !is StripLibsStatus.NoNativeLibs + if (showBanner) { + item(key = "strip_libs_banner") { + StripLibsStatusBanner(status = uiState.stripLibsStatus) + } + } items( items = uiState.filteredPatches, @@ -1374,28 +1371,66 @@ private fun CommandPreview( // ── Architecture Selector ── @Composable -private fun ArchitectureSelectorCard( - architectures: List, - selectedArchitectures: Set, - onToggleArchitecture: (String) -> Unit, +private fun StripLibsStatusBanner( + status: StripLibsStatus, modifier: Modifier = Modifier ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current - val deviceState by DeviceMonitor.state.collectAsState() - val deviceArch = deviceState.selectedDevice?.architecture + + // Each status variant maps to a BannerDisplay that tells the banner what color, + // headline, description, and arch chips to render. + // accents.secondary is the app's "informational" accent; MaterialTheme tertiary is + // used for warning/fallback states. + val display: BannerDisplay = when (status) { + is StripLibsStatus.NoNativeLibs -> BannerDisplay( + dotColor = accents.secondary.copy(alpha = 0.4f), + headline = "NO NATIVE LIBRARIES", + detail = "This APK has no native libraries. Stripping does not apply." + ) + is StripLibsStatus.Universal -> BannerDisplay( + dotColor = accents.secondary.copy(alpha = 0.4f), + headline = "UNIVERSAL NATIVE LIBS", + detail = "This APK ships a single universal native lib folder. Stripping does not apply." + ) + is StripLibsStatus.KeepAll -> BannerDisplay( + dotColor = accents.secondary.copy(alpha = 0.4f), + headline = "NO STRIPPING NEEDED", + detail = if (status.notInApk.isEmpty()) { + "Your keep-list covers every architecture in this APK. Nothing will be stripped." + } else { + "Every architecture in this APK is in your keep list — nothing will be stripped. Some of your other preferences don't apply because this APK doesn't ship them." + }, + notInApkChips = status.notInApk + ) + is StripLibsStatus.Fallback -> BannerDisplay( + dotColor = MaterialTheme.colorScheme.tertiary, + headline = "FALLBACK: KEEPING ALL", + detail = "None of your preferred architectures are present in this APK. Keeping everything to avoid a broken output. Review your Strip Libs settings.", + keepChips = status.apkArches + ) + is StripLibsStatus.WillStrip -> BannerDisplay( + dotColor = accents.secondary, + headline = "STRIPPING NATIVE LIBS", + detail = if (status.notInApk.isEmpty()) { + "The output APK will keep only the architectures from your preferences that this APK actually ships." + } else { + "The output APK will keep only the architectures from your preferences that this APK actually ships. Some of your other preferences don't apply because this APK doesn't ship them." + }, + keepChips = status.keeping, + stripChips = status.stripping, + notInApkChips = status.notInApk + ) + } + val (dotColor, headline, detail, keepChips, stripChips, notInApkChips) = display Column( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(corners.small)) - .border( - 1.dp, - accents.secondary.copy(alpha = 0.15f), - RoundedCornerShape(corners.small) - ) - .background(accents.secondary.copy(alpha = 0.03f)) + .border(1.dp, dotColor.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) + .background(dotColor.copy(alpha = 0.04f)) .padding(12.dp) ) { Row( @@ -1405,10 +1440,10 @@ private fun ArchitectureSelectorCard( Box( modifier = Modifier .size(6.dp) - .background(accents.secondary, RoundedCornerShape(1.dp)) + .background(dotColor, RoundedCornerShape(1.dp)) ) Text( - text = "STRIP NATIVE LIBRARIES", + text = headline, fontSize = 10.sp, fontWeight = FontWeight.Bold, fontFamily = mono, @@ -1420,82 +1455,128 @@ private fun ArchitectureSelectorCard( Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Uncheck architectures to remove from the output APK and reduce file size.", + text = detail, fontSize = 10.sp, fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) ) - if (deviceArch != null) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "Your device's CPU architecture: $deviceArch", - fontSize = 10.sp, - fontWeight = FontWeight.Medium, - fontFamily = mono, - color = accents.secondary.copy(alpha = 0.8f) - ) + if (keepChips.isNotEmpty() || stripChips.isNotEmpty() || notInApkChips.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + keepChips.forEach { arch -> + ArchChip(label = arch, accent = dotColor, role = ArchChipRole.KEEP) + } + stripChips.forEach { arch -> + ArchChip(label = arch, accent = dotColor, role = ArchChipRole.STRIP) + } + notInApkChips.forEach { arch -> + ArchChip(label = arch, accent = dotColor, role = ArchChipRole.NOT_IN_APK) + } + } } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Change in Settings → Strip Libs", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 0.5.sp + ) + } +} + +private enum class ArchChipRole { KEEP, STRIP, NOT_IN_APK } +@Composable +private fun ArchChip( + label: String, + accent: Color, + role: ArchChipRole +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + + // Chip visual treatment per role: + // - KEEP : filled accent background, strong border, full-opacity text + // - STRIP : outlined only, dim border, dimmed text + // - NOT_IN_APK : outlined only, very dim border, dimmed italicized text — + // signals "this preference has no effect on this APK" + val borderAlpha = when (role) { + ArchChipRole.KEEP -> 0.4f + ArchChipRole.STRIP -> 0.15f + ArchChipRole.NOT_IN_APK -> 0.12f + } + val textAlpha = when (role) { + ArchChipRole.KEEP -> 1f + ArchChipRole.STRIP -> 0.45f + ArchChipRole.NOT_IN_APK -> 0.5f + } + val roleLabel = when (role) { + ArchChipRole.KEEP -> "keep" + ArchChipRole.STRIP -> "strip" + ArchChipRole.NOT_IN_APK -> "not in apk" + } + val labelColor = when (role) { + ArchChipRole.KEEP -> accent.copy(alpha = textAlpha) + ArchChipRole.STRIP -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = textAlpha) + ArchChipRole.NOT_IN_APK -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = textAlpha) + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accent.copy(alpha = borderAlpha), RoundedCornerShape(corners.small)) + .then( + if (role == ArchChipRole.KEEP) { + Modifier.background(accent.copy(alpha = 0.08f), RoundedCornerShape(corners.small)) + } else Modifier + ) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - architectures.forEach { arch -> - val isSelected = selectedArchitectures.contains(arch) - val archHover = remember { MutableInteractionSource() } - val isArchHovered by archHover.collectIsHoveredAsState() - val archBorder by animateColorAsState( - when { - isSelected -> accents.secondary.copy(alpha = 0.4f) - isArchHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f) - else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) - }, - animationSpec = tween(150) - ) - - Box( - modifier = Modifier - .hoverable(archHover) - .clip(RoundedCornerShape(corners.small)) - .border(1.dp, archBorder, RoundedCornerShape(corners.small)) - .then( - if (isSelected) Modifier.background( - accents.secondary.copy(alpha = 0.08f), - RoundedCornerShape(corners.small) - ) else Modifier - ) - .clickable { onToggleArchitecture(arch) } - .padding(horizontal = 10.dp, vertical = 6.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Box( - modifier = Modifier - .size(6.dp) - .background( - if (isSelected) accents.secondary - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), - RoundedCornerShape(1.dp) - ) - ) - Text( - text = arch, - fontSize = 11.sp, - fontFamily = mono, - fontWeight = FontWeight.Medium, - color = if (isSelected) accents.secondary - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } - } + Text( + text = roleLabel, + fontSize = 8.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + color = accent.copy(alpha = textAlpha * 0.7f), + fontStyle = if (role == ArchChipRole.NOT_IN_APK) androidx.compose.ui.text.font.FontStyle.Italic + else androidx.compose.ui.text.font.FontStyle.Normal + ) + Text( + text = label, + fontSize = 11.sp, + fontFamily = mono, + fontWeight = FontWeight.Medium, + color = labelColor, + fontStyle = if (role == ArchChipRole.NOT_IN_APK) androidx.compose.ui.text.font.FontStyle.Italic + else androidx.compose.ui.text.font.FontStyle.Normal + ) } } } + +/** + * Per-status display data for the strip-libs banner. Lets the `when(status)` branch + * stay terse (each variant just fills in what's relevant) and the rendering code + * below stay uniform. + */ +private data class BannerDisplay( + val dotColor: Color, + val headline: String, + val detail: String, + val keepChips: List = emptyList(), + val stripChips: List = emptyList(), + val notInApkChips: List = emptyList() +) 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 e75c391..17cc540 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 @@ -11,6 +11,7 @@ import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchConfig +import app.morphe.gui.data.repository.ConfigRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,6 +19,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.gui.util.FileUtils.ANDROID_ARCHITECTURES import app.morphe.patcher.resource.CpuArchitecture import java.io.File @@ -29,6 +31,7 @@ class PatchSelectionViewModel( private val apkArchitectures: List, private val patchService: PatchService, private val patchRepository: PatchRepository, + private val configRepository: ConfigRepository, private val localPatchFilePath: String? = null ) : ScreenModel { @@ -37,12 +40,22 @@ class PatchSelectionViewModel( private val _uiState = MutableStateFlow(PatchSelectionUiState( apkArchitectures = apkArchitectures, - selectedArchitectures = apkArchitectures.toSet() + stripLibsStatus = computeStripLibsStatus(apkArchitectures, ANDROID_ARCHITECTURES) )) val uiState: StateFlow = _uiState.asStateFlow() init { loadPatches() + loadStripLibsPreference() + } + + private fun loadStripLibsPreference() { + screenModelScope.launch { + val config = configRepository.loadConfig() + _uiState.value = _uiState.value.copy( + stripLibsStatus = computeStripLibsStatus(apkArchitectures, config.keepArchitectures) + ) + } } fun getApkPath(): String = apkPath @@ -159,16 +172,12 @@ class PatchSelectionViewModel( _uiState.value = _uiState.value.copy(error = null) } - fun toggleArchitecture(arch: String) { - val current = _uiState.value.selectedArchitectures - // Don't allow deselecting all architectures - if (current.contains(arch) && current.size <= 1) return - val newSelection = if (current.contains(arch)) { - current - arch - } else { - current + arch - } - _uiState.value = _uiState.value.copy(selectedArchitectures = newSelection) + /** + * Recompute strip-libs status from the latest settings. Called when the user + * closes the Settings dialog so the banner stays in sync with preference edits. + */ + fun refreshStripLibsStatus() { + loadStripLibsPreference() } /** @@ -223,17 +232,14 @@ class PatchSelectionViewModel( .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } .map { it.name } - // Only set striplibs if user deselected any architecture (keeps = selected ones). - // Note: selectedArchitectures stores display strings like "arm64-v8a" (with - // hyphens), so use valueOfOrNull which matches against the enum's `.arch` - // property — plain valueOf() only accepts the underscored Kotlin constant name. - val striplibs = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { - _uiState.value.selectedArchitectures - .mapNotNull { CpuArchitecture.valueOfOrNull(it) } - .toSet() - } else { - emptySet() - } + // Only ship a non-empty keepArchitectures set when the current status actually + // prescribes stripping. All other states (no native libs, universal, keep-all, + // fallback) → empty set → patcher leaves native libs untouched. + val keepArches = (uiState.value.stripLibsStatus as? StripLibsStatus.WillStrip) + ?.keeping + ?.mapNotNull { CpuArchitecture.valueOfOrNull(it) } + ?.toSet() + ?: emptySet() return PatchConfig( inputApkPath = apkPath, @@ -243,7 +249,7 @@ class PatchSelectionViewModel( disabledPatches = disabledPatchNames, patchOptions = _uiState.value.patchOptionValues, useExclusiveMode = true, - keepArchitectures = striplibs, + keepArchitectures = keepArches, continueOnError = continueOnError ) } @@ -297,12 +303,10 @@ class PatchSelectionViewModel( // Use whichever produces fewer flags val useExclusive = selectedPatchNames.size <= disabledPatchNames.size - // striplibs flag: only when user deselected at least one architecture - val striplibsArg = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { - _uiState.value.selectedArchitectures.joinToString(",") - } else { - null - } + // striplibs flag: only when the computed status prescribes actual stripping + val striplibsArg = (uiState.value.stripLibsStatus as? StripLibsStatus.WillStrip) + ?.keeping + ?.joinToString(",") // Keystore flags (only if custom keystore is set) val hasCustomKeystore = keystorePath != null @@ -435,9 +439,73 @@ data class PatchSelectionUiState( val showOnlySelected: Boolean = false, val error: String? = null, val apkArchitectures: List = emptyList(), - val selectedArchitectures: Set = emptySet(), + val stripLibsStatus: StripLibsStatus = StripLibsStatus.NoNativeLibs, val patchOptionValues: Map = emptyMap() ) { val selectedCount: Int get() = selectedPatches.size val totalCount: Int get() = allPatches.size } + +/** + * What the strip-libs feature will do for the currently loaded APK given the + * user's global keep-list preference. Computed by `computeStripLibsStatus`. + */ +sealed class StripLibsStatus { + /** APK ships no native libraries — stripping is meaningless. */ + data object NoNativeLibs : StripLibsStatus() + + /** APK ships a single `universal` native lib folder — stripping does not apply. */ + data object Universal : StripLibsStatus() + + /** + * User's keep-list covers every arch in the APK — nothing to strip. `notInApk` + * holds any extra arches in the user's keep list that don't appear in the APK, + * so the banner can surface "your preference for X has no effect here". + */ + data class KeepAll(val notInApk: List) : StripLibsStatus() + + /** User's keep-list doesn't overlap with the APK's arches — skip stripping as a safety fallback. */ + data class Fallback(val apkArches: List) : StripLibsStatus() + + /** + * Partial overlap — patcher will keep `keeping` and strip `stripping`. `notInApk` + * lists arches the user selected that this APK doesn't ship, so the banner can + * tell the user which of their preferences actually affect this APK. + */ + data class WillStrip( + val keeping: List, + val stripping: List, + val notInApk: List + ) : StripLibsStatus() +} + +/** + * Decide what strip-libs should do given the APK's native arches and the user's + * global keep-list preference. Pure function — no I/O, no side effects — so the + * same inputs always produce the same output. Used by both the informational + * banner in PatchSelectionScreen and by createPatchConfig when dispatching to + * the patcher, guaranteeing UI and behavior stay in sync. + */ +internal fun computeStripLibsStatus( + apkArches: List, + userKeep: Set +): StripLibsStatus { + if (apkArches.isEmpty()) return StripLibsStatus.NoNativeLibs + if (apkArches.size == 1 && apkArches[0].equals("universal", ignoreCase = true)) { + return StripLibsStatus.Universal + } + + val apkSet = apkArches.toSet() + val overlap = apkSet.intersect(userKeep) + val notInApk = userKeep.filter { it !in apkSet } + + return when { + overlap.isEmpty() -> StripLibsStatus.Fallback(apkArches) + overlap == apkSet -> StripLibsStatus.KeepAll(notInApk = notInApk) + else -> StripLibsStatus.WillStrip( + keeping = apkArches.filter { it in overlap }, + stripping = apkArches.filter { it !in overlap }, + notInApk = notInApk + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt index 0b4914a..c229a15 100644 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -20,7 +20,7 @@ object FileUtils { /** * All modern Android architectures. Obsolete architectures such as Mips are not included. */ - private val ANDROID_ARCHITECTURES = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + val ANDROID_ARCHITECTURES = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") private val EXTENSION_APK_BUNDLES = setOf("apkm", "xapk", "apks") private val EXTENSION_APK_ANY = EXTENSION_APK_BUNDLES + "apk" From d388aa558ece3444a33c4da66a7fdac617244f29 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 20 Apr 2026 08:13:52 +0000 Subject: [PATCH 10/14] chore: Release v1.8.0-dev.5 [skip ci] # [1.8.0-dev.5](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.4...v1.8.0-dev.5) (2026-04-20) ### Bug Fixes * Re-enable strip libs feature ([#117](https://github.com/MorpheApp/morphe-cli/issues/117)) ([6d2bb94](https://github.com/MorpheApp/morphe-cli/commit/6d2bb94425e3fa040cc0d00261b77559d69e4470)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e7ea5..0bb4008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.8.0-dev.5](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.4...v1.8.0-dev.5) (2026-04-20) + + +### Bug Fixes + +* Re-enable strip libs feature ([#117](https://github.com/MorpheApp/morphe-cli/issues/117)) ([6d2bb94](https://github.com/MorpheApp/morphe-cli/commit/6d2bb94425e3fa040cc0d00261b77559d69e4470)) + # [1.8.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.3...v1.8.0-dev.4) (2026-04-19) diff --git a/gradle.properties b/gradle.properties index dacebd2..9532159 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.8.0-dev.4 +version = 1.8.0-dev.5 From 4b946dd19aa437d0e7735ad9f26e20b40a7502ea Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:17:09 +0200 Subject: [PATCH 11/14] ci: Update release action --- .github/workflows/release.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd532ff..b13e7d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,11 +25,6 @@ jobs: - name: Cache Gradle uses: burrunan/gradle-cache-action@v3 - - name: Build - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./gradlew build clean - - name: Setup Node.js uses: actions/setup-node@v6 with: @@ -40,7 +35,7 @@ jobs: run: npm install - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} From 036faba68f8f0c1f683f3c6222f08d0377217266 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:02:51 +0530 Subject: [PATCH 12/14] fix: continue-on-error fix + force windows to `FULL` (#120) Fixed an issue where continue-on-error would fail sometimes even though it is not supposed to. Windows now forces `FULL` BytecodeMode. (Temp fix until wchill's permanent fix) --- .../app/morphe/cli/command/PatchCommand.kt | 9 +++++++-- src/main/kotlin/app/morphe/engine/PatchEngine.kt | 14 ++++++++++++-- .../kotlin/app/morphe/engine/PlatformCheck.kt | 5 +++++ .../gui/ui/screens/patching/PatchingViewModel.kt | 16 +++++++++++++++- 4 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/app/morphe/engine/PlatformCheck.kt diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index c5ecc41..34734ef 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -10,6 +10,7 @@ package app.morphe.cli.command import app.morphe.cli.command.model.* import app.morphe.engine.PatchEngine +import app.morphe.engine.isWindows 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.DEFAULT_SIGNER_NAME @@ -542,7 +543,11 @@ internal object PatchCommand : Callable { patcherTemporaryFilesPath.absolutePath, useArsclib = if (aaptBinaryPath != null) { false } else { !forceApktool }, keepArchitectures = keepArchitectures, - useBytecodeMode = bytecodeMode, + /* + TODO: Remove Windows override once the patcher ships its proper fix + (reflection-based MappedByteBuffer release + copy-instead-of-rename for output DEX files). + */ + useBytecodeMode = if (isWindows()) { BytecodeMode.FULL } else { bytecodeMode }, verifier = verifier ), ).use { patcher -> @@ -694,9 +699,9 @@ internal object PatchCommand : Callable { writer.toString() ) ) - patchingResult.success = false if (!continueOnError) { + patchingResult.success = false throw PatchFailedException( "\"${patchResult.patch}\" failed", exception diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index de53b55..cddc9cb 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -57,6 +57,7 @@ object PatchEngine { val aaptBinaryPath: File? = null, val tempDir: File? = null, val failOnError: Boolean = true, + val bytecodeMode: BytecodeMode = BytecodeMode.STRIP_FAST ) { companion object { internal const val DEFAULT_KEYSTORE_ALIAS = "Morphe" @@ -127,7 +128,11 @@ object PatchEngine { patcherTempDir.absolutePath, useArsclib = true, keepArchitectures = config.architecturesToKeep, - useBytecodeMode = BytecodeMode.STRIP_FAST + /* + TODO: Remove Windows override once the patcher ships its proper fix + (reflection-based MappedByteBuffer release + copy-instead-of-rename for output DEX files). + */ + useBytecodeMode = if (isWindows()) { BytecodeMode.FULL } else { config.bytecodeMode } ) Patcher(patcherConfig).use { patcher -> @@ -281,8 +286,13 @@ object PatchEngine { onProgress("Patching complete!") + // When failOnError=false (user asked to continue on error), reaching this + // line means the APK was successfully rebuilt from the patches that worked, + // treat the run as a success. Individual failures are still reported via + // `failedPatches` for the UI to display. Only strict mode (failOnError=true) + // treats any failure as an overall failure. return Result( - success = failedPatches.isEmpty(), + success = if (config.failOnError) failedPatches.isEmpty() else true, outputPath = config.outputApk.absolutePath, packageName = packageName, packageVersion = packageVersion, diff --git a/src/main/kotlin/app/morphe/engine/PlatformCheck.kt b/src/main/kotlin/app/morphe/engine/PlatformCheck.kt new file mode 100644 index 0000000..cdc4088 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/PlatformCheck.kt @@ -0,0 +1,5 @@ +package app.morphe.engine + +internal fun isWindows(): Boolean { + return System.getProperty("os.name").startsWith("Windows", ignoreCase = true) +} \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt index 5c27ddc..386c17c 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -84,7 +84,21 @@ class PatchingViewModel( result.fold( onSuccess = { patchResult -> if (patchResult.success) { - addLog("Patching completed successfully!", LogLevel.SUCCESS) + // Distinguish clean success from "continue-on-error" partial success: + // the APK was built, but some patches were skipped. Log the skipped + // ones as a warning so the user sees what didn't apply. + if (patchResult.failedPatches.isNotEmpty()) { + addLog( + "Patching completed with ${patchResult.failedPatches.size} patches skipped", + LogLevel.WARNING + ) + addLog( + "Skipped patches: ${patchResult.failedPatches.joinToString(", ")}", + LogLevel.WARNING + ) + } else { + addLog("Patching completed successfully!", LogLevel.SUCCESS) + } addLog("Applied ${patchResult.appliedPatches.size} patches", LogLevel.SUCCESS) _uiState.value = _uiState.value.copy( status = PatchingStatus.COMPLETED, From 5e2f9663baefb9eb1f0ed1a6e40451354e996607 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 21 Apr 2026 01:34:32 +0000 Subject: [PATCH 13/14] chore: Release v1.8.0-dev.6 [skip ci] # [1.8.0-dev.6](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.5...v1.8.0-dev.6) (2026-04-21) ### Bug Fixes * continue-on-error fix + force windows to `FULL` ([#120](https://github.com/MorpheApp/morphe-cli/issues/120)) ([036faba](https://github.com/MorpheApp/morphe-cli/commit/036faba68f8f0c1f683f3c6222f08d0377217266)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb4008..50ce0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.8.0-dev.6](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.5...v1.8.0-dev.6) (2026-04-21) + + +### Bug Fixes + +* continue-on-error fix + force windows to `FULL` ([#120](https://github.com/MorpheApp/morphe-cli/issues/120)) ([036faba](https://github.com/MorpheApp/morphe-cli/commit/036faba68f8f0c1f683f3c6222f08d0377217266)) + # [1.8.0-dev.5](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0-dev.4...v1.8.0-dev.5) (2026-04-20) diff --git a/gradle.properties b/gradle.properties index 9532159..452bb73 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.8.0-dev.5 +version = 1.8.0-dev.6 From 5c001cbde59aae7f2fc41a6ea8c857b641f633ea Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:25:37 +0200 Subject: [PATCH 14/14] docs: Recommend Java 21 --- docs/0_prerequisites.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/0_prerequisites.md b/docs/0_prerequisites.md index 9ac1c1c..db48b6c 100644 --- a/docs/0_prerequisites.md +++ b/docs/0_prerequisites.md @@ -4,7 +4,7 @@ To use Morphe CLI, you will need to fulfill specific requirements. ## 🤝 Requirements -- 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/) +- Java Runtime Environment 21 - [Azul Zulu JRE](https://www.azul.com/downloads/?version=java-21-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 ## ⏭️ Whats next