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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f831960..50ce0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +# [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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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/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 923da53..b159ac1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,16 @@ 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.shadow) + 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") application `maven-publish` signing @@ -107,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 // ============================================================================ @@ -134,10 +152,20 @@ 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) } + // 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 +177,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 +217,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/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 diff --git a/gradle.properties b/gradle.properties index d91e7c3..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.7.0 +version = 1.8.0-dev.6 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a0ab91..853d654 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,16 @@ [versions] # Core -shadow = "9.3.2" junit = "5.11.0" kotlin = "2.3.20" # CLI picocli = "4.7.7" arsclib = "9696ffecda" -morphe-patcher = "1.3.3" +morphe-patcher = "1.4.2" morphe-library = "1.3.0" # Compose Desktop -compose = "1.10.0" +compose = "1.10.3" # Networking ktor = "3.4.2" @@ -35,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" } @@ -79,9 +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" } -shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } +about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries" } diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index f589fd6..34734ef 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -8,35 +8,24 @@ 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.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 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 +260,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 +275,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 +307,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 +451,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 +541,14 @@ 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, + /* + 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 -> val packageName = patcher.context.packageMetadata.packageName @@ -636,9 +699,9 @@ internal object PatchCommand : Callable { writer.toString() ) ) - patchingResult.success = false if (!continueOnError) { + patchingResult.success = false throw PatchFailedException( "\"${patchResult.patch}\" failed", exception @@ -710,6 +773,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..cddc9cb 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 @@ -56,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" @@ -125,7 +127,12 @@ object PatchEngine { config.aaptBinaryPath?.path, patcherTempDir.absolutePath, useArsclib = true, - keepArchitectures = config.architecturesToKeep + keepArchitectures = config.architecturesToKeep, + /* + 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 -> @@ -279,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/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 9d3c420..9653dce 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( @@ -76,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 @@ -84,6 +90,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) } @@ -203,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) @@ -222,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) @@ -269,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 @@ -401,6 +435,10 @@ fun SettingsDialog( ) } + if (showLicensesDialog) { + LicensesDialog(onDismiss = { showLicensesDialog = false }) + } + editingSource?.let { source -> EditPatchSourceDialog( source = source, @@ -413,6 +451,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 @@ -434,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) @@ -455,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 @@ -607,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", @@ -1127,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 @@ -1140,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 @@ -1157,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/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, 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"