From 25a7ab44754c3c9f4bcab773eed258fa659d4edb Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:46:17 +0530 Subject: [PATCH 01/23] potential apkm fix --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e5cbb64..402927a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ # Local configuration file (sdk path, etc) local.properties +gradle.properties # Log/OS Files *.log From f92d1c9583824860350dcbfb59f6355a49a4a7d1 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:54:32 +0530 Subject: [PATCH 02/23] Update PatchCommand.kt --- .../app/morphe/cli/command/PatchCommand.kt | 205 ++++++------------ 1 file changed, 71 insertions(+), 134 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index bf88cd4..397e05f 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -30,7 +30,6 @@ import java.io.PrintWriter import java.io.StringWriter import java.util.logging.Logger -@OptIn(ExperimentalSerializationApi::class) @CommandLine.Command( name = "patch", description = ["Patch an APK file."], @@ -127,17 +126,6 @@ internal object PatchCommand : Runnable { this.outputFilePath = outputFilePath?.absoluteFile } - private var patchingResultOutputFilePath: File? = null - - @CommandLine.Option( - names = ["-r", "--result-file"], - description = ["Path to save the patching result file to"], - ) - @Suppress("unused") - private fun setPatchingResultOutputFilePath(outputFilePath: File?) { - this.patchingResultOutputFilePath = outputFilePath?.absoluteFile - } - @CommandLine.Option( names = ["-i", "--install"], description = ["Serial of the ADB device to install to. If not specified, the first connected device will be used."], @@ -343,79 +331,53 @@ internal object PatchCommand : Runnable { apk } - val patchingResult = PatchingResult() - - try { - val (packageName, patcherResult) = Patcher( - PatcherConfig( - inputApk, - patcherTemporaryFilesPath, - aaptBinaryPath?.path, - patcherTemporaryFilesPath.absolutePath, - ), - ).use { patcher -> - val packageName = patcher.context.packageMetadata.packageName - val packageVersion = patcher.context.packageMetadata.packageVersion - - patchingResult.packageName = packageName - patchingResult.packageVersion = packageVersion - - val filteredPatches = patches.filterPatchSelection(packageName, packageVersion) - - logger.info("Setting patch options") - - val patchesList = patches.toList() - selection.filter { it.enabled != null }.associate { - val enabledSelection = it.enabled!! - - (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to - enabledSelection.options - }.let(filteredPatches::setOptions) - - patcher += filteredPatches - - // Execute patches. - patchingResult.addStepResult( - PatchingStep.PATCHING, - { - runBlocking { - patcher().collect { patchResult -> - patchResult.exception?.let { exception -> - StringWriter().use { writer -> - exception.printStackTrace(PrintWriter(writer)) - - logger.severe("\"${patchResult.patch}\" failed:\n$writer") - - patchingResult.failedPatches.add( - FailedPatch( - patchResult.patch.toSerializablePatch(), - writer.toString() - ) - ) - patchingResult.success = false - } - } ?: patchResult.patch.let { - patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) - logger.info("\"${patchResult.patch}\" succeeded") - } - } - } - } - ) + val (packageName, patcherResult) = Patcher( + PatcherConfig( + inputApk, + patcherTemporaryFilesPath, + aaptBinaryPath?.path, + patcherTemporaryFilesPath.absolutePath, + ), + ).use { patcher -> + val packageName = patcher.context.packageMetadata.packageName + val packageVersion = patcher.context.packageMetadata.packageVersion - patcher.context.packageMetadata.packageName to patcher.get() - } + val filteredPatches = patches.filterPatchSelection(packageName, packageVersion) - // region Save. + logger.info("Setting patch options") - inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { - patchingResult.addStepResult( - PatchingStep.REBUILDING, - { - patcherResult.applyTo(this) + val patchesList = patches.toList() + selection.filter { it.enabled != null }.associate { + val enabledSelection = it.enabled!! + + (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to + enabledSelection.options + }.let(filteredPatches::setOptions) + + patcher += filteredPatches + + // Execute patches. + runBlocking { + patcher().collect { patchResult -> + val exception = patchResult.exception + ?: return@collect logger.info("\"${patchResult.patch}\" succeeded") + + StringWriter().use { writer -> + exception.printStackTrace(PrintWriter(writer)) + + logger.severe("\"${patchResult.patch}\" failed:\n$writer") } - ) - }.also { rebuiltApk -> + } + } + + patcher.context.packageMetadata.packageName to patcher.get() + } + + // region Save. + + inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { + patcherResult.applyTo(this) + }.also { rebuiltApk -> if (striplibs.isNotEmpty()) { patchingResult.addStepResult( PatchingStep.STRIPPING_LIBS, @@ -427,66 +389,41 @@ internal object PatchCommand : Runnable { ) } }.let { patchedApkFile -> - if (!mount && !unsigned) { - patchingResult.addStepResult( - PatchingStep.SIGNING, - { - ApkUtils.signApk( - patchedApkFile, - outputFilePath, - signer, - ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - keyStoreEntryAlias, - keyStoreEntryPassword, - ), - ) - } - ) - } else { - patchedApkFile.copyTo(outputFilePath, overwrite = true) - } - } - - logger.info("Saved to $outputFilePath") - - // endregion - - // region Install. - - deviceSerial?.let { - patchingResult.addStepResult( - PatchingStep.INSTALLING, - { - runBlocking { - val result = installer!!.install(Installer.Apk(outputFilePath, packageName)) - when (result) { - RootInstallerResult.FAILURE -> { - logger.severe("Failed to mount the patched APK file") - throw IllegalStateException("Failed to mount the patched APK file") - } - is AdbInstallerResult.Failure -> { - logger.severe(result.exception.toString()) - throw result.exception - } - else -> logger.info("Installed the patched APK file") - } - } - } + if (!mount && !unsigned) { + ApkUtils.signApk( + patchedApkFile, + outputFilePath, + signer, + ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + keyStoreEntryAlias, + keyStoreEntryPassword, + ), ) + } else { + patchedApkFile.copyTo(outputFilePath, overwrite = true) } + } + + logger.info("Saved to $outputFilePath") - // endregion - } finally { - patchingResultOutputFilePath?.let { outputFile -> - outputFile.outputStream().use { outputStream -> - Json.encodeToStream(patchingResult, outputStream) + // endregion + + // region Install. + + deviceSerial?.let { + runBlocking { + when (val result = installer!!.install(Installer.Apk(outputFilePath, packageName))) { + RootInstallerResult.FAILURE -> logger.severe("Failed to mount the patched APK file") + is AdbInstallerResult.Failure -> logger.severe(result.exception.toString()) + else -> logger.info("Installed the patched APK file") } - logger.info("Patching result saved to $outputFile") } } + // endregion + if (purge) { logger.info("Purging temporary files") purge(temporaryFilesPath) From 227bc14fbd189a2096b581c423612e15fc795f9f Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 1 Feb 2026 03:25:05 +0530 Subject: [PATCH 03/23] Update .gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 402927a..e5cbb64 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ build/ # Local configuration file (sdk path, etc) local.properties -gradle.properties # Log/OS Files *.log From b2e63beecd79057d5bd3df5aa99029bbdb87fa6a Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 1 Feb 2026 09:23:41 +0530 Subject: [PATCH 04/23] Update PatchCommand.kt --- .../app/morphe/cli/command/PatchCommand.kt | 205 ++++++++++++------ 1 file changed, 134 insertions(+), 71 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 397e05f..bf88cd4 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -30,6 +30,7 @@ import java.io.PrintWriter import java.io.StringWriter import java.util.logging.Logger +@OptIn(ExperimentalSerializationApi::class) @CommandLine.Command( name = "patch", description = ["Patch an APK file."], @@ -126,6 +127,17 @@ internal object PatchCommand : Runnable { this.outputFilePath = outputFilePath?.absoluteFile } + private var patchingResultOutputFilePath: File? = null + + @CommandLine.Option( + names = ["-r", "--result-file"], + description = ["Path to save the patching result file to"], + ) + @Suppress("unused") + private fun setPatchingResultOutputFilePath(outputFilePath: File?) { + this.patchingResultOutputFilePath = outputFilePath?.absoluteFile + } + @CommandLine.Option( names = ["-i", "--install"], description = ["Serial of the ADB device to install to. If not specified, the first connected device will be used."], @@ -331,53 +343,79 @@ internal object PatchCommand : Runnable { apk } - val (packageName, patcherResult) = Patcher( - PatcherConfig( - inputApk, - patcherTemporaryFilesPath, - aaptBinaryPath?.path, - patcherTemporaryFilesPath.absolutePath, - ), - ).use { patcher -> - val packageName = patcher.context.packageMetadata.packageName - val packageVersion = patcher.context.packageMetadata.packageVersion - - val filteredPatches = patches.filterPatchSelection(packageName, packageVersion) - - logger.info("Setting patch options") - - val patchesList = patches.toList() - selection.filter { it.enabled != null }.associate { - val enabledSelection = it.enabled!! - - (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to - enabledSelection.options - }.let(filteredPatches::setOptions) - - patcher += filteredPatches - - // Execute patches. - runBlocking { - patcher().collect { patchResult -> - val exception = patchResult.exception - ?: return@collect logger.info("\"${patchResult.patch}\" succeeded") - - StringWriter().use { writer -> - exception.printStackTrace(PrintWriter(writer)) - - logger.severe("\"${patchResult.patch}\" failed:\n$writer") + val patchingResult = PatchingResult() + + try { + val (packageName, patcherResult) = Patcher( + PatcherConfig( + inputApk, + patcherTemporaryFilesPath, + aaptBinaryPath?.path, + patcherTemporaryFilesPath.absolutePath, + ), + ).use { patcher -> + val packageName = patcher.context.packageMetadata.packageName + val packageVersion = patcher.context.packageMetadata.packageVersion + + patchingResult.packageName = packageName + patchingResult.packageVersion = packageVersion + + val filteredPatches = patches.filterPatchSelection(packageName, packageVersion) + + logger.info("Setting patch options") + + val patchesList = patches.toList() + selection.filter { it.enabled != null }.associate { + val enabledSelection = it.enabled!! + + (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to + enabledSelection.options + }.let(filteredPatches::setOptions) + + patcher += filteredPatches + + // Execute patches. + patchingResult.addStepResult( + PatchingStep.PATCHING, + { + runBlocking { + patcher().collect { patchResult -> + patchResult.exception?.let { exception -> + StringWriter().use { writer -> + exception.printStackTrace(PrintWriter(writer)) + + logger.severe("\"${patchResult.patch}\" failed:\n$writer") + + patchingResult.failedPatches.add( + FailedPatch( + patchResult.patch.toSerializablePatch(), + writer.toString() + ) + ) + patchingResult.success = false + } + } ?: patchResult.patch.let { + patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) + logger.info("\"${patchResult.patch}\" succeeded") + } + } + } } - } - } + ) - patcher.context.packageMetadata.packageName to patcher.get() - } + patcher.context.packageMetadata.packageName to patcher.get() + } - // region Save. + // region Save. - inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { - patcherResult.applyTo(this) - }.also { rebuiltApk -> + inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { + patchingResult.addStepResult( + PatchingStep.REBUILDING, + { + patcherResult.applyTo(this) + } + ) + }.also { rebuiltApk -> if (striplibs.isNotEmpty()) { patchingResult.addStepResult( PatchingStep.STRIPPING_LIBS, @@ -389,41 +427,66 @@ internal object PatchCommand : Runnable { ) } }.let { patchedApkFile -> - if (!mount && !unsigned) { - ApkUtils.signApk( - patchedApkFile, - outputFilePath, - signer, - ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - keyStoreEntryAlias, - keyStoreEntryPassword, - ), - ) - } else { - patchedApkFile.copyTo(outputFilePath, overwrite = true) + if (!mount && !unsigned) { + patchingResult.addStepResult( + PatchingStep.SIGNING, + { + ApkUtils.signApk( + patchedApkFile, + outputFilePath, + signer, + ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + keyStoreEntryAlias, + keyStoreEntryPassword, + ), + ) + } + ) + } else { + patchedApkFile.copyTo(outputFilePath, overwrite = true) + } } - } - logger.info("Saved to $outputFilePath") - - // endregion - - // region Install. + logger.info("Saved to $outputFilePath") + + // endregion + + // region Install. + + deviceSerial?.let { + patchingResult.addStepResult( + PatchingStep.INSTALLING, + { + runBlocking { + val result = installer!!.install(Installer.Apk(outputFilePath, packageName)) + when (result) { + RootInstallerResult.FAILURE -> { + logger.severe("Failed to mount the patched APK file") + throw IllegalStateException("Failed to mount the patched APK file") + } + is AdbInstallerResult.Failure -> { + logger.severe(result.exception.toString()) + throw result.exception + } + else -> logger.info("Installed the patched APK file") + } + } + } + ) + } - deviceSerial?.let { - runBlocking { - when (val result = installer!!.install(Installer.Apk(outputFilePath, packageName))) { - RootInstallerResult.FAILURE -> logger.severe("Failed to mount the patched APK file") - is AdbInstallerResult.Failure -> logger.severe(result.exception.toString()) - else -> logger.info("Installed the patched APK file") + // endregion + } finally { + patchingResultOutputFilePath?.let { outputFile -> + outputFile.outputStream().use { outputStream -> + Json.encodeToStream(patchingResult, outputStream) } + logger.info("Patching result saved to $outputFile") } } - // endregion - if (purge) { logger.info("Purging temporary files") purge(temporaryFilesPath) From ee63a1489b68d0add25f38c0ca4460a6c585b05e Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:06:27 +0530 Subject: [PATCH 05/23] feat: GUI Update --- .gitignore | 7 + build.gradle.kts | 113 +- gradle.properties | 1 + gradle/libs.versions.toml | 72 +- settings.gradle.kts | 13 + src/main/composeResources/drawable/morphe.svg | 40 + .../composeResources/drawable/morphe_logo.png | Bin 0 -> 5905 bytes src/main/composeResources/drawable/reddit.svg | 10 + .../composeResources/drawable/youtube.svg | 1 + .../drawable/youtube_music.svg | 1 + src/main/kotlin/app/morphe/MorpheLauncher.kt | 14 + .../app/morphe/cli/command/MainCommand.kt | 4 +- src/main/kotlin/app/morphe/gui/App.kt | 123 ++ src/main/kotlin/app/morphe/gui/GuiMain.kt | 99 ++ .../morphe/gui/data/constants/AppConstants.kt | 150 +++ .../app/morphe/gui/data/model/AppConfig.kt | 39 + .../kotlin/app/morphe/gui/data/model/Patch.kt | 83 ++ .../app/morphe/gui/data/model/Release.kt | 70 + .../app/morphe/gui/data/model/SupportedApp.kt | 71 + .../gui/data/repository/ConfigRepository.kt | 129 ++ .../gui/data/repository/PatchRepository.kt | 179 +++ .../kotlin/app/morphe/gui/di/AppModule.kt | 63 + .../morphe/gui/ui/components/ErrorDialog.kt | 154 +++ .../gui/ui/components/SettingsButton.kt | 81 ++ .../gui/ui/components/SettingsDialog.kt | 305 +++++ .../morphe/gui/ui/screens/home/HomeScreen.kt | 1165 +++++++++++++++++ .../gui/ui/screens/home/HomeViewModel.kt | 519 ++++++++ .../ui/screens/home/components/ApkDropZone.kt | 212 +++ .../ui/screens/home/components/ApkInfoCard.kt | 414 ++++++ .../home/components/FullScreenDropZone.kt | 74 ++ .../screens/patches/PatchSelectionScreen.kt | 707 ++++++++++ .../patches/PatchSelectionViewModel.kt | 325 +++++ .../gui/ui/screens/patches/PatchesScreen.kt | 478 +++++++ .../ui/screens/patches/PatchesViewModel.kt | 211 +++ .../gui/ui/screens/patching/PatchingScreen.kt | 457 +++++++ .../ui/screens/patching/PatchingViewModel.kt | 236 ++++ .../gui/ui/screens/quick/QuickPatchScreen.kt | 963 ++++++++++++++ .../ui/screens/quick/QuickPatchViewModel.kt | 470 +++++++ .../gui/ui/screens/result/ResultScreen.kt | 753 +++++++++++ .../kotlin/app/morphe/gui/ui/theme/Theme.kt | 79 ++ .../app/morphe/gui/ui/theme/ThemeState.kt | 17 + .../kotlin/app/morphe/gui/util/AdbManager.kt | 359 +++++ .../app/morphe/gui/util/ChecksumUtils.kt | 58 + .../kotlin/app/morphe/gui/util/FileUtils.kt | 149 +++ src/main/kotlin/app/morphe/gui/util/Logger.kt | 219 ++++ .../app/morphe/gui/util/PatchService.kt | 307 +++++ .../morphe/gui/util/SupportedAppExtractor.kt | 68 + src/main/resources/morphe_logo.icns | Bin 0 -> 230839 bytes src/main/resources/morphe_logo.ico | Bin 0 -> 605 bytes src/main/resources/morphe_logo.png | Bin 0 -> 5905 bytes 50 files changed, 10037 insertions(+), 25 deletions(-) create mode 100644 src/main/composeResources/drawable/morphe.svg create mode 100755 src/main/composeResources/drawable/morphe_logo.png create mode 100644 src/main/composeResources/drawable/reddit.svg create mode 100644 src/main/composeResources/drawable/youtube.svg create mode 100644 src/main/composeResources/drawable/youtube_music.svg create mode 100644 src/main/kotlin/app/morphe/MorpheLauncher.kt create mode 100644 src/main/kotlin/app/morphe/gui/App.kt create mode 100644 src/main/kotlin/app/morphe/gui/GuiMain.kt create mode 100644 src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt create mode 100644 src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt create mode 100644 src/main/kotlin/app/morphe/gui/data/model/Patch.kt create mode 100644 src/main/kotlin/app/morphe/gui/data/model/Release.kt create mode 100644 src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt create mode 100644 src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt create mode 100644 src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt create mode 100644 src/main/kotlin/app/morphe/gui/di/AppModule.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/components/ErrorDialog.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkDropZone.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/theme/ThemeState.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/AdbManager.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/FileUtils.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/Logger.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/PatchService.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt create mode 100644 src/main/resources/morphe_logo.icns create mode 100644 src/main/resources/morphe_logo.ico create mode 100755 src/main/resources/morphe_logo.png diff --git a/.gitignore b/.gitignore index e5cbb64..f32e01e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ # Local configuration file (sdk path, etc) local.properties +old_build.gradle.kts # Log/OS Files *.log @@ -23,6 +24,9 @@ misc.xml deploymentTargetDropDown.xml render.experimental.xml +# Kotlin +.kotlin/ + # Keystore files *.jks *.keystore @@ -32,3 +36,6 @@ google-services.json # Android Profiling *.hprof + +# Mac files +.DS_Store diff --git a/build.gradle.kts b/build.gradle.kts index 7fb4ed5..8af331b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,10 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.kotlin) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.compose) alias(libs.plugins.shadow) application `maven-publish` @@ -11,10 +13,33 @@ plugins { group = "app.morphe" +// ============================================================================ +// JVM / Kotlin Configuration +// ============================================================================ +kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + vendor.set(JvmVendorSpec.ADOPTIUM) + } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +// ============================================================================ +// Application Entry Point +// ============================================================================ +// Shadow JAR reads this for Main-Class manifest attribute. +// +// No args / double-click → GUI (Compose Desktop) +// With args (terminal) → CLI (PicoCLI) application { - mainClass = "app.morphe.cli.command.MainCommandKt" + mainClass.set("app.morphe.MorpheLauncherKt") } +// ============================================================================ +// Repositories +// ============================================================================ repositories { mavenLocal() mavenCentral() @@ -23,8 +48,10 @@ repositories { // A repository must be specified for some reason. "registry" is a dummy. url = uri("https://maven.pkg.github.com/MorpheApp/registry") credentials { - username = project.findProperty("gpr.user") as String? ?: System.getenv("GITHUB_ACTOR") - password = project.findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN") + username = project.findProperty("gpr.user") as String? + ?: System.getenv("GITHUB_ACTOR") + password = project.findProperty("gpr.key") as String? + ?: System.getenv("GITHUB_TOKEN") } } // Obtain baksmali/smali from source builds - https://github.com/iBotPeaches/smali @@ -32,6 +59,9 @@ repositories { maven { url = uri("https://jitpack.io") } } +// ============================================================================ +// Dependencies +// ============================================================================ val apkEditorLib by configurations.creating val strippedApkEditorLib by tasks.registering(org.gradle.jvm.tasks.Jar::class) { @@ -52,27 +82,55 @@ val strippedApkEditorLib by tasks.registering(org.gradle.jvm.tasks.Jar::class) { } dependencies { + // -- CLI / Core -------------------------------------------------------- implementation(libs.morphe.patcher) implementation(libs.morphe.library) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.json) implementation(libs.picocli) apkEditorLib(files("$rootDir/libs/APKEditor-1.4.7.jar")) implementation(files(strippedApkEditorLib)) - testImplementation(libs.kotlin.test) -} + // -- Compose Desktop --------------------------------------------------- + // OS-specific: JAR only runs on the OS it was built on. + // Build once per target OS (macOS, Linux, Windows). + implementation(compose.desktop.currentOs) + implementation(compose.components.resources) + @Suppress("DEPRECATION") + implementation(compose.material3) + implementation(compose.materialIconsExtended) -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } -} + // -- Async / Serialization --------------------------------------------- + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.kotlinx.serialization.json) + + // -- Networking (GUI) -------------------------------------------------- + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) + + // -- DI / Navigation (GUI) --------------------------------------------- + implementation(platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.koin.compose) -java { - targetCompatibility = JavaVersion.VERSION_11 + implementation(libs.voyager.navigator) + implementation(libs.voyager.screenmodel) + implementation(libs.voyager.koin) + implementation(libs.voyager.transitions) + + // -- APK Parsing (GUI) ------------------------------------------------- + implementation(libs.apk.parser) + + // -- Testing ----------------------------------------------------------- + testImplementation(libs.kotlin.test) + testImplementation(libs.mockk) } +// ============================================================================ +// Tasks +// ============================================================================ tasks { test { useJUnitPlatform() @@ -82,19 +140,39 @@ tasks { } processResources { - expand("projectVersion" to project.version) + // Only expand properties files, not binary files like PNG/ICO + filesMatching("**/*.properties") { + expand("projectVersion" to project.version) + } } + // ------------------------------------------------------------------------- + // Shadow JAR — the only distribution artifact + // ------------------------------------------------------------------------- shadowJar { exclude( "/prebuilt/linux/aapt", "/prebuilt/windows/aapt.exe", "/prebuilt/*/aapt_*", ) + exclude("/prebuilt/linux/aapt") + exclude("/prebuilt/windows/aapt.exe") + exclude("/prebuilt/*/aapt_*") + minimize { exclude(dependency("org.bouncycastle:.*")) exclude(dependency("app.morphe:morphe-patcher")) + // Compose / Skiko / Swing — cannot be minimized (reflection, native libs) + exclude(dependency("org.jetbrains.compose.*:.*")) + exclude(dependency("org.jetbrains.skiko:.*")) + exclude(dependency("org.jetbrains.kotlinx:kotlinx-coroutines-swing:.*")) + // Ktor uses ServiceLoader + exclude(dependency("io.ktor:.*")) + // Koin uses reflection + exclude(dependency("io.insert-koin:.*")) } + + mergeServiceFiles() } publish { @@ -102,6 +180,9 @@ tasks { } } +// ============================================================================ +// Publishing / Signing +// ============================================================================ // Needed by gradle-semantic-release-plugin. // Tracking: https://github.com/KengoTODA/gradle-semantic-release-plugin/issues/435 diff --git a/gradle.properties b/gradle.properties index fd63a01..9b4b907 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,4 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official version = 1.4.0-dev.2 +compose.resources.generated.internal = never diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 53481b5..8ad4fa6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,78 @@ [versions] -shadow = "8.3.9" +# Core kotlin = "2.3.0" -kotlinx = "1.9.0" +shadow = "8.3.9" + +# CLI picocli = "4.7.7" morphe-patcher = "1.1.1" morphe-library = "1.2.0" +# Compose Desktop +compose = "1.10.0" + +# Networking +ktor = "3.4.0" + +# DI +koin-bom = "4.1.1" + +# Navigation +voyager = "1.1.0-beta03" + +# Async / Serialization +coroutines = "1.10.2" +kotlinx-serialization = "1.9.0" + +# APK +apk-parser = "2.6.10" +arsclib = "1.3.8" + +# Testing +mockk = "1.14.3" + [libraries] -kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx" } +# Morphe Core picocli = { module = "info.picocli:picocli", version.ref = "picocli" } morphe-patcher = { module = "app.morphe:morphe-patcher", version.ref = "morphe-patcher" } morphe-library = { module = "app.morphe:morphe-library-jvm", version.ref = "morphe-library" } +# Ktor Client +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } + +# Koin +koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" } +koin-core = { module = "io.insert-koin:koin-core" } +koin-compose = { module = "io.insert-koin:koin-compose" } + +# Voyager Navigation +voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } +voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" } +voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" } +voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } + +# Coroutines +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } + +# Serialization +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +# APK +apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" } +arsclib = { module = "io.github.reandroid:ARSCLib", version.ref = "arsclib" } + +# Testing +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } + [plugins] -shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } -kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3afed46..9a2941c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,14 @@ +pluginManagement { + repositories { + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + rootProject.name = "morphe-cli" diff --git a/src/main/composeResources/drawable/morphe.svg b/src/main/composeResources/drawable/morphe.svg new file mode 100644 index 0000000..96ce4ec --- /dev/null +++ b/src/main/composeResources/drawable/morphe.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/composeResources/drawable/morphe_logo.png b/src/main/composeResources/drawable/morphe_logo.png new file mode 100755 index 0000000000000000000000000000000000000000..1c211b7b0c98fe89f990f214cedc899a93878ff7 GIT binary patch literal 5905 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 + + + + + + + + + \ No newline at end of file diff --git a/src/main/composeResources/drawable/youtube.svg b/src/main/composeResources/drawable/youtube.svg new file mode 100644 index 0000000..f125ec3 --- /dev/null +++ b/src/main/composeResources/drawable/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/composeResources/drawable/youtube_music.svg b/src/main/composeResources/drawable/youtube_music.svg new file mode 100644 index 0000000..2257e05 --- /dev/null +++ b/src/main/composeResources/drawable/youtube_music.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/MorpheLauncher.kt b/src/main/kotlin/app/morphe/MorpheLauncher.kt new file mode 100644 index 0000000..3098e7f --- /dev/null +++ b/src/main/kotlin/app/morphe/MorpheLauncher.kt @@ -0,0 +1,14 @@ +package app.morphe + +import app.morphe.library.logging.Logger + +fun main(args: Array) { + if (args.isEmpty()) { + app.morphe.gui.launchGui(args) + } else { + Logger.setDefault() + picocli.CommandLine(app.morphe.cli.command.MainCommand) + .execute(*args) + .let(System::exit) + } +} diff --git a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt index d8eff33..40bc787 100644 --- a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt @@ -7,7 +7,7 @@ import picocli.CommandLine.Command import picocli.CommandLine.IVersionProvider import java.util.* -fun main(args: Array) { +fun cliMain(args: Array) { Logger.setDefault() CommandLine(MainCommand).execute(*args).let(System::exit) } @@ -39,4 +39,4 @@ private object CLIVersionProvider : IVersionProvider { UtilityCommand::class, ], ) -private object MainCommand +internal object MainCommand diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt new file mode 100644 index 0000000..8bd8504 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -0,0 +1,123 @@ +package app.morphe.gui + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.navigator.Navigator +import cafe.adriel.voyager.transitions.SlideTransition +import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.gui.data.repository.PatchRepository +import app.morphe.gui.util.PatchService +import app.morphe.gui.di.appModule +import kotlinx.coroutines.launch +import org.koin.compose.KoinApplication +import org.koin.compose.koinInject +import app.morphe.gui.ui.screens.home.HomeScreen +import app.morphe.gui.ui.screens.quick.QuickPatchContent +import app.morphe.gui.ui.screens.quick.QuickPatchViewModel +import app.morphe.gui.ui.theme.LocalThemeState +import app.morphe.gui.ui.theme.MorpheTheme +import app.morphe.gui.ui.theme.ThemePreference +import app.morphe.gui.ui.theme.ThemeState +import app.morphe.gui.util.Logger + +/** + * Mode state for switching between simplified and full mode. + */ +data class ModeState( + val isSimplified: Boolean, + val onChange: (Boolean) -> Unit +) + +val LocalModeState = staticCompositionLocalOf { + error("No ModeState provided") +} + +@Composable +fun App(initialSimplifiedMode: Boolean = true) { + LaunchedEffect(Unit) { + Logger.init() + } + + KoinApplication(application = { + modules(appModule) + }) { + AppContent(initialSimplifiedMode) + } +} + +@Composable +private fun AppContent(initialSimplifiedMode: Boolean) { + val configRepository: ConfigRepository = koinInject() + val patchRepository: PatchRepository = koinInject() + val patchService: PatchService = koinInject() + val scope = rememberCoroutineScope() + + var themePreference by remember { mutableStateOf(ThemePreference.SYSTEM) } + var isSimplifiedMode by remember { mutableStateOf(initialSimplifiedMode) } + var isLoading by remember { mutableStateOf(true) } + + // Load config on startup + LaunchedEffect(Unit) { + val config = configRepository.loadConfig() + themePreference = config.getThemePreference() + isSimplifiedMode = config.useSimplifiedMode + isLoading = false + } + + // Callback for changing theme + val onThemeChange: (ThemePreference) -> Unit = { newTheme -> + themePreference = newTheme + scope.launch { + configRepository.setThemePreference(newTheme) + Logger.info("Theme changed to: ${newTheme.name}") + } + } + + // Callback for changing mode + val onModeChange: (Boolean) -> Unit = { simplified -> + isSimplifiedMode = simplified + scope.launch { + configRepository.setUseSimplifiedMode(simplified) + Logger.info("Mode changed to: ${if (simplified) "Simplified" else "Full"}") + } + } + + val themeState = ThemeState( + current = themePreference, + onChange = onThemeChange + ) + + val modeState = ModeState( + isSimplified = isSimplifiedMode, + onChange = onModeChange + ) + + MorpheTheme(themePreference = themePreference) { + CompositionLocalProvider( + LocalThemeState provides themeState, + LocalModeState provides modeState + ) { + Surface(modifier = Modifier.fillMaxSize()) { + if (!isLoading) { + Crossfade(targetState = isSimplifiedMode) { simplified -> + if (simplified) { + // Quick/Simplified mode + val quickViewModel = remember { + QuickPatchViewModel(patchRepository, patchService, configRepository) + } + QuickPatchContent(quickViewModel) + } else { + // Full mode + Navigator(HomeScreen()) { navigator -> + SlideTransition(navigator) + } + } + } + } + } + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt new file mode 100644 index 0000000..7e3b77a --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -0,0 +1,99 @@ +package app.morphe.gui + +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import app.morphe.gui.data.model.AppConfig +import kotlinx.serialization.json.Json +import org.jetbrains.skia.Image +import app.morphe.gui.util.FileUtils + +/** + * Main entry point. + * The app switches between simplified and full mode dynamically via settings. + */ +fun launchGui(args: Array) = application { + // Determine initial mode from args or config + val initialSimplifiedMode = when { + args.contains("--quick") || args.contains("-q") -> true + args.contains("--full") || args.contains("-f") -> false + else -> loadConfigSync().useSimplifiedMode + } + + val windowState = rememberWindowState( + size = DpSize(1024.dp, 768.dp), + position = WindowPosition(Alignment.Center) + ) + + val appIcon = remember { loadAppIcon() } + + Window( + onCloseRequest = ::exitApplication, + title = "Morphe", + state = windowState, + icon = appIcon + ) { + window.minimumSize = java.awt.Dimension(600, 400) + App(initialSimplifiedMode = initialSimplifiedMode) + } +} + +/** + * Load config synchronously (needed before app starts). + */ +private fun loadConfigSync(): AppConfig { + return try { + val configFile = FileUtils.getConfigFile() + if (configFile.exists()) { + val json = Json { ignoreUnknownKeys = true } + json.decodeFromString(configFile.readText()) + } else { + AppConfig() // Defaults: useSimplifiedMode = true + } + } catch (e: Exception) { + AppConfig() // Defaults on error + } +} + +/** + * Load the app icon from resources. + * Tries multiple classloaders and paths to handle different resource packaging. + */ +private fun loadAppIcon(): BitmapPainter? { + val possiblePaths = listOf( + "/morphe_logo.png", + "morphe_logo.png", + "/composeResources/app.morphe.morphe_cli.generated.resources/drawable/morphe_logo.png", + "composeResources/app.morphe.morphe_cli.generated.resources/drawable/morphe_logo.png" + ) + + // Try different classloader approaches + val classLoaders = listOf( + { path: String -> object {}.javaClass.getResourceAsStream(path) }, + { path: String -> Thread.currentThread().contextClassLoader.getResourceAsStream(path) }, + { path: String -> ClassLoader.getSystemResourceAsStream(path) } + ) + + for (loader in classLoaders) { + for (path in possiblePaths) { + try { + val stream = loader(path) + if (stream != null) { + return stream.use { + BitmapPainter(Image.makeFromEncoded(it.readBytes()).toComposeImageBitmap()) + } + } + } catch (e: Exception) { + // Try next combination + } + } + } + return null +} diff --git a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt new file mode 100644 index 0000000..95163f2 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -0,0 +1,150 @@ +package app.morphe.gui.data.constants + +/** + * Centralized configuration for supported apps. + * Update version, URL, and checksum here - changes will reflect throughout the app. + */ +object AppConstants { + + // ==================== APP INFO ==================== + const val APP_NAME = "Morphe GUI" + const val APP_VERSION = "1.4.0" // Keep in sync with build.gradle.kts + + // ==================== YOUTUBE ==================== + object YouTube { + const val DISPLAY_NAME = "YouTube" + const val PACKAGE_NAME = "com.google.android.youtube" + const val SUGGESTED_VERSION = "20.40.45" + const val APK_MIRROR_URL = "https://www.apkmirror.com/apk/google-inc/youtube/youtube-20-40-45-release/youtube-20-40-45-2-android-apk-download/" + + // SHA-256 checksum from APKMirror (leave null if not verified) + // You can find this on the APKMirror download page under "File SHA-256" + val SHA256_CHECKSUM: String? = "b7659da492a1ebd8bd7cea2909be4ee1f58e00a2586d65a1c91b2e1e5ec6acd1" + } + + // ==================== YOUTUBE MUSIC ==================== + object YouTubeMusic { + const val DISPLAY_NAME = "YouTube Music" + const val PACKAGE_NAME = "com.google.android.apps.youtube.music" + const val SUGGESTED_VERSION = "8.40.54" + const val APK_MIRROR_URL = "https://www.apkmirror.com/apk/google-inc/youtube-music/youtube-music-8-40-54-release/" + val SHA256_CHECKSUMS: Map = mapOf( + "arm64-v8a" to "d5b44919a5cd5648b01e392115fe68b9569b1c7847f3cdf65b1ace1302d005d2", + "armeabi-v7a" to "6f5181e8aaa2595af6c421b86ffffcc1c7a4e97968d7be89d04b46776392eaec", + "x86" to "03b1eb6993d43b1de6a9416828df7864be975ca6dd3a82468c431e3c193f3a80", + "x86_64" to "eab4cd51220b28c7108343cdb95a063251029f9a137d052a519d007a9321c848" + ) + } + + // ==================== REDDIT ==================== + object Reddit { + const val DISPLAY_NAME = "Reddit" + const val PACKAGE_NAME = "com.reddit.frontpage" + // APKMirror URL - to be updated with specific version when known + const val APK_MIRROR_URL = "https://www.apkmirror.com/apk/redditinc/reddit/" + } + + /** + * List of all supported package names for quick lookup. + */ + val SUPPORTED_PACKAGES = listOf( + YouTube.PACKAGE_NAME, + YouTubeMusic.PACKAGE_NAME, + Reddit.PACKAGE_NAME + ) + + /** + * Get suggested version for a package name. + */ + fun getSuggestedVersion(packageName: String): String? { + return when (packageName) { + YouTube.PACKAGE_NAME -> YouTube.SUGGESTED_VERSION + YouTubeMusic.PACKAGE_NAME -> YouTubeMusic.SUGGESTED_VERSION + else -> null + } + } + + /** + * Get checksum for a package name, version, and architecture. + * @param packageName The app's package name + * @param version The app version + * @param architectures List of architectures in the APK (from lib/ folder) + * @return The expected checksum, or null if not configured/version mismatch + */ + fun getChecksum(packageName: String, version: String, architectures: List = emptyList()): String? { + return when (packageName) { + YouTube.PACKAGE_NAME -> { + // YouTube has a universal APK with single checksum + if (version == YouTube.SUGGESTED_VERSION) YouTube.SHA256_CHECKSUM else null + } + YouTubeMusic.PACKAGE_NAME -> { + if (version != YouTubeMusic.SUGGESTED_VERSION) return null + if (YouTubeMusic.SHA256_CHECKSUMS.isEmpty()) return null + + // Try to find matching architecture checksum + // Check for universal first, then specific architectures + YouTubeMusic.SHA256_CHECKSUMS["universal"] + ?: architectures.firstNotNullOfOrNull { arch -> + YouTubeMusic.SHA256_CHECKSUMS[arch] + } + } + else -> null + } + } + + /** + * Check if we have any checksum configured for this package/version/architecture combo. + */ + fun hasChecksumConfigured(packageName: String, version: String, architectures: List = emptyList()): Boolean { + return getChecksum(packageName, version, architectures) != null + } + + /** + * Check if this is the recommended version. + */ + fun isRecommendedVersion(packageName: String, version: String): Boolean { + return getSuggestedVersion(packageName) == version + } + + // ==================== PATCH RECOMMENDATIONS ==================== + + /** + * Patches that are commonly disabled by users. + * These patches change default behavior in ways that some users may not want. + * The key is a partial match (case-insensitive) against patch names. + */ + object PatchRecommendations { + /** + * Patches commonly disabled for YouTube. + * Pair of (patch name pattern, reason for commonly disabling) + */ + val YOUTUBE_COMMONLY_DISABLED: List> = listOf( + "Custom Branding" to "Keeps the original name and logo for the app", +// "Hide ads" to "Some users prefer keeping ads to support creators", +// "Premium heading" to "Changes the YouTube logo/heading appearance", +// "Navigation buttons" to "Modifies bottom navigation bar layout", +// "Spoof client" to "May cause playback issues on some devices", +// "Change start page" to "Modifies the default landing page", +// "Disable auto captions" to "Some users rely on auto-generated captions" + ) + + /** + * Patches commonly disabled for YouTube Music. + */ + val YOUTUBE_MUSIC_COMMONLY_DISABLED: List> = listOf( + "Custom Branding" to "Keeps the original name and logo for the app", +// "Spoof client" to "May cause playback issues on some devices" + ) + + /** + * Get commonly disabled patches for a package. + */ + fun getCommonlyDisabled(packageName: String): List> { + return when (packageName) { + YouTube.PACKAGE_NAME -> YOUTUBE_COMMONLY_DISABLED + YouTubeMusic.PACKAGE_NAME -> YOUTUBE_MUSIC_COMMONLY_DISABLED + else -> emptyList() + } + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt new file mode 100644 index 0000000..bd23a27 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -0,0 +1,39 @@ +package app.morphe.gui.data.model + +import kotlinx.serialization.Serializable +import app.morphe.gui.ui.theme.ThemePreference + +/** + * Application configuration stored in config.json + */ +@Serializable +data class AppConfig( + val themePreference: String = ThemePreference.SYSTEM.name, + val lastCliVersion: String? = null, + val lastPatchesVersion: String? = null, + val preferredPatchChannel: String = PatchChannel.STABLE.name, + val defaultOutputDirectory: String? = null, + val autoCleanupTempFiles: Boolean = true, // Default ON + val useSimplifiedMode: Boolean = true // Default to Quick/Simplified mode +) { + fun getThemePreference(): ThemePreference { + return try { + ThemePreference.valueOf(themePreference) + } catch (e: Exception) { + ThemePreference.SYSTEM + } + } + + fun getPatchChannel(): PatchChannel { + return try { + PatchChannel.valueOf(preferredPatchChannel) + } catch (e: Exception) { + PatchChannel.STABLE + } + } +} + +enum class PatchChannel { + STABLE, + DEV +} diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt new file mode 100644 index 0000000..42b1396 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -0,0 +1,83 @@ +package app.morphe.gui.data.model + +import kotlinx.serialization.Serializable + +/** + * Represents a single patch from Morphe patches bundle. + */ +@Serializable +data class Patch( + val name: String, + val description: String = "", + val compatiblePackages: List = emptyList(), + val options: List = emptyList(), + val isEnabled: Boolean = true +) { + /** + * Unique identifier for this patch. + * Combines name, packages, and description hash for true uniqueness. + */ + val uniqueId: String + get() { + val packages = compatiblePackages.joinToString(",") { it.name } + val descHash = description.hashCode().toString(16) + return "$name|$packages|$descHash" + } + + /** + * Check if patch is compatible with a given package. + * Patches with no compatible packages listed are NOT shown (they're system patches). + */ + fun isCompatibleWith(packageName: String, versionName: String? = null): Boolean { + // Patches without explicit package compatibility are excluded + if (compatiblePackages.isEmpty()) return false + + return compatiblePackages.any { pkg -> + pkg.name == packageName && ( + versionName == null || + pkg.versions.isEmpty() || + pkg.versions.contains(versionName) + ) + } + } +} + +@Serializable +data class CompatiblePackage( + val name: String, + val versions: List = emptyList() +) + +@Serializable +data class PatchOption( + val key: String, + val title: String, + val description: String = "", + val type: PatchOptionType = PatchOptionType.STRING, + val default: String? = null, + val required: Boolean = false +) + +@Serializable +enum class PatchOptionType { + STRING, + BOOLEAN, + INT, + LONG, + FLOAT, + LIST +} + +/** + * Configuration for a patching session. + */ +@Serializable +data class PatchConfig( + val inputApkPath: String, + val outputApkPath: String, + val patchesFilePath: String, + val enabledPatches: List = emptyList(), + val disabledPatches: List = emptyList(), + val patchOptions: Map = emptyMap(), + val useExclusiveMode: Boolean = false +) diff --git a/src/main/kotlin/app/morphe/gui/data/model/Release.kt b/src/main/kotlin/app/morphe/gui/data/model/Release.kt new file mode 100644 index 0000000..941b5e9 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/model/Release.kt @@ -0,0 +1,70 @@ +package app.morphe.gui.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a GitHub release (for CLI or Patches) + */ +@Serializable +data class Release( + val id: Long, + @SerialName("tag_name") + val tagName: String, + val name: String, + @SerialName("prerelease") + val isPrerelease: Boolean, + val draft: Boolean = false, + @SerialName("published_at") + val publishedAt: String, + val assets: List = emptyList(), + val body: String? = null +) { + /** + * Get the version string (removes 'v' prefix if present) + */ + fun getVersion(): String { + return tagName.removePrefix("v") + } + + /** + * Check if this is a dev/pre-release version + */ + fun isDevRelease(): Boolean { + return isPrerelease || tagName.contains("dev", ignoreCase = true) || + tagName.contains("alpha", ignoreCase = true) || + tagName.contains("beta", ignoreCase = true) + } +} + +@Serializable +data class ReleaseAsset( + val id: Long, + val name: String, + @SerialName("browser_download_url") + val downloadUrl: String, + val size: Long, + @SerialName("content_type") + val contentType: String +) { + /** + * Check if this is a JAR file + */ + fun isJar(): Boolean = name.endsWith(".jar", ignoreCase = true) + + /** + * Check if this is an MPP (Morphe Patches) file + */ + fun isMpp(): Boolean = name.endsWith(".mpp", ignoreCase = true) + + /** + * Get human-readable file size + */ + fun getFormattedSize(): String { + return when { + size < 1024 -> "$size B" + size < 1024 * 1024 -> "${size / 1024} KB" + else -> "${size / (1024 * 1024)} MB" + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt new file mode 100644 index 0000000..d32745a --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt @@ -0,0 +1,71 @@ +package app.morphe.gui.data.model + +/** + * Represents a supported app extracted dynamically from patch metadata. + * This is populated by parsing the .mpp file's compatible packages. + */ +data class SupportedApp( + val packageName: String, + val displayName: String, + val supportedVersions: List, + val recommendedVersion: String?, + val apkMirrorUrl: String? = null +) { + companion object { + /** + * Derive display name from package name. + */ + fun getDisplayName(packageName: String): String { + return when (packageName) { + "com.google.android.youtube" -> "YouTube" + "com.google.android.apps.youtube.music" -> "YouTube Music" + "com.reddit.frontpage" -> "Reddit" + else -> { + // Fallback: Extract last part of package name and capitalize + packageName.substringAfterLast(".") + .replaceFirstChar { it.uppercase() } + } + } + } + + /** + * Get APK Mirror URL for a package name. + */ + fun getApkMirrorUrl(packageName: String): String? { + return when (packageName) { + "com.google.android.youtube" -> "https://www.apkmirror.com/apk/google-inc/youtube/" + "com.google.android.apps.youtube.music" -> "https://www.apkmirror.com/apk/google-inc/youtube-music/" + "com.reddit.frontpage" -> "https://www.apkmirror.com/apk/redditinc/reddit/" + else -> null + } + } + + /** + * Get the recommended version from a list of supported versions. + * Returns the highest version number. + */ + fun getRecommendedVersion(versions: List): String? { + if (versions.isEmpty()) return null + + return versions.sortedWith { v1, v2 -> + compareVersions(v2, v1) // Descending order + }.firstOrNull() + } + + /** + * Compare two version strings. + * Returns positive if v1 > v2, negative if v1 < v2, 0 if equal. + */ + private fun compareVersions(v1: String, v2: String): Int { + val parts1 = v1.split(".").mapNotNull { it.toIntOrNull() } + val parts2 = v2.split(".").mapNotNull { it.toIntOrNull() } + + for (i in 0 until maxOf(parts1.size, parts2.size)) { + val p1 = parts1.getOrElse(i) { 0 } + val p2 = parts2.getOrElse(i) { 0 } + if (p1 != p2) return p1.compareTo(p2) + } + return 0 + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt new file mode 100644 index 0000000..a298b0c --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -0,0 +1,129 @@ +package app.morphe.gui.data.repository + +import app.morphe.gui.data.model.AppConfig +import app.morphe.gui.data.model.PatchChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import app.morphe.gui.ui.theme.ThemePreference +import app.morphe.gui.util.FileUtils +import app.morphe.gui.util.Logger + +/** + * Repository for managing app configuration (config.json) + */ +class ConfigRepository { + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + private var cachedConfig: AppConfig? = null + + /** + * Load config from file, or return default if not exists. + */ + suspend fun loadConfig(): AppConfig = withContext(Dispatchers.IO) { + cachedConfig?.let { return@withContext it } + + val configFile = FileUtils.getConfigFile() + + try { + if (configFile.exists()) { + val content = configFile.readText() + val config = json.decodeFromString(content) + cachedConfig = config + Logger.info("Config loaded from ${configFile.absolutePath}") + config + } else { + Logger.info("No config file found, using defaults") + val default = AppConfig() + saveConfig(default) + default + } + } catch (e: Exception) { + Logger.error("Failed to load config, using defaults", e) + AppConfig() + } + } + + /** + * Save config to file. + */ + suspend fun saveConfig(config: AppConfig) = withContext(Dispatchers.IO) { + try { + val configFile = FileUtils.getConfigFile() + val content = json.encodeToString(AppConfig.serializer(), config) + configFile.writeText(content) + cachedConfig = config + Logger.info("Config saved to ${configFile.absolutePath}") + } catch (e: Exception) { + Logger.error("Failed to save config", e) + } + } + + /** + * Update theme preference. + */ + suspend fun setThemePreference(theme: ThemePreference) { + val current = loadConfig() + saveConfig(current.copy(themePreference = theme.name)) + } + + /** + * Update patch channel preference. + */ + suspend fun setPatchChannel(channel: PatchChannel) { + val current = loadConfig() + saveConfig(current.copy(preferredPatchChannel = channel.name)) + } + + /** + * Update last used CLI version. + */ + suspend fun setLastCliVersion(version: String) { + val current = loadConfig() + saveConfig(current.copy(lastCliVersion = version)) + } + + /** + * Update last used patches version. + */ + suspend fun setLastPatchesVersion(version: String) { + val current = loadConfig() + saveConfig(current.copy(lastPatchesVersion = version)) + } + + /** + * Update default output directory. + */ + suspend fun setDefaultOutputDirectory(path: String?) { + val current = loadConfig() + saveConfig(current.copy(defaultOutputDirectory = path)) + } + + /** + * Update auto-cleanup temp files setting. + */ + suspend fun setAutoCleanupTempFiles(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(autoCleanupTempFiles = enabled)) + } + + /** + * Update simplified mode setting. + */ + suspend fun setUseSimplifiedMode(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(useSimplifiedMode = enabled)) + } + + /** + * Clear cached config (for testing). + */ + fun clearCache() { + cachedConfig = null + } +} diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt new file mode 100644 index 0000000..7881939 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -0,0 +1,179 @@ +package app.morphe.gui.data.repository + +import app.morphe.gui.data.model.Release +import app.morphe.gui.data.model.ReleaseAsset +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import app.morphe.gui.util.FileUtils +import app.morphe.gui.util.Logger +import java.io.File + +/** + * Repository for fetching Morphe patches from GitHub releases. + */ +class PatchRepository( + private val httpClient: HttpClient +) { + companion object { + private const val GITHUB_API_BASE = "https://api.github.com" + private const val PATCHES_REPO = "MorpheApp/morphe-patches" + private const val RELEASES_ENDPOINT = "$GITHUB_API_BASE/repos/$PATCHES_REPO/releases" + } + + /** + * Fetch all releases from GitHub. + */ + suspend fun fetchReleases(): Result> = withContext(Dispatchers.IO) { + try { + Logger.info("Fetching releases from $RELEASES_ENDPOINT") + val response: HttpResponse = httpClient.get(RELEASES_ENDPOINT) { + headers { + append(HttpHeaders.Accept, "application/vnd.github+json") + append("X-GitHub-Api-Version", "2022-11-28") + } + } + + if (response.status.isSuccess()) { + val releases: List = response.body() + Logger.info("Fetched ${releases.size} releases") + Result.success(releases) + } else { + val error = "Failed to fetch releases: ${response.status}" + Logger.error(error) + Result.failure(Exception(error)) + } + } catch (e: Exception) { + Logger.error("Error fetching releases", e) + Result.failure(e) + } + } + + /** + * Get stable releases only (non-prerelease). + */ + suspend fun fetchStableReleases(): Result> { + return fetchReleases().map { releases -> + releases.filter { !it.isDevRelease() } + } + } + + /** + * Get dev/prerelease versions only. + */ + suspend fun fetchDevReleases(): Result> { + return fetchReleases().map { releases -> + releases.filter { it.isDevRelease() } + } + } + + /** + * Get the latest stable release. + */ + suspend fun getLatestStableRelease(): Result { + return fetchStableReleases().map { it.firstOrNull() } + } + + /** + * Get the latest dev release. + */ + suspend fun getLatestDevRelease(): Result { + return fetchDevReleases().map { it.firstOrNull() } + } + + /** + * Find the .mpp asset in a release. + */ + fun findMppAsset(release: Release): ReleaseAsset? { + return release.assets.find { it.isMpp() } + } + + /** + * Download the .mpp patch file from a release. + * Returns the path to the downloaded file. + */ + suspend fun downloadPatches(release: Release, onProgress: (Float) -> Unit = {}): Result = withContext(Dispatchers.IO) { + val asset = findMppAsset(release) + if (asset == null) { + val error = "No .mpp file found in release ${release.tagName}" + Logger.error(error) + return@withContext Result.failure(Exception(error)) + } + + val patchesDir = FileUtils.getPatchesDir() + val targetFile = File(patchesDir, asset.name) + + // Check if already cached + if (targetFile.exists() && targetFile.length() == asset.size) { + Logger.info("Using cached patches: ${targetFile.absolutePath}") + onProgress(1f) + return@withContext Result.success(targetFile) + } + + try { + Logger.info("Downloading patches from ${asset.downloadUrl}") + + val response: HttpResponse = httpClient.get(asset.downloadUrl) { + headers { + append(HttpHeaders.Accept, "application/octet-stream") + } + } + + if (!response.status.isSuccess()) { + val error = "Failed to download patches: ${response.status}" + Logger.error(error) + return@withContext Result.failure(Exception(error)) + } + + val bytes = response.readRawBytes() + targetFile.writeBytes(bytes) + onProgress(1f) + + Logger.info("Patches downloaded to ${targetFile.absolutePath}") + Result.success(targetFile) + } catch (e: Exception) { + Logger.error("Error downloading patches", e) + // Clean up partial download + if (targetFile.exists()) { + targetFile.delete() + } + Result.failure(e) + } + } + + /** + * Get cached patch file for a specific version. + */ + fun getCachedPatches(version: String): File? { + val patchesDir = FileUtils.getPatchesDir() + return patchesDir.listFiles()?.find { + it.name.contains(version) && it.name.endsWith(".mpp") + } + } + + /** + * List all cached patch versions. + */ + fun listCachedPatches(): List { + val patchesDir = FileUtils.getPatchesDir() + return patchesDir.listFiles()?.filter { it.name.endsWith(".mpp") } ?: emptyList() + } + + /** + * Delete cached patches. + */ + fun clearCache(): Boolean { + return try { + FileUtils.getPatchesDir().listFiles()?.forEach { it.delete() } + Logger.info("Patches cache cleared") + true + } catch (e: Exception) { + Logger.error("Failed to clear patches cache", e) + false + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt new file mode 100644 index 0000000..b542bca --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -0,0 +1,63 @@ +package app.morphe.gui.di + +import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.gui.data.repository.PatchRepository +import app.morphe.gui.util.PatchService +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json +import org.koin.dsl.module +import app.morphe.gui.ui.screens.home.HomeViewModel +import app.morphe.gui.ui.screens.patches.PatchesViewModel +import app.morphe.gui.ui.screens.patches.PatchSelectionViewModel +import app.morphe.gui.ui.screens.patching.PatchingViewModel + +/** + * Main Koin module for dependency injection. + */ +val appModule = module { + + // JSON serialization + single { + Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + isLenient = true + } + } + + // Ktor HTTP Client + single { + HttpClient(CIO) { + install(ContentNegotiation) { + json(get()) + } + install(Logging) { + level = LogLevel.INFO + logger = object : Logger { + override fun log(message: String) { + app.morphe.gui.util.Logger.debug("HTTP: $message") + } + } + } + engine { + requestTimeout = 60_000 + } + } + } + + // Repositories and Services + single { ConfigRepository() } + single { PatchRepository(get()) } + single { PatchService() } + + // ViewModels (ScreenModels) + factory { HomeViewModel(get(), get(), get()) } + factory { params -> PatchesViewModel(params.get(), params.get(), get(), get()) } + factory { params -> PatchSelectionViewModel(params.get(), params.get(), params.get(), get(), get()) } + factory { params -> PatchingViewModel(params.get(), get(), get()) } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ErrorDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/ErrorDialog.kt new file mode 100644 index 0000000..804ece1 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/ErrorDialog.kt @@ -0,0 +1,154 @@ +package app.morphe.gui.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.MorpheColors + +enum class ErrorType { + NETWORK, + FILE, + CLI, + GENERIC +} + +@Composable +fun ErrorDialog( + title: String, + message: String, + errorType: ErrorType = ErrorType.GENERIC, + onDismiss: () -> Unit, + onRetry: (() -> Unit)? = null, + dismissText: String = "OK", + retryText: String = "Retry" +) { + val icon = when (errorType) { + ErrorType.NETWORK -> Icons.Default.WifiOff + ErrorType.FILE -> Icons.Default.Error + ErrorType.CLI -> Icons.Default.Error + ErrorType.GENERIC -> Icons.Default.Warning + } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(16.dp), + icon = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(48.dp) + ) + }, + title = { + Text( + text = title, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + }, + text = { + Text( + text = message, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + confirmButton = { + if (onRetry != null) { + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Blue + ), + shape = RoundedCornerShape(8.dp) + ) { + Text(retryText) + } + } else { + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Blue + ), + shape = RoundedCornerShape(8.dp) + ) { + Text(dismissText) + } + } + }, + dismissButton = if (onRetry != null) { + { + TextButton(onClick = onDismiss) { + Text(dismissText) + } + } + } else null + ) +} + +/** + * Helper function to determine error type from exception or message. + */ +fun getErrorType(error: String): ErrorType { + val lowerError = error.lowercase() + return when { + lowerError.contains("network") || + lowerError.contains("connect") || + lowerError.contains("timeout") || + lowerError.contains("unreachable") || + lowerError.contains("internet") -> ErrorType.NETWORK + + lowerError.contains("file") || + lowerError.contains("permission") || + lowerError.contains("access") || + lowerError.contains("read") || + lowerError.contains("write") -> ErrorType.FILE + + lowerError.contains("cli") || + lowerError.contains("patch") || + lowerError.contains("exit code") -> ErrorType.CLI + + else -> ErrorType.GENERIC + } +} + +/** + * Get user-friendly error message. + */ +fun getFriendlyErrorMessage(error: String): String { + val lowerError = error.lowercase() + return when { + lowerError.contains("timeout") -> + "The connection timed out. Please check your internet connection and try again." + + lowerError.contains("unreachable") || lowerError.contains("connect") -> + "Unable to connect to the server. Please check your internet connection." + + lowerError.contains("permission") || lowerError.contains("access denied") -> + "Permission denied. Please check that you have access to the file or folder." + + lowerError.contains("not found") -> + "The requested file or resource was not found." + + lowerError.contains("disk full") || lowerError.contains("no space") -> + "Not enough disk space. Please free up some space and try again." + + lowerError.contains("exit code") -> + "The patching process encountered an error. Check the logs for details." + + else -> error + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt new file mode 100644 index 0000000..ac1411b --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -0,0 +1,81 @@ +package app.morphe.gui.ui.components + +import app.morphe.gui.LocalModeState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import app.morphe.gui.data.repository.ConfigRepository +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import app.morphe.gui.ui.theme.LocalThemeState + +/** + * Reusable settings button that can be placed on any screen. + * @param allowCacheClear Whether to allow cache clearing (disable on patches screen and beyond) + */ +@Composable +fun SettingsButton( + modifier: Modifier = Modifier, + allowCacheClear: Boolean = true +) { + val themeState = LocalThemeState.current + val modeState = LocalModeState.current + val configRepository: ConfigRepository = koinInject() + val scope = rememberCoroutineScope() + + var showSettingsDialog by remember { mutableStateOf(false) } + var autoCleanupTempFiles by remember { mutableStateOf(true) } + + // Load config when dialog is shown + LaunchedEffect(showSettingsDialog) { + if (showSettingsDialog) { + val config = configRepository.loadConfig() + autoCleanupTempFiles = config.autoCleanupTempFiles + } + } + + Box(modifier = modifier) { + IconButton( + onClick = { showSettingsDialog = true }, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (showSettingsDialog) { + SettingsDialog( + currentTheme = themeState.current, + onThemeChange = { themeState.onChange(it) }, + autoCleanupTempFiles = autoCleanupTempFiles, + onAutoCleanupChange = { enabled -> + autoCleanupTempFiles = enabled + scope.launch { + configRepository.setAutoCleanupTempFiles(enabled) + } + }, + useSimplifiedMode = modeState.isSimplified, + onSimplifiedModeChange = { enabled -> + modeState.onChange(enabled) + }, + onDismiss = { showSettingsDialog = false }, + allowCacheClear = allowCacheClear + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt new file mode 100644 index 0000000..dd88e88 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -0,0 +1,305 @@ +package app.morphe.gui.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.ui.theme.ThemePreference +import app.morphe.gui.util.FileUtils +import app.morphe.gui.util.Logger +import java.awt.Desktop +import java.io.File + +@Composable +fun SettingsDialog( + currentTheme: ThemePreference, + onThemeChange: (ThemePreference) -> Unit, + autoCleanupTempFiles: Boolean, + onAutoCleanupChange: (Boolean) -> Unit, + useSimplifiedMode: Boolean, + onSimplifiedModeChange: (Boolean) -> Unit, + onDismiss: () -> Unit, + allowCacheClear: Boolean = true +) { + var showClearCacheConfirm by remember { mutableStateOf(false) } + var cacheCleared by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(16.dp), + title = { + Text( + text = "Settings", + fontWeight = FontWeight.SemiBold + ) + }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .widthIn(min = 300.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Theme selection + Text( + text = "Theme", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ThemePreference.entries.forEach { theme -> + FilterChip( + selected = currentTheme == theme, + onClick = { onThemeChange(theme) }, + label = { Text(theme.toDisplayName()) } + ) + } + } + + HorizontalDivider() + + // Simplified mode setting + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Simplified mode", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Quick one-click patching with default settings", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = useSimplifiedMode, + onCheckedChange = onSimplifiedModeChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MorpheColors.Blue, + checkedTrackColor = MorpheColors.Blue.copy(alpha = 0.5f) + ) + ) + } + + HorizontalDivider() + + // Auto-cleanup setting + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Auto-cleanup temp files", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Automatically delete temporary files after patching", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = autoCleanupTempFiles, + onCheckedChange = onAutoCleanupChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MorpheColors.Teal, + checkedTrackColor = MorpheColors.Teal.copy(alpha = 0.5f) + ) + ) + } + + HorizontalDivider() + + // Actions + Text( + text = "Actions", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + + // Export logs button + OutlinedButton( + onClick = { + try { + val logsDir = FileUtils.getLogsDir() + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(logsDir) + } + } catch (e: Exception) { + Logger.error("Failed to open logs folder", e) + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.BugReport, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Open Logs Folder") + } + + // Open app data folder + OutlinedButton( + onClick = { + try { + val appDataDir = FileUtils.getAppDataDir() + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(appDataDir) + } + } catch (e: Exception) { + Logger.error("Failed to open app data folder", e) + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Open App Data Folder") + } + + // Clear cache button + OutlinedButton( + onClick = { showClearCacheConfirm = true }, + enabled = allowCacheClear && !cacheCleared, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = if (cacheCleared) MorpheColors.Teal else MaterialTheme.colorScheme.error, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + when { + !allowCacheClear -> "Clear Cache (disabled during patching)" + cacheCleared -> "Cache Cleared" + else -> "Clear Cache" + } + ) + } + + // Cache info + val cacheSize = calculateCacheSize() + Text( + text = "Cache: $cacheSize (CLI + Patches)", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + + // About + Text( + text = "${AppConstants.APP_NAME} v${AppConstants.APP_VERSION}", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Close") + } + } + ) + + // Clear cache confirmation dialog + if (showClearCacheConfirm) { + AlertDialog( + onDismissRequest = { showClearCacheConfirm = false }, + shape = RoundedCornerShape(16.dp), + title = { Text("Clear Cache?") }, + text = { + Text("This will delete downloaded CLI and patch files. They will be re-downloaded when needed.") + }, + confirmButton = { + Button( + onClick = { + clearAllCache() + cacheCleared = true + showClearCacheConfirm = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Clear") + } + }, + dismissButton = { + TextButton(onClick = { showClearCacheConfirm = false }) { + Text("Cancel") + } + } + ) + } +} + +private fun ThemePreference.toDisplayName(): String { + return when (this) { + ThemePreference.LIGHT -> "Light" + ThemePreference.DARK -> "Dark" + ThemePreference.SYSTEM -> "System" + } +} + +private fun calculateCacheSize(): String { + val patchesSize = FileUtils.getPatchesDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + + return when { + patchesSize < 1024 -> "$patchesSize B" + patchesSize < 1024 * 1024 -> "%.1f KB".format(patchesSize / 1024.0) + else -> "%.1f MB".format(patchesSize / (1024.0 * 1024.0)) + } +} + +private fun clearAllCache() { + try { + FileUtils.getPatchesDir().listFiles()?.forEach { it.delete() } + FileUtils.cleanupAllTempDirs() + Logger.info("Cache cleared successfully") + } catch (e: Exception) { + Logger.error("Failed to clear cache", e) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..5844a51 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -0,0 +1,1165 @@ +package app.morphe.gui.ui.screens.home + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.morphe_cli.generated.resources.Res +import app.morphe.morphe_cli.generated.resources.morphe +import app.morphe.morphe_cli.generated.resources.reddit +import app.morphe.morphe_cli.generated.resources.youtube +import app.morphe.morphe_cli.generated.resources.youtube_music +import org.jetbrains.compose.resources.painterResource +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.data.model.SupportedApp +import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.screens.home.components.ApkInfoCard +import app.morphe.gui.ui.screens.home.components.FullScreenDropZone +import app.morphe.gui.ui.screens.patches.PatchesScreen +import app.morphe.gui.ui.screens.patches.PatchSelectionScreen +import app.morphe.gui.ui.theme.MorpheColors +import java.awt.FileDialog +import java.awt.Frame +import java.io.File + +class HomeScreen : Screen { + + @Composable + override fun Content() { + val viewModel = koinScreenModel() + HomeScreenContent(viewModel = viewModel) + } +} + +@Composable +fun HomeScreenContent( + viewModel: HomeViewModel +) { + val navigator = LocalNavigator.currentOrThrow + val uiState by viewModel.uiState.collectAsState() + + // Refresh patches when returning from PatchesScreen (in case user selected a different version) + // Use navigator.items.size as key so this triggers when navigation stack changes (e.g., pop back) + val navStackSize = navigator.items.size + LaunchedEffect(navStackSize) { + viewModel.refreshPatchesIfNeeded() + } + + // Show error snackbar + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + snackbarHostState.showSnackbar( + message = error, + duration = SnackbarDuration.Short + ) + viewModel.clearError() + } + } + + // Full screen drop zone wrapper + FullScreenDropZone( + isDragHovering = uiState.isDragHovering, + onDragHoverChange = { viewModel.setDragHover(it) }, + onFilesDropped = { viewModel.onFilesDropped(it) } + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + val isCompact = maxWidth < 500.dp + val isSmall = maxHeight < 600.dp + val padding = if (isCompact) 16.dp else 24.dp + + // Version warning dialog state + var showVersionWarningDialog by remember { mutableStateOf(false) } + + // Version warning dialog + if (showVersionWarningDialog && uiState.apkInfo != null) { + VersionWarningDialog( + versionStatus = uiState.apkInfo!!.versionStatus, + currentVersion = uiState.apkInfo!!.versionName, + suggestedVersion = uiState.apkInfo!!.suggestedVersion ?: "", + onConfirm = { + showVersionWarningDialog = false + val patchesFile = viewModel.getCachedPatchesFile() + if (patchesFile != null && uiState.apkInfo != null) { + navigator.push(PatchSelectionScreen( + apkPath = uiState.apkInfo!!.filePath, + apkName = uiState.apkInfo!!.appName, + patchesFilePath = patchesFile.absolutePath + )) + } + }, + onDismiss = { showVersionWarningDialog = false } + ) + } + + val scrollState = rememberScrollState() + + // Estimate content heights to calculate flexible spacer + val brandingHeight = if (isCompact) 48.dp else 60.dp + val topSpacing = if (isSmall) 24.dp else 48.dp // top spacer + after branding + val middleContentHeight = if (uiState.apkInfo != null) { + // ApkInfoCard (~250dp) + buttons (~72dp) + spacer + if (isCompact) 340.dp else 380.dp + } else { + // Drop prompt section + if (isCompact) 160.dp else 200.dp + } + val supportedAppsHeight = if (isCompact) 220.dp else 280.dp + val bottomSpacing = if (isSmall) 24.dp else 40.dp // spacers around supported apps + + val totalFixedHeight = brandingHeight + topSpacing + middleContentHeight + supportedAppsHeight + bottomSpacing + (padding * 2) + + // Extra space to push supported apps to bottom on large screens + val extraSpace = (maxHeight - totalFixedHeight).coerceAtLeast(0.dp) + + Box(modifier = Modifier.fillMaxSize()) { + // Always scrollable - but on large screens extraSpace fills the gap + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(padding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) + BrandingSection(isCompact = isCompact) + + // Patches version selector card - right under logo + if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + PatchesVersionCard( + patchesVersion = uiState.patchesVersion!!, + isLatest = uiState.isUsingLatestPatches, + onChangePatchesClick = { + // Navigate to patches version selection screen + // Pass empty apk info since user hasn't selected an APK yet + navigator.push(PatchesScreen( + apkPath = uiState.apkInfo?.filePath ?: "", + apkName = uiState.apkInfo?.appName ?: "Select APK first" + )) + }, + isCompact = isCompact, + modifier = Modifier + .widthIn(max = 400.dp) + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + ) + } else if (uiState.isLoadingPatches) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MorpheColors.Blue + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Loading patches...", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(if (isSmall) 16.dp else 32.dp)) + + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = !uiState.isLoadingPatches && viewModel.getCachedPatchesFile() != null, + onClearClick = { viewModel.clearSelection() }, + onChangeClick = { + openFilePicker()?.let { file -> + viewModel.onFileSelected(file) + } + }, + onContinueClick = { + val patchesFile = viewModel.getCachedPatchesFile() + if (patchesFile == null) { + // Patches not ready yet + return@MiddleContent + } + + val versionStatus = uiState.apkInfo?.versionStatus + if (versionStatus != null && versionStatus != VersionStatus.EXACT_MATCH && versionStatus != VersionStatus.UNKNOWN) { + showVersionWarningDialog = true + } else { + uiState.apkInfo?.let { info -> + navigator.push(PatchSelectionScreen( + apkPath = info.filePath, + apkName = info.appName, + patchesFilePath = patchesFile.absolutePath + )) + } + } + } + ) + + // Flexible spacer - expands on large screens, minimal on small screens + Spacer(modifier = Modifier.height(extraSpace + if (isSmall) 16.dp else 24.dp)) + + SupportedAppsSection( + isCompact = isCompact, + maxWidth = this@BoxWithConstraints.maxWidth, + isLoading = uiState.isLoadingPatches, + supportedApps = uiState.supportedApps, + loadError = uiState.patchLoadError, + onRetry = { viewModel.retryLoadPatches() } + ) + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) + } + + // Settings button in top-right corner + SettingsButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(padding), + allowCacheClear = true + ) + + // Snackbar host + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + + // Drag overlay + if (uiState.isDragHovering) { + DragOverlay() + } + } + } + } +} + +@Composable +private fun MiddleContent( + uiState: HomeUiState, + isCompact: Boolean, + patchesLoaded: Boolean, + onClearClick: () -> Unit, + onChangeClick: () -> Unit, + onContinueClick: () -> Unit +) { + if (uiState.apkInfo != null) { + ApkSelectedSection( + patchesLoaded = patchesLoaded, + apkInfo = uiState.apkInfo, + isCompact = isCompact, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick + ) + } else { + DropPromptSection( + isDragHovering = uiState.isDragHovering, + isCompact = isCompact, + onBrowseClick = onChangeClick + ) + } +} + +@Composable +private fun ApkSelectedSection( + patchesLoaded: Boolean, + apkInfo: ApkInfo, + isCompact: Boolean, + onClearClick: () -> Unit, + onChangeClick: () -> Unit, + onContinueClick: () -> Unit +) { + val showWarning = apkInfo.versionStatus != VersionStatus.EXACT_MATCH && + apkInfo.versionStatus != VersionStatus.UNKNOWN + val warningColor = when (apkInfo.versionStatus) { + VersionStatus.NEWER_VERSION -> MaterialTheme.colorScheme.error + VersionStatus.OLDER_VERSION -> Color(0xFFFF9800) + else -> MorpheColors.Blue + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.widthIn(max = 500.dp) + ) { + ApkInfoCard( + apkInfo = apkInfo, + onClearClick = onClearClick, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(if (isCompact) 16.dp else 24.dp)) + + // Action buttons - stack vertically on compact + if (isCompact) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = onContinueClick, + enabled = patchesLoaded, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (showWarning) warningColor else MorpheColors.Blue + ), + shape = RoundedCornerShape(12.dp) + ) { + if (!patchesLoaded) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Loading patches...", + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } else { + if (showWarning) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Warning", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + "Continue", + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + } + OutlinedButton( + onClick = onChangeClick, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text( + "Change APK", + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedButton( + onClick = onChangeClick, + modifier = Modifier.height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text( + "Change APK", + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + + Button( + onClick = onContinueClick, + enabled = patchesLoaded, + modifier = Modifier + .widthIn(min = 160.dp) + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (showWarning) warningColor else MorpheColors.Blue + ), + shape = RoundedCornerShape(12.dp) + ) { + if (!patchesLoaded) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Loading...", + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } else { + if (showWarning) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Warning", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + "Continue", + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } +} + +@Composable +private fun VersionWarningDialog( + versionStatus: VersionStatus, + currentVersion: String, + suggestedVersion: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val (title, message) = when (versionStatus) { + VersionStatus.NEWER_VERSION -> Pair( + "Version Too New", + "You're using v$currentVersion, but the recommended version is v$suggestedVersion.\n\n" + + "Patching newer versions may cause issues or some patches might not work correctly.\n\n" + + "Do you want to continue anyway?" + ) + VersionStatus.OLDER_VERSION -> Pair( + "Older Version Detected", + "You're using v$currentVersion, but newer patches are available for v$suggestedVersion.\n\n" + + "You may be missing out on new features and bug fixes.\n\n" + + "Do you want to continue with this version?" + ) + else -> Pair("Version Notice", "Continue with v$currentVersion?") + } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(16.dp), + icon = { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = if (versionStatus == VersionStatus.NEWER_VERSION) + MaterialTheme.colorScheme.error + else + Color(0xFFFF9800), + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = title, + fontWeight = FontWeight.SemiBold + ) + }, + text = { + Text( + text = message, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = if (versionStatus == VersionStatus.NEWER_VERSION) + MaterialTheme.colorScheme.error + else + Color(0xFFFF9800) + ) + ) { + Text("Continue Anyway") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun BrandingSection(isCompact: Boolean = false) { + Image( + painter = painterResource(Res.drawable.morphe), + contentDescription = "Morphe Logo", + modifier = Modifier.height(if (isCompact) 48.dp else 60.dp) + ) +} + +@Composable +private fun DropPromptSection( + isDragHovering: Boolean, + isCompact: Boolean = false, + onBrowseClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = if (isCompact) 16.dp else 32.dp) + ) { + Text( + text = if (isDragHovering) "Release to drop" else "Drop your APK here", + fontSize = if (isCompact) 18.sp else 22.sp, + fontWeight = FontWeight.Medium, + color = if (isDragHovering) + MorpheColors.Blue + else + MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) + + Text( + text = "or", + fontSize = if (isCompact) 12.sp else 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) + + OutlinedButton( + onClick = onBrowseClick, + modifier = Modifier.height(if (isCompact) 44.dp else 48.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MorpheColors.Blue + ) + ) { + Text( + "Browse Files", + fontSize = if (isCompact) 14.sp else 16.sp, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) + + Text( + text = "Supported: .apk files from APKMirror", + fontSize = if (isCompact) 11.sp else 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } +} + +@Composable +private fun SupportedAppsSection( + isCompact: Boolean = false, + maxWidth: Dp = 800.dp, + isLoading: Boolean = false, + supportedApps: List = emptyList(), + loadError: String? = null, + onRetry: () -> Unit = {} +) { + // Stack vertically if very narrow + val useVerticalLayout = maxWidth < 400.dp + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "SUPPORTED APPS", + fontSize = if (isCompact) 11.sp else 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 2.sp + ) + + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) + + // Important notice about APK handling + Text( + text = "Download the exact version from APKMirror and drop it here directly. Do not rename or modify the file.", + fontSize = if (isCompact) 10.sp else 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier + .widthIn(max = if (useVerticalLayout) 280.dp else 500.dp) + .padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) + + when { + isLoading -> { + // Loading state + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MorpheColors.Blue, + strokeWidth = 3.dp + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Loading patches...", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + loadError != null -> { + // Error state + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Could not load supported apps", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = loadError, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onRetry, + shape = RoundedCornerShape(8.dp) + ) { + Text("Retry") + } + } + } + supportedApps.isEmpty() -> { + // Empty state (shouldn't happen normally) + Text( + text = "No supported apps found", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + else -> { + // Display supported apps dynamically + if (useVerticalLayout) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(horizontal = 16.dp) + .widthIn(max = 300.dp) + ) { + supportedApps.forEach { app -> + SupportedAppCardDynamic( + supportedApp = app, + isCompact = isCompact, + modifier = Modifier.fillMaxWidth() + ) + } + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 16.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + .widthIn(max = 600.dp) + ) { + supportedApps.forEach { app -> + SupportedAppCardDynamic( + supportedApp = app, + isCompact = isCompact, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } + } +} + +/** + * Card showing current patches version with option to change. + */ +@Composable +private fun PatchesVersionCard( + patchesVersion: String, + isLatest: Boolean, + onChangePatchesClick: () -> Unit, + isCompact: Boolean = false, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onChangePatchesClick), + colors = CardDefaults.cardColors( + containerColor = MorpheColors.Blue.copy(alpha = 0.1f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = if (isCompact) 10.dp else 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Using patches", + fontSize = if (isCompact) 12.sp else 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Surface( + color = MorpheColors.Blue.copy(alpha = 0.2f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = patchesVersion, + fontSize = if (isCompact) 11.sp else 12.sp, + fontWeight = FontWeight.SemiBold, + color = MorpheColors.Blue, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + if (isLatest) { + Spacer(modifier = Modifier.width(6.dp)) + Surface( + color = MorpheColors.Teal.copy(alpha = 0.2f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "Latest", + fontSize = if (isCompact) 9.sp else 10.sp, + color = MorpheColors.Teal, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } +} + +/** + * Dynamic supported app card that uses SupportedApp data from patches. + */ +@Composable +private fun SupportedAppCardDynamic( + supportedApp: SupportedApp, + isCompact: Boolean = false, + modifier: Modifier = Modifier +) { + var showAllVersions by remember { mutableStateOf(false) } + + val cardPadding = if (isCompact) 12.dp else 16.dp + val iconSize = if (isCompact) 48.dp else 56.dp + val iconInnerSize = if (isCompact) 32.dp else 40.dp + + // Get icon resource based on package name + val iconRes = when (supportedApp.packageName) { + AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube + AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music + AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit + else -> null + } + + // Get APKMirror URL from AppConstants (still hardcoded) + val apkMirrorUrl = when (supportedApp.packageName) { + AppConstants.YouTube.PACKAGE_NAME -> AppConstants.YouTube.APK_MIRROR_URL + AppConstants.YouTubeMusic.PACKAGE_NAME -> AppConstants.YouTubeMusic.APK_MIRROR_URL + AppConstants.Reddit.PACKAGE_NAME -> AppConstants.Reddit.APK_MIRROR_URL + else -> null + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(if (isCompact) 12.dp else 16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(cardPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // App icon + Box( + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(if (isCompact) 10.dp else 12.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + if (iconRes != null) { + Image( + painter = painterResource(iconRes), + contentDescription = "${supportedApp.displayName} icon", + modifier = Modifier.size(iconInnerSize) + ) + } else { + // Fallback: show first letter of app name + Text( + text = supportedApp.displayName.first().toString(), + fontSize = if (isCompact) 20.sp else 24.sp, + fontWeight = FontWeight.Bold, + color = MorpheColors.Blue + ) + } + } + + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) + + // App name + Text( + text = supportedApp.displayName, + fontSize = if (isCompact) 14.sp else 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) + + // Recommended version badge (dynamic from patches) + if (supportedApp.recommendedVersion != null) { + val cornerRadius = if (isCompact) 6.dp else 8.dp + Surface( + color = MorpheColors.Teal.copy(alpha = 0.15f), + shape = RoundedCornerShape(cornerRadius), + modifier = Modifier + .clip(RoundedCornerShape(cornerRadius)) + .clickable { showAllVersions = !showAllVersions } + ) { + Column( + modifier = Modifier.padding( + horizontal = if (isCompact) 10.dp else 12.dp, + vertical = if (isCompact) 6.dp else 8.dp + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Recommended", + fontSize = if (isCompact) 9.sp else 10.sp, + color = MorpheColors.Teal.copy(alpha = 0.8f), + letterSpacing = 0.5.sp + ) + Text( + text = "v${supportedApp.recommendedVersion}", + fontSize = if (isCompact) 12.sp else 14.sp, + fontWeight = FontWeight.SemiBold, + color = MorpheColors.Teal + ) + // Show version count if more than 1 (excluding recommended) + val otherVersionsCount = supportedApp.supportedVersions.count { it != supportedApp.recommendedVersion } + if (otherVersionsCount > 0) { + Text( + text = if (showAllVersions) "▲ Hide versions" else "▼ +$otherVersionsCount more", + fontSize = if (isCompact) 9.sp else 10.sp, + color = MorpheColors.Teal.copy(alpha = 0.6f) + ) + } + } + } + + // Expandable versions list (excluding recommended version) + val otherVersions = supportedApp.supportedVersions.filter { it != supportedApp.recommendedVersion } + if (showAllVersions && otherVersions.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = RoundedCornerShape(6.dp) + ) { + Column( + modifier = Modifier.padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Other supported versions:", + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + // Show versions in a compact grid-like format + val versionsText = otherVersions.joinToString(", ") { "v$it" } + Text( + text = versionsText, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + lineHeight = 14.sp + ) + } + } + } + } else { + // No specific version recommended + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp) + ) { + Text( + text = "Any version", + fontSize = if (isCompact) 11.sp else 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + horizontal = if (isCompact) 10.dp else 12.dp, + vertical = if (isCompact) 6.dp else 8.dp + ) + ) + } + } + + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) + + // Download from APKMirror button (only if URL is configured) + if (apkMirrorUrl != null) { + OutlinedButton( + onClick = { + try { + java.awt.Desktop.getDesktop().browse(java.net.URI(apkMirrorUrl)) + } catch (e: Exception) { + // Ignore errors + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp), + contentPadding = PaddingValues( + horizontal = if (isCompact) 8.dp else 12.dp, + vertical = if (isCompact) 6.dp else 8.dp + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MorpheColors.Blue + ) + ) { + Text( + text = if (isCompact) "APKMirror" else "Get from APKMirror", + fontSize = if (isCompact) 11.sp else 12.sp, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) + } + + // Package name + Text( + text = supportedApp.packageName, + fontSize = if (isCompact) 9.sp else 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } +} + +@Composable +private fun SupportedAppCard( + appType: AppType, + iconRes: org.jetbrains.compose.resources.DrawableResource, + isCompact: Boolean = false, + modifier: Modifier = Modifier +) { + val cardPadding = if (isCompact) 12.dp else 16.dp + val iconSize = if (isCompact) 48.dp else 56.dp + val iconInnerSize = if (isCompact) 32.dp else 40.dp + + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(if (isCompact) 12.dp else 16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(cardPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // App icon + Box( + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(if (isCompact) 10.dp else 12.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(iconRes), + contentDescription = "${appType.displayName} icon", + modifier = Modifier.size(iconInnerSize) + ) + } + + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) + + // App name + Text( + text = appType.displayName, + fontSize = if (isCompact) 14.sp else 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) + + // Suggested version badge + Surface( + color = MorpheColors.Teal.copy(alpha = 0.15f), + shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp) + ) { + Column( + modifier = Modifier.padding( + horizontal = if (isCompact) 10.dp else 12.dp, + vertical = if (isCompact) 6.dp else 8.dp + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Recommended", + fontSize = if (isCompact) 9.sp else 10.sp, + color = MorpheColors.Teal.copy(alpha = 0.8f), + letterSpacing = 0.5.sp + ) + Text( + text = "v${appType.suggestedVersion}", + fontSize = if (isCompact) 12.sp else 14.sp, + fontWeight = FontWeight.SemiBold, + color = MorpheColors.Teal + ) + } + } + + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) + + // Download from APKMirror button + OutlinedButton( + onClick = { + try { + java.awt.Desktop.getDesktop().browse(java.net.URI(appType.apkMirrorUrl)) + } catch (e: Exception) { + // Ignore errors + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp), + contentPadding = PaddingValues( + horizontal = if (isCompact) 8.dp else 12.dp, + vertical = if (isCompact) 6.dp else 8.dp + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MorpheColors.Blue + ) + ) { + Text( + text = if (isCompact) "APKMirror" else "Get from APKMirror", + fontSize = if (isCompact) 11.sp else 12.sp, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) + + // Package name + Text( + text = appType.packageName, + fontSize = if (isCompact) 9.sp else 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } +} + +@Composable +private fun DragOverlay() { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf( + MorpheColors.Blue.copy(alpha = 0.15f), + MorpheColors.Blue.copy(alpha = 0.05f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier.padding(32.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier.padding(48.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Drop APK here", + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = MorpheColors.Blue + ) + } + } + } +} + +private fun openFilePicker(): File? { + val fileDialog = FileDialog(null as Frame?, "Select APK File", FileDialog.LOAD).apply { + isMultipleMode = false + setFilenameFilter { _, name -> name.lowercase().endsWith(".apk") } + isVisible = true + } + + val directory = fileDialog.directory + val file = fileDialog.file + + return if (directory != null && file != null) { + File(directory, file) + } else { + null + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt new file mode 100644 index 0000000..1a622a8 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,519 @@ +package app.morphe.gui.ui.screens.home + +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.SupportedApp +import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.gui.data.repository.PatchRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.dongliu.apk.parser.ApkFile +import app.morphe.gui.util.FileUtils +import app.morphe.gui.util.Logger +import app.morphe.gui.util.PatchService +import app.morphe.gui.util.SupportedAppExtractor +import java.io.File + +class HomeViewModel( + private val patchRepository: PatchRepository, + private val patchService: PatchService, + private val configRepository: ConfigRepository +) : ScreenModel { + + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Cached patches and supported apps + private var cachedPatches: List = emptyList() + private var cachedPatchesFile: File? = null + + init { + // Auto-fetch patches on startup + loadPatchesAndSupportedApps() + } + + // Track the last loaded version to avoid reloading unnecessarily + private var lastLoadedVersion: String? = null + + /** + * Load patches from GitHub and extract supported apps. + * If a saved version exists in config, load that version instead of latest. + */ + private fun loadPatchesAndSupportedApps(forceRefresh: Boolean = false) { + screenModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingPatches = true, patchLoadError = null) + + try { + // Check if there's a saved patches version in config + val config = configRepository.loadConfig() + val savedVersion = config.lastPatchesVersion + + // 1. Fetch all releases to find the right one + val releasesResult = patchRepository.fetchReleases() + val releases = releasesResult.getOrNull() + + if (releases.isNullOrEmpty()) { + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = "Could not fetch patches: ${releasesResult.exceptionOrNull()?.message}" + ) + return@launch + } + + // Find the latest stable release for reference + val latestStable = releases.firstOrNull { !it.isDevRelease() } + val latestVersion = latestStable?.tagName + + // 2. Find the release to use - prefer saved version, fallback to latest stable + val release = if (savedVersion != null) { + releases.find { it.tagName == savedVersion } + ?: latestStable // Fallback to latest stable + } else { + latestStable // Latest stable + } + + if (release == null) { + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = "No suitable release found" + ) + return@launch + } + + // Skip reload if we've already loaded this version (unless forced) + if (!forceRefresh && lastLoadedVersion == release.tagName && cachedPatchesFile?.exists() == true) { + Logger.info("Skipping reload - already loaded version ${release.tagName}") + _uiState.value = _uiState.value.copy(isLoadingPatches = false) + return@launch + } + + Logger.info("Loading patches version: ${release.tagName} (saved=$savedVersion)") + + // 3. Download patches + val patchFileResult = patchRepository.downloadPatches(release) + val patchFile = patchFileResult.getOrNull() + + if (patchFile == null) { + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = "Could not download patches: ${patchFileResult.exceptionOrNull()?.message}" + ) + return@launch + } + + cachedPatchesFile = patchFile + lastLoadedVersion = release.tagName + + // 3. Load patches using PatchService (direct library call) + val patchesResult = patchService.listPatches(patchFile.absolutePath) + val patches = patchesResult.getOrNull() + + if (patches == null || patches.isEmpty()) { + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = "Could not load patches: ${patchesResult.exceptionOrNull()?.message}" + ) + return@launch + } + + cachedPatches = patches + + // 5. Extract supported apps + val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) + Logger.info("Loaded ${supportedApps.size} supported apps from patches: ${supportedApps.map { "${it.displayName} (${it.recommendedVersion})" }}") + + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + supportedApps = supportedApps, + patchesVersion = release.tagName, + latestPatchesVersion = latestVersion, + patchLoadError = null + ) + } catch (e: Exception) { + Logger.error("Failed to load patches and supported apps", e) + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = e.message ?: "Unknown error" + ) + } + } + } + + /** + * Retry loading patches. + */ + fun retryLoadPatches() { + loadPatchesAndSupportedApps(forceRefresh = true) + } + + /** + * Refresh patches if a different version was selected. + * Called when returning to HomeScreen from PatchesScreen. + */ + fun refreshPatchesIfNeeded() { + screenModelScope.launch { + val config = configRepository.loadConfig() + val savedVersion = config.lastPatchesVersion + + // If saved version differs from currently loaded version, reload + if (savedVersion != null && savedVersion != lastLoadedVersion) { + Logger.info("Patches version changed: $lastLoadedVersion -> $savedVersion, reloading...") + loadPatchesAndSupportedApps(forceRefresh = true) + } + } + } + + /** + * Get the cached patches file path for navigation to next screen. + */ + fun getCachedPatchesFile(): File? = cachedPatchesFile + + /** + * Get recommended version for a package from loaded patches. + */ + fun getRecommendedVersion(packageName: String): String? { + return SupportedAppExtractor.getRecommendedVersion(cachedPatches, packageName) + } + + fun onFileSelected(file: File) { + screenModelScope.launch { + Logger.info("File selected: ${file.absolutePath}") + + _uiState.value = _uiState.value.copy(isAnalyzing = true) + + val validationResult = withContext(Dispatchers.IO) { + validateAndAnalyzeApk(file) + } + + if (validationResult.isValid) { + _uiState.value = _uiState.value.copy( + selectedApk = file, + apkInfo = validationResult.apkInfo, + error = null, + isReady = true, + isAnalyzing = false + ) + Logger.info("APK analyzed successfully: ${validationResult.apkInfo?.appName ?: file.name}") + } else { + _uiState.value = _uiState.value.copy( + selectedApk = null, + apkInfo = null, + error = validationResult.errorMessage, + isReady = false, + isAnalyzing = false + ) + Logger.warn("APK validation failed: ${validationResult.errorMessage}") + } + } + } + + fun onFilesDropped(files: List) { + val apkFile = files.firstOrNull { FileUtils.isApkFile(it) } + if (apkFile != null) { + onFileSelected(apkFile) + } else { + _uiState.value = _uiState.value.copy( + error = "No valid APK file found. Please drop an .apk file.", + isReady = false + ) + } + } + + fun clearSelection() { + // Preserve loaded patches state when clearing APK selection + _uiState.value = _uiState.value.copy( + selectedApk = null, + apkInfo = null, + error = null, + isDragHovering = false, + isReady = false, + isAnalyzing = false + ) + Logger.info("APK selection cleared") + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + fun setDragHover(isHovering: Boolean) { + _uiState.value = _uiState.value.copy(isDragHovering = isHovering) + } + + private fun validateAndAnalyzeApk(file: File): ApkValidationResult { + if (!file.exists()) { + return ApkValidationResult(false, errorMessage = "File does not exist") + } + + if (!file.isFile) { + return ApkValidationResult(false, errorMessage = "Selected item is not a file") + } + + if (!FileUtils.isApkFile(file)) { + return ApkValidationResult(false, errorMessage = "File must have .apk extension") + } + + if (file.length() < 1024) { + return ApkValidationResult(false, errorMessage = "File is too small to be a valid APK") + } + + // Parse APK info from AndroidManifest.xml using apk-parser + val apkInfo = parseApkManifest(file) + + return if (apkInfo != null) { + ApkValidationResult(true, apkInfo = apkInfo) + } else { + ApkValidationResult(false, errorMessage = "Could not parse APK. The file may be corrupted or not a valid APK.") + } + } + + /** + * Parse APK metadata directly from AndroidManifest.xml using apk-parser library. + * This works with APKs from any source, not just APKMirror. + */ + private fun parseApkManifest(file: File): ApkInfo? { + return try { + ApkFile(file).use { apk -> + val meta = apk.apkMeta + + val packageName = meta.packageName + val versionName = meta.versionName ?: "Unknown" + val minSdk = meta.minSdkVersion?.toIntOrNull() + + // Check if package is supported - first check dynamic, then fallback to hardcoded + val dynamicSupportedApp = _uiState.value.supportedApps.find { it.packageName == packageName } + val isSupported = dynamicSupportedApp != null || + packageName in listOf( + app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME, + app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME + ) + + if (!isSupported) { + Logger.warn("Unsupported package: $packageName") + return null + } + + // Get app display name - prefer dynamic, fallback to hardcoded + val appName = dynamicSupportedApp?.displayName + ?: SupportedApp.getDisplayName(packageName) + + // Get recommended version - prefer dynamic, fallback to hardcoded + val suggestedVersion = dynamicSupportedApp?.recommendedVersion + ?: app.morphe.gui.data.constants.AppConstants.getSuggestedVersion(packageName) + + // Determine AppType for backward compatibility (still used in some places) + val appType = when (packageName) { + app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME -> AppType.YOUTUBE + app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME -> AppType.YOUTUBE_MUSIC + else -> null + } + + // Compare versions if we have a suggested version + val versionStatus = if (suggestedVersion != null) { + compareVersions(versionName, suggestedVersion) + } else { + VersionStatus.UNKNOWN + } + + // Get supported architectures from native libraries in the APK + val architectures = extractArchitectures(file) + + // Verify checksum (still uses AppConstants for now) + val checksumStatus = verifyChecksum(file, packageName, versionName, architectures, suggestedVersion) + + Logger.info("Parsed APK: $packageName v$versionName (recommended=$suggestedVersion, minSdk=$minSdk, archs=$architectures)") + + ApkInfo( + fileName = file.name, + filePath = file.absolutePath, + fileSize = file.length(), + formattedSize = formatFileSize(file.length()), + appName = appName, + appType = appType, + packageName = packageName, + versionName = versionName, + architectures = architectures, + minSdk = minSdk, + suggestedVersion = suggestedVersion, + versionStatus = versionStatus, + checksumStatus = checksumStatus + ) + } + } catch (e: Exception) { + Logger.error("Failed to parse APK manifest", e) + null + } + } + + /** + * Extract supported CPU architectures from native libraries in the APK. + * Uses ZipFile to scan for lib// directories. + */ + private fun extractArchitectures(file: File): List { + return try { + java.util.zip.ZipFile(file).use { zip -> + val archDirs = zip.entries().asSequence() + .map { it.name } + .filter { it.startsWith("lib/") } + .mapNotNull { path -> + val parts = path.split("/") + if (parts.size >= 2) parts[1] else null + } + .distinct() + .toList() + + archDirs.ifEmpty { + // No native libs - likely a universal APK + listOf("universal") + } + } + } catch (e: Exception) { + Logger.warn("Could not extract architectures: ${e.message}") + emptyList() + } + } + + /** + * Verify the APK checksum against expected values. + */ + private fun verifyChecksum( + file: File, + packageName: String, + version: String, + architectures: List, + recommendedVersion: String? + ): app.morphe.gui.util.ChecksumStatus { + // Check if this is a non-recommended version (use dynamic recommended version) + if (recommendedVersion != null && version != recommendedVersion) { + return app.morphe.gui.util.ChecksumStatus.NonRecommendedVersion + } + + // Get expected checksum (still from AppConstants - checksums are manually maintained) + val expectedChecksum = app.morphe.gui.data.constants.AppConstants.getChecksum(packageName, version, architectures) + if (expectedChecksum == null) { + return app.morphe.gui.util.ChecksumStatus.NotConfigured + } + + // Calculate actual checksum + return try { + val actualChecksum = app.morphe.gui.util.ChecksumUtils.calculateSha256(file) + Logger.info("Checksum verification - Expected: $expectedChecksum, Actual: $actualChecksum") + + if (actualChecksum.equals(expectedChecksum, ignoreCase = true)) { + app.morphe.gui.util.ChecksumStatus.Verified + } else { + app.morphe.gui.util.ChecksumStatus.Mismatch(expectedChecksum, actualChecksum) + } + } catch (e: Exception) { + Logger.error("Checksum calculation failed", e) + app.morphe.gui.util.ChecksumStatus.Error(e.message ?: "Unknown error") + } + } + + private fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) + } + } + + /** + * Compares two version strings (e.g., "19.16.39" vs "20.40.45") + * Returns the version status of the current version relative to suggested. + */ + private fun compareVersions(current: String, suggested: String): VersionStatus { + return try { + val currentParts = current.split(".").map { it.toInt() } + val suggestedParts = suggested.split(".").map { it.toInt() } + + // Compare each part + for (i in 0 until maxOf(currentParts.size, suggestedParts.size)) { + val currentPart = currentParts.getOrElse(i) { 0 } + val suggestedPart = suggestedParts.getOrElse(i) { 0 } + + when { + currentPart > suggestedPart -> return VersionStatus.NEWER_VERSION + currentPart < suggestedPart -> return VersionStatus.OLDER_VERSION + } + } + VersionStatus.EXACT_MATCH + } catch (e: Exception) { + Logger.warn("Failed to compare versions: $current vs $suggested") + VersionStatus.UNKNOWN + } + } +} + +data class HomeUiState( + val selectedApk: File? = null, + val apkInfo: ApkInfo? = null, + val error: String? = null, + val isDragHovering: Boolean = false, + val isReady: Boolean = false, + val isAnalyzing: Boolean = false, + // Dynamic patches data + val isLoadingPatches: Boolean = true, + val supportedApps: List = emptyList(), + val patchesVersion: String? = null, + val latestPatchesVersion: String? = null, // Track the latest available version + val patchLoadError: String? = null +) { + val isUsingLatestPatches: Boolean + get() = patchesVersion != null && patchesVersion == latestPatchesVersion +} + +enum class AppType( + val displayName: String, + val packageName: String, + val suggestedVersion: String, + val apkMirrorUrl: String +) { + YOUTUBE( + displayName = app.morphe.gui.data.constants.AppConstants.YouTube.DISPLAY_NAME, + packageName = app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME, + suggestedVersion = app.morphe.gui.data.constants.AppConstants.YouTube.SUGGESTED_VERSION, + apkMirrorUrl = app.morphe.gui.data.constants.AppConstants.YouTube.APK_MIRROR_URL + ), + YOUTUBE_MUSIC( + displayName = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.DISPLAY_NAME, + packageName = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME, + suggestedVersion = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.SUGGESTED_VERSION, + apkMirrorUrl = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.APK_MIRROR_URL + ) +} + +data class ApkInfo( + val fileName: String, + val filePath: String, + val fileSize: Long, + val formattedSize: String, + val appName: String, + val appType: AppType?, // Nullable for dynamically supported apps not in the enum + val packageName: String, + val versionName: String, + val architectures: List = emptyList(), + val minSdk: Int? = null, + val suggestedVersion: String? = null, + val versionStatus: VersionStatus = VersionStatus.UNKNOWN, + val checksumStatus: app.morphe.gui.util.ChecksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured +) + +enum class VersionStatus { + EXACT_MATCH, // Using the suggested version + OLDER_VERSION, // Using an older version (newer patches available) + NEWER_VERSION, // Using a newer version (might have issues) + UNKNOWN // Could not determine +} + +data class ApkValidationResult( + val isValid: Boolean, + val apkInfo: ApkInfo? = null, + val errorMessage: String? = null +) 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 new file mode 100644 index 0000000..13f7c01 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkDropZone.kt @@ -0,0 +1,212 @@ +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/home/components/ApkInfoCard.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt new file mode 100644 index 0000000..cf4202e --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt @@ -0,0 +1,414 @@ +package app.morphe.gui.ui.screens.home.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.morphe_cli.generated.resources.Res +import app.morphe.morphe_cli.generated.resources.reddit +import app.morphe.morphe_cli.generated.resources.youtube +import app.morphe.morphe_cli.generated.resources.youtube_music +import org.jetbrains.compose.resources.painterResource +import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.ui.screens.home.ApkInfo +import app.morphe.gui.ui.screens.home.AppType +import app.morphe.gui.ui.screens.home.VersionStatus +import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.ChecksumStatus + +@Composable +fun ApkInfoCard( + apkInfo: ApkInfo, + onClearClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + // Header with app icon and close button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + // App icon - determine from appType or packageName + val iconRes = when { + apkInfo.appType == AppType.YOUTUBE -> Res.drawable.youtube + apkInfo.appType == AppType.YOUTUBE_MUSIC -> Res.drawable.youtube_music + apkInfo.packageName == AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube + apkInfo.packageName == AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music + apkInfo.packageName == AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit + else -> null + } + + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + if (iconRes != null) { + Image( + painter = painterResource(iconRes), + contentDescription = "${apkInfo.appName} icon", + modifier = Modifier.size(48.dp) + ) + } else { + // Fallback: show first letter of app name + Text( + text = apkInfo.appName.first().toString(), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MorpheColors.Blue + ) + } + } + + Column { + // App name + Text( + text = apkInfo.appName, + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(2.dp)) + + // Version + Text( + text = "v${apkInfo.versionName}", + fontSize = 15.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Close button + IconButton( + onClick = onClearClick, + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Info grid + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Size + InfoColumn( + label = "Size", + value = apkInfo.formattedSize, + modifier = Modifier.weight(1f) + ) + + // Architecture + InfoColumn( + label = "Architecture", + value = formatArchitectures(apkInfo.architectures), + modifier = Modifier.weight(1f) + ) + + // Min SDK + if (apkInfo.minSdk != null) { + InfoColumn( + label = "Min SDK", + value = "API ${apkInfo.minSdk}", + modifier = Modifier.weight(1f) + ) + } + } + + // Version and checksum status section + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Version status + if (apkInfo.suggestedVersion != null && apkInfo.versionStatus != VersionStatus.EXACT_MATCH) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + VersionStatusBanner( + versionStatus = apkInfo.versionStatus, + currentVersion = apkInfo.versionName, + suggestedVersion = apkInfo.suggestedVersion + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Checksum warning for non-recommended versions + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Checksum verification unavailable for non-recommended versions", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + textAlign = TextAlign.Center + ) + } + } else if (apkInfo.versionStatus == VersionStatus.EXACT_MATCH) { + // Show checksum status for recommended version + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + ChecksumStatusBanner(checksumStatus = apkInfo.checksumStatus) + } + } + } + } +} + +@Composable +private fun ChecksumStatusBanner(checksumStatus: ChecksumStatus) { + when (checksumStatus) { + is ChecksumStatus.Verified -> { + Surface( + color = MorpheColors.Teal.copy(alpha = 0.15f), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Recommended version - Verified", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MorpheColors.Teal + ) + Text( + text = "Checksum matches APKMirror", + fontSize = 10.sp, + color = MorpheColors.Teal.copy(alpha = 0.8f) + ) + } + } + } + + is ChecksumStatus.Mismatch -> { + Surface( + color = MaterialTheme.colorScheme.error.copy(alpha = 0.15f), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Checksum Mismatch", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error + ) + Text( + text = "File may be corrupted or modified. Re-download from APKMirror.", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + } + } + } + + is ChecksumStatus.NotConfigured -> { + Surface( + color = MorpheColors.Teal.copy(alpha = 0.15f), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Using recommended version", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MorpheColors.Teal, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + + is ChecksumStatus.Error -> { + Surface( + color = Color(0xFFFF9800).copy(alpha = 0.15f), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Using recommended version", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFFFF9800) + ) + Text( + text = "Could not verify checksum", + fontSize = 10.sp, + color = Color(0xFFFF9800).copy(alpha = 0.8f) + ) + } + } + } + + is ChecksumStatus.NonRecommendedVersion -> { + // This shouldn't happen in this branch, but handle it gracefully + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Using non-recommended version", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + } +} + +@Composable +private fun InfoColumn( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.Start + ) { + Text( + text = label, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun VersionStatusBanner( + versionStatus: VersionStatus, + currentVersion: String, + suggestedVersion: String +) { + val (backgroundColor, textColor, message) = when (versionStatus) { + VersionStatus.OLDER_VERSION -> Triple( + Color(0xFFFF9800).copy(alpha = 0.15f), + Color(0xFFFF9800), + "Newer patches available for v$suggestedVersion" + ) + VersionStatus.NEWER_VERSION -> Triple( + MaterialTheme.colorScheme.error.copy(alpha = 0.15f), + MaterialTheme.colorScheme.error, + "Version too new. Recommended: v$suggestedVersion" + ) + else -> Triple( + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurfaceVariant, + "Suggested version: v$suggestedVersion" + ) + } + + Surface( + color = backgroundColor, + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = message, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = textColor, + textAlign = TextAlign.Center + ) + if (versionStatus == VersionStatus.NEWER_VERSION) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Patching may not work correctly with newer versions", + fontSize = 11.sp, + color = textColor.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + } + } + } +} + +private fun formatArchitectures(archs: List): String { + if (archs.isEmpty()) return "Unknown" + + // Show full architecture names for clarity + val formatted = archs.map { arch -> + when (arch) { + "arm64-v8a" -> "arm64-v8a" + "armeabi-v7a" -> "armeabi-v7a" + "x86_64" -> "x86_64" + "x86" -> "x86" + else -> arch + } + } + + return formatted.joinToString(", ") +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt new file mode 100644 index 0000000..8db0374 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt @@ -0,0 +1,74 @@ +package app.morphe.gui.ui.screens.home.components + +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.awtTransferable +import java.awt.datatransfer.DataFlavor +import java.io.File + +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +@Composable +fun FullScreenDropZone( + isDragHovering: Boolean, + onDragHoverChange: (Boolean) -> Unit, + onFilesDropped: (List) -> Unit, + content: @Composable () -> Unit +) { + 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 + .fillMaxSize() + .dragAndDropTarget( + shouldStartDragAndDrop = { true }, + target = dragAndDropTarget + ) + ) { + content() + } +} 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 new file mode 100644 index 0000000..0ccab99 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -0,0 +1,707 @@ +package app.morphe.gui.ui.screens.patches + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import app.morphe.gui.data.model.Patch +import org.koin.core.parameter.parametersOf +import app.morphe.gui.ui.components.ErrorDialog +import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.getErrorType +import app.morphe.gui.ui.components.getFriendlyErrorMessage +import app.morphe.gui.ui.screens.patching.PatchingScreen +import app.morphe.gui.ui.theme.MorpheColors +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +/** + * Screen for selecting which patches to apply. + */ +data class PatchSelectionScreen( + val apkPath: String, + val apkName: String, + val patchesFilePath: String +) : Screen { + + @Composable + override fun Content() { + val viewModel = koinScreenModel { + parametersOf(apkPath, apkName, patchesFilePath) + } + PatchSelectionScreenContent(viewModel = viewModel) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { + val navigator = LocalNavigator.currentOrThrow + val uiState by viewModel.uiState.collectAsState() + + var showErrorDialog by remember { mutableStateOf(false) } + var currentError by remember { mutableStateOf(null) } + + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + currentError = error + showErrorDialog = true + } + } + + // Error dialog + if (showErrorDialog && currentError != null) { + ErrorDialog( + title = "Error Loading Patches", + message = getFriendlyErrorMessage(currentError!!), + errorType = getErrorType(currentError!!), + onDismiss = { + showErrorDialog = false + viewModel.clearError() + }, + onRetry = { + showErrorDialog = false + viewModel.clearError() + viewModel.loadPatches() + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("Select Patches", fontWeight = FontWeight.SemiBold) + Text( + text = "${uiState.selectedCount} of ${uiState.totalCount} selected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = { navigator.pop() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + // Select all / Deselect all + TextButton(onClick = { + if (uiState.selectedPatches.size == uiState.allPatches.size) { + viewModel.deselectAll() + } else { + viewModel.selectAll() + } + }) { + Text( + if (uiState.selectedPatches.size == uiState.allPatches.size) "Deselect All" else "Select All", + color = MorpheColors.Blue + ) + } + SettingsButton(allowCacheClear = false) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + ) { paddingValues -> + // State for command preview + var cleanMode by remember { mutableStateOf(false) } + var isCollapsed by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Command preview at the top - updates in real-time + if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { + val commandPreview = remember(uiState.selectedPatches, cleanMode) { + viewModel.getCommandPreview(cleanMode) + } + CommandPreview( + command = commandPreview, + cleanMode = cleanMode, + isCollapsed = isCollapsed, + onToggleMode = { cleanMode = !cleanMode }, + onToggleCollapse = { isCollapsed = !isCollapsed }, + onCopy = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(commandPreview), null) + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + // Search bar + SearchBar( + query = uiState.searchQuery, + onQueryChange = { viewModel.setSearchQuery(it) }, + showOnlySelected = uiState.showOnlySelected, + onShowOnlySelectedChange = { viewModel.setShowOnlySelected(it) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // Commonly disabled patches suggestion + val commonlyDisabledPatches = remember(uiState.selectedPatches, uiState.allPatches) { + viewModel.getCommonlyDisabledPatches() + } + var suggestionDismissed by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = commonlyDisabledPatches.isNotEmpty() && !suggestionDismissed && !uiState.isLoading, + enter = expandVertically(), + exit = shrinkVertically() + ) { + CommonlyDisabledSuggestion( + patches = commonlyDisabledPatches, + onDeselectAll = { viewModel.deselectCommonlyDisabled() }, + onDismiss = { suggestionDismissed = true }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator(color = MorpheColors.Blue) + Text( + text = "Loading patches...", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + uiState.filteredPatches.isEmpty() && !uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = if (uiState.searchQuery.isNotBlank()) "No patches match your search" else "No patches found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + else -> { + // Patch list + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = uiState.filteredPatches, + key = { it.uniqueId } + ) { patch -> + PatchListItem( + patch = patch, + isSelected = uiState.selectedPatches.contains(patch.uniqueId), + onToggle = { viewModel.togglePatch(patch.uniqueId) } + ) + } + } + + // Bottom action bar + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { + val config = viewModel.createPatchConfig() + navigator.push(PatchingScreen(config)) + }, + enabled = uiState.selectedPatches.isNotEmpty(), + modifier = Modifier + .weight(1f) + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Blue + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "Patch (${uiState.selectedCount})", + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + } + } +} + +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + showOnlySelected: Boolean, + onShowOnlySelectedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.weight(1f), + placeholder = { Text("Search patches...") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MorpheColors.Blue, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) + + FilterChip( + selected = showOnlySelected, + onClick = { onShowOnlySelectedChange(!showOnlySelected) }, + label = { Text("Selected") }, + leadingIcon = if (showOnlySelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } else null + ) + } +} + +@Composable +private fun PatchListItem( + patch: Patch, + isSelected: Boolean, + onToggle: () -> Unit +) { + val backgroundColor = if (isSelected) { + MorpheColors.Blue.copy(alpha = 0.1f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = isSelected, + onCheckedChange = { onToggle() }, + colors = CheckboxDefaults.colors( + checkedColor = MorpheColors.Blue, + uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = patch.name, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + + if (patch.description.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = patch.description, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Show compatible packages if any + if (patch.compatiblePackages.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + patch.compatiblePackages.take(2).forEach { pkg -> + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = pkg.name.substringAfterLast("."), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } + + // Show options indicator if patch has options + if (patch.options.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${patch.options.size} option${if (patch.options.size > 1) "s" else ""} available", + fontSize = 11.sp, + color = MorpheColors.Teal + ) + } + } + } + } +} + +@Composable +private fun CommonlyDisabledSuggestion( + patches: List>, + onDeselectAll: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = Color(0xFFFF9800).copy(alpha = 0.1f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = Color(0xFFFF9800), + modifier = Modifier.size(18.dp) + ) + Text( + text = "Commonly disabled patches", + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + color = Color(0xFFFF9800) + ) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "These ${patches.size} patch${if (patches.size > 1) "es are" else " is"} commonly disabled by users:", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(6.dp)) + + // List patch names + patches.take(4).forEach { (patch, _) -> + Text( + text = "• ${patch.name}", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (patches.size > 4) { + Text( + text = "• +${patches.size - 4} more", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Text("Keep all", fontSize = 12.sp) + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + onDeselectAll() + onDismiss() + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF9800) + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(8.dp) + ) { + Text("Deselect these", fontSize = 12.sp) + } + } + } + } +} + +/** + * Terminal-style command preview showing the CLI command that will be executed. + */ +@Composable +private fun CommandPreview( + command: String, + cleanMode: Boolean, + isCollapsed: Boolean, + onToggleMode: () -> Unit, + onToggleCollapse: () -> Unit, + onCopy: () -> Unit, + modifier: Modifier = Modifier +) { + val terminalBackground = Color(0xFF1E1E1E) + val terminalGreen = Color(0xFF4EC9B0) + val terminalText = Color(0xFFD4D4D4) + val terminalDim = Color(0xFF6A9955) + + var showCopied by remember { mutableStateOf(false) } + + // Reset "Copied!" message after a delay + LaunchedEffect(showCopied) { + if (showCopied) { + kotlinx.coroutines.delay(1500) + showCopied = false + } + } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = terminalBackground), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + // Header with terminal icon, controls, and collapse toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Left side - icon, title, and collapse toggle + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onToggleCollapse) + .padding(end = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + tint = terminalGreen, + modifier = Modifier.size(14.dp) + ) + Text( + text = "Command Preview", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = terminalGreen + ) + Icon( + imageVector = if (isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess, + contentDescription = if (isCollapsed) "Expand" else "Collapse", + tint = terminalDim, + modifier = Modifier.size(16.dp) + ) + } + + // Right side - controls + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Copy button + Surface( + onClick = { + onCopy() + showCopied = true + }, + color = Color.Transparent, + shape = RoundedCornerShape(4.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = if (showCopied) terminalGreen else terminalDim, + modifier = Modifier.size(12.dp) + ) + Text( + text = if (showCopied) "Copied!" else "Copy", + fontSize = 10.sp, + color = if (showCopied) terminalGreen else terminalDim + ) + } + } + + // Mode toggle (only show when not collapsed) + if (!isCollapsed) { + Surface( + onClick = onToggleMode, + color = Color.Transparent, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = if (cleanMode) "compact" else "expand", + fontSize = 10.sp, + color = terminalDim, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } + + // Command text - collapsible, vertically scrollable + AnimatedVisibility( + visible = !isCollapsed, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + + // Vertically scrollable command text with max height + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 120.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = command, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = terminalText, + lineHeight = 16.sp + ) + } + } + } + } + } +} 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 new file mode 100644 index 0000000..3136327 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -0,0 +1,325 @@ +package app.morphe.gui.ui.screens.patches + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.data.model.Patch +import app.morphe.gui.data.model.PatchConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import app.morphe.gui.util.Logger +import app.morphe.gui.util.PatchService +import app.morphe.gui.data.repository.PatchRepository +import java.io.File + +class PatchSelectionViewModel( + private val apkPath: String, + private val apkName: String, + private val patchesFilePath: String, + private val patchService: PatchService, + private val patchRepository: PatchRepository +) : ScreenModel { + + // Actual path to use - may differ from patchesFilePath if we had to re-download + private var actualPatchesFilePath: String = patchesFilePath + + private val _uiState = MutableStateFlow(PatchSelectionUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadPatches() + } + + fun getApkPath(): String = apkPath + fun getPatchesFilePath(): String = actualPatchesFilePath + + fun loadPatches() { + screenModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + // First, ensure the patches file exists - download if missing + val patchesFile = File(patchesFilePath) + if (!patchesFile.exists()) { + Logger.info("Patches file not found at $patchesFilePath, attempting to download...") + + // Try to extract version from the filename and find a matching release + // Filename format: morphe-patches-x.x.x.mpp or similar + val downloadResult = downloadMissingPatches(patchesFile.name) + if (downloadResult.isFailure) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Patches file missing and could not be downloaded: ${downloadResult.exceptionOrNull()?.message}" + ) + return@launch + } + actualPatchesFilePath = downloadResult.getOrNull()!!.absolutePath + } + + val packageName = getPackageNameFromApk() + + // Load patches using PatchService (direct library call) + val patchesResult = patchService.listPatches(actualPatchesFilePath, packageName) + + patchesResult.fold( + onSuccess = { patches -> + // Deduplicate by uniqueId in case of true duplicates + val deduplicatedPatches = patches.distinctBy { it.uniqueId } + + Logger.info("Loaded ${deduplicatedPatches.size} patches for $packageName") + + _uiState.value = _uiState.value.copy( + isLoading = false, + allPatches = deduplicatedPatches, + filteredPatches = deduplicatedPatches, + selectedPatches = deduplicatedPatches.map { it.uniqueId }.toSet() + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Failed to list patches: ${e.message}" + ) + Logger.error("Failed to list patches", e) + } + ) + } + } + + fun togglePatch(patchId: String) { + val current = _uiState.value.selectedPatches + val newSelection = if (current.contains(patchId)) { + current - patchId + } else { + current + patchId + } + _uiState.value = _uiState.value.copy(selectedPatches = newSelection) + } + + fun selectAll() { + val allIds = _uiState.value.filteredPatches.map { it.uniqueId }.toSet() + _uiState.value = _uiState.value.copy(selectedPatches = allIds) + } + + fun deselectAll() { + _uiState.value = _uiState.value.copy(selectedPatches = emptySet()) + } + + fun setSearchQuery(query: String) { + val filtered = if (query.isBlank()) { + _uiState.value.allPatches + } else { + _uiState.value.allPatches.filter { + it.name.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true) + } + } + _uiState.value = _uiState.value.copy( + searchQuery = query, + filteredPatches = filtered + ) + } + + fun setShowOnlySelected(show: Boolean) { + val filtered = if (show) { + _uiState.value.allPatches.filter { _uiState.value.selectedPatches.contains(it.uniqueId) } + } else if (_uiState.value.searchQuery.isNotBlank()) { + _uiState.value.allPatches.filter { + it.name.contains(_uiState.value.searchQuery, ignoreCase = true) || + it.description.contains(_uiState.value.searchQuery, ignoreCase = true) + } + } else { + _uiState.value.allPatches + } + _uiState.value = _uiState.value.copy( + showOnlySelected = show, + filteredPatches = filtered + ) + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + /** + * Get patches that match the commonly disabled list and are currently selected. + * Returns list of (patch, reason) pairs. + */ + fun getCommonlyDisabledPatches(): List> { + val packageName = getPackageNameFromApk() + val commonlyDisabled = AppConstants.PatchRecommendations.getCommonlyDisabled(packageName) + + return _uiState.value.allPatches + .filter { patch -> _uiState.value.selectedPatches.contains(patch.uniqueId) } + .mapNotNull { patch -> + // Find matching commonly disabled entry + val match = commonlyDisabled.find { (pattern, _) -> + patch.name.contains(pattern, ignoreCase = true) + } + if (match != null) { + patch to match.second + } else { + null + } + } + } + + /** + * Deselect all commonly disabled patches at once. + */ + fun deselectCommonlyDisabled() { + val patchesToDeselect = getCommonlyDisabledPatches().map { it.first.uniqueId }.toSet() + val newSelection = _uiState.value.selectedPatches - patchesToDeselect + _uiState.value = _uiState.value.copy(selectedPatches = newSelection) + } + + fun createPatchConfig(): PatchConfig { + // Create app folder in the same location as the input APK + val inputFile = File(apkPath) + val appFolderName = apkName.replace(" ", "-") + val outputDir = File(inputFile.parentFile, appFolderName) + outputDir.mkdirs() + + // Extract version from APK filename for output name + val version = extractVersionFromFilename(inputFile.name) ?: "patched" + val outputFileName = "${appFolderName}-${version}-patched.apk" + val outputPath = File(outputDir, outputFileName).absolutePath + + // Convert unique IDs back to patch names for CLI + val selectedPatchNames = _uiState.value.allPatches + .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } + .map { it.name } + + val disabledPatchNames = _uiState.value.allPatches + .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } + .map { it.name } + + return PatchConfig( + inputApkPath = apkPath, + outputApkPath = outputPath, + patchesFilePath = actualPatchesFilePath, + enabledPatches = selectedPatchNames, + disabledPatches = disabledPatchNames, + useExclusiveMode = true + ) + } + + private fun extractVersionFromFilename(fileName: String): String? { + // Extract version from APKMirror format: com.google.android.youtube_20.40.45-xxx + return try { + val afterPackage = fileName.substringAfter("_") + afterPackage.substringBefore("-").takeIf { it.isNotEmpty() } + } catch (e: Exception) { + null + } + } + + fun getApkName(): String = apkName + + /** + * Generate a preview of the CLI command that will be executed. + * @param cleanMode If true, formats with newlines for readability. If false, compact single-line format. + */ + fun getCommandPreview(cleanMode: Boolean = false): String { + val inputFile = File(apkPath) + val patchesFile = File(actualPatchesFilePath) + val appFolderName = apkName.replace(" ", "-") + val version = extractVersionFromFilename(inputFile.name) ?: "patched" + val outputFileName = "${appFolderName}-${version}-patched.apk" + + val selectedPatchNames = _uiState.value.allPatches + .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } + .map { it.name } + + return if (cleanMode) { + val sb = StringBuilder() + sb.append("java -jar morphe-cli.jar patch \\\n") + sb.append(" -p ${patchesFile.name} \\\n") + sb.append(" -o ${outputFileName} \\\n") + sb.append(" --exclusive \\\n") + + selectedPatchNames.forEachIndexed { index, patch -> + val isLast = index == selectedPatchNames.lastIndex + sb.append(" -e \"$patch\"") + if (!isLast) { + sb.append(" \\") + } + sb.append("\n") + } + + sb.append(" ${inputFile.name}") + sb.toString() + } else { + // Compact mode - single line that wraps naturally + val patches = selectedPatchNames.joinToString(" ") { "-e \"$it\"" } + "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --exclusive $patches ${inputFile.name}" + } + } + + /** + * Download patches file if it's missing (e.g., after cache clear). + * Tries to find a release matching the expected filename, or falls back to latest stable. + */ + private suspend fun downloadMissingPatches(expectedFilename: String): Result { + // Try to extract version from filename (e.g., "morphe-patches-1.9.0.mpp" -> "1.9.0") + val versionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") + val versionMatch = versionRegex.find(expectedFilename) + val expectedVersion = versionMatch?.groupValues?.get(1) + + Logger.info("Looking for patches version: ${expectedVersion ?: "latest"}") + + // Fetch releases + val releasesResult = patchRepository.fetchReleases() + if (releasesResult.isFailure) { + return Result.failure(releasesResult.exceptionOrNull() + ?: Exception("Failed to fetch releases")) + } + + val releases = releasesResult.getOrNull() ?: emptyList() + if (releases.isEmpty()) { + return Result.failure(Exception("No releases found")) + } + + // Find matching release by version, or use latest stable + val targetRelease = if (expectedVersion != null) { + releases.find { it.tagName.contains(expectedVersion) } + ?: releases.firstOrNull { !it.isDevRelease() } // Fallback to latest stable + } else { + releases.firstOrNull { !it.isDevRelease() } // Latest stable + } + + if (targetRelease == null) { + return Result.failure(Exception("No suitable release found")) + } + + Logger.info("Downloading patches from release: ${targetRelease.tagName}") + + // Download the patches + return patchRepository.downloadPatches(targetRelease) + } + + private fun getPackageNameFromApk(): String { + // Extract package name from APK filename (APKMirror format) + val fileName = File(apkPath).name + return when { + fileName.startsWith("com.google.android.youtube_") -> "com.google.android.youtube" + fileName.startsWith("com.google.android.apps.youtube.music_") -> "com.google.android.apps.youtube.music" + fileName.startsWith("com.reddit.frontpage_") -> "com.reddit.frontpage" + else -> "" + } + } +} + +data class PatchSelectionUiState( + val isLoading: Boolean = false, + val allPatches: List = emptyList(), + val filteredPatches: List = emptyList(), + val selectedPatches: Set = emptySet(), + val searchQuery: String = "", + val showOnlySelected: Boolean = false, + val error: String? = null +) { + val selectedCount: Int get() = selectedPatches.size + val totalCount: Int get() = allPatches.size +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt new file mode 100644 index 0000000..7149f72 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt @@ -0,0 +1,478 @@ +package app.morphe.gui.ui.screens.patches + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import app.morphe.gui.data.model.Release +import org.koin.core.parameter.parametersOf +import cafe.adriel.voyager.koin.koinScreenModel +import app.morphe.gui.ui.components.ErrorDialog +import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.getErrorType +import app.morphe.gui.ui.components.getFriendlyErrorMessage +import app.morphe.gui.ui.theme.MorpheColors +import java.io.File + +/** + * Screen for selecting patches to apply. + */ +data class PatchesScreen( + val apkPath: String, + val apkName: String +) : Screen { + + @Composable + override fun Content() { + val viewModel = koinScreenModel { parametersOf(apkPath, apkName) } + PatchesScreenContent(viewModel = viewModel) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PatchesScreenContent(viewModel: PatchesViewModel) { + val navigator = LocalNavigator.currentOrThrow + val uiState by viewModel.uiState.collectAsState() + + var showErrorDialog by remember { mutableStateOf(false) } + var currentError by remember { mutableStateOf(null) } + + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + currentError = error + showErrorDialog = true + } + } + + // Error dialog + if (showErrorDialog && currentError != null) { + ErrorDialog( + title = "Error", + message = getFriendlyErrorMessage(currentError!!), + errorType = getErrorType(currentError!!), + onDismiss = { + showErrorDialog = false + viewModel.clearError() + }, + onRetry = { + showErrorDialog = false + viewModel.clearError() + viewModel.loadReleases() + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("Select Patches", fontWeight = FontWeight.SemiBold) + Text( + text = viewModel.getApkName(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = { navigator.pop() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + IconButton( + onClick = { viewModel.loadReleases() }, + enabled = !uiState.isLoading + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh" + ) + } + SettingsButton(allowCacheClear = true) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Channel selector + ChannelSelector( + selectedChannel = uiState.selectedChannel, + onChannelSelected = { viewModel.setChannel(it) }, + stableCount = uiState.stableReleases.size, + devCount = uiState.devReleases.size, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator(color = MorpheColors.Blue) + Text( + text = "Fetching releases...", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + uiState.currentReleases.isEmpty() && !uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "No releases found", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedButton(onClick = { viewModel.loadReleases() }) { + Text("Retry") + } + } + } + } + + else -> { + // Releases list + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(uiState.currentReleases) { release -> + ReleaseCard( + release = release, + isSelected = release == uiState.selectedRelease, + onClick = { viewModel.selectRelease(release) } + ) + } + } + + // Bottom action bar + BottomActionBar( + uiState = uiState, + onDownloadClick = { viewModel.downloadPatches() }, + onSelectClick = { + // Save the selected version to config before navigating back + viewModel.confirmSelection() + // Go back to HomeScreen - the new patches file is now cached + navigator.pop() + } + ) + } + } + } + } +} + +@Composable +private fun ChannelSelector( + selectedChannel: ReleaseChannel, + onChannelSelected: (ReleaseChannel) -> Unit, + stableCount: Int, + devCount: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ChannelChip( + label = "Stable", + count = stableCount, + isSelected = selectedChannel == ReleaseChannel.STABLE, + onClick = { onChannelSelected(ReleaseChannel.STABLE) }, + modifier = Modifier.weight(1f) + ) + ChannelChip( + label = "Dev", + count = devCount, + isSelected = selectedChannel == ReleaseChannel.DEV, + onClick = { onChannelSelected(ReleaseChannel.DEV) }, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ChannelChip( + label: String, + count: Int, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val backgroundColor = if (isSelected) { + MorpheColors.Blue.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + + val borderColor = if (isSelected) { + MorpheColors.Blue + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + } + + Surface( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + color = backgroundColor, + shape = RoundedCornerShape(12.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, borderColor) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = if (isSelected) MorpheColors.Blue else MaterialTheme.colorScheme.onSurface + ) + if (count > 0) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "($count)", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ReleaseCard( + release: Release, + isSelected: Boolean, + onClick: () -> Unit +) { + val backgroundColor = if (isSelected) { + MorpheColors.Blue.copy(alpha = 0.1f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = release.tagName, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + if (release.isDevRelease()) { + Surface( + color = MorpheColors.Teal.copy(alpha = 0.2f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "DEV", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = MorpheColors.Teal, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Show .mpp file info if available + release.assets.find { it.isMpp() }?.let { mppAsset -> + Text( + text = "${mppAsset.name} (${mppAsset.getFormattedSize()})", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = "Published: ${formatDate(release.publishedAt)}", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MorpheColors.Blue, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Composable +private fun BottomActionBar( + uiState: PatchesUiState, + onDownloadClick: () -> Unit, + onSelectClick: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Download progress + if (uiState.isDownloading) { + LinearProgressIndicator( + progress = { uiState.downloadProgress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = MorpheColors.Blue, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Downloading patches...", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Download button + if (uiState.downloadedPatchFile == null) { + Button( + onClick = onDownloadClick, + enabled = uiState.selectedRelease != null && !uiState.isDownloading, + modifier = Modifier + .weight(1f) + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Blue + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = if (uiState.isDownloading) "Downloading..." else "Download Patches", + fontWeight = FontWeight.Medium + ) + } + } else { + // Select button (patches downloaded) + Button( + onClick = onSelectClick, + modifier = Modifier + .weight(1f) + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Teal + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "Select", + fontWeight = FontWeight.Medium + ) + } + } + } + + // Downloaded file info + uiState.downloadedPatchFile?.let { file -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Downloaded: ${file.name}", + fontSize = 12.sp, + color = MorpheColors.Teal + ) + } + } + } +} + +private fun formatDate(isoDate: String): String { + return try { + // Simple date formatting - takes "2024-01-15T10:30:00Z" and returns "Jan 15, 2024" + val datePart = isoDate.substringBefore("T") + val parts = datePart.split("-") + if (parts.size == 3) { + val months = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + val month = months.getOrElse(parts[1].toInt() - 1) { "???" } + val day = parts[2].toInt() + val year = parts[0] + "$month $day, $year" + } else { + datePart + } + } catch (e: Exception) { + isoDate + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt new file mode 100644 index 0000000..b8c3365 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt @@ -0,0 +1,211 @@ +package app.morphe.gui.ui.screens.patches + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import app.morphe.gui.data.model.Release +import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.gui.data.repository.PatchRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import app.morphe.gui.util.Logger +import java.io.File + +class PatchesViewModel( + private val apkPath: String, + private val apkName: String, + private val patchRepository: PatchRepository, + private val configRepository: ConfigRepository +) : ScreenModel { + + private val _uiState = MutableStateFlow(PatchesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadReleases() + } + + fun loadReleases() { + screenModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + val result = patchRepository.fetchReleases() + + result.fold( + onSuccess = { releases -> + val stableReleases = releases.filter { !it.isDevRelease() } + val devReleases = releases.filter { it.isDevRelease() } + + // Check config for previously selected version + val config = configRepository.loadConfig() + val savedVersion = config.lastPatchesVersion + + // Find the saved release, or fall back to latest stable + val initialRelease = if (savedVersion != null) { + // Try to find in stable first, then dev + stableReleases.find { it.tagName == savedVersion } + ?: devReleases.find { it.tagName == savedVersion } + ?: stableReleases.firstOrNull() + } else { + stableReleases.firstOrNull() + } + + // Determine initial channel based on selected release + val initialChannel = if (initialRelease != null && initialRelease.isDevRelease()) { + ReleaseChannel.DEV + } else { + ReleaseChannel.STABLE + } + + // Check if patches for the initial release are already cached + val cachedFile = initialRelease?.let { checkCachedPatches(it) } + + _uiState.value = _uiState.value.copy( + isLoading = false, + stableReleases = stableReleases, + devReleases = devReleases, + selectedChannel = initialChannel, + selectedRelease = initialRelease, + downloadedPatchFile = cachedFile + ) + Logger.info("Loaded ${stableReleases.size} stable and ${devReleases.size} dev releases, saved=$savedVersion, selected=${initialRelease?.tagName}, cached: ${cachedFile != null}") + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message ?: "Failed to load releases" + ) + Logger.error("Failed to load releases", e) + } + ) + } + } + + fun selectRelease(release: Release) { + // Check if patches for this release are already cached + val cachedFile = checkCachedPatches(release) + + _uiState.value = _uiState.value.copy( + selectedRelease = release, + downloadedPatchFile = cachedFile + ) + Logger.info("Selected release: ${release.tagName}, cached: ${cachedFile != null}") + } + + /** + * Check if patches for a release are already downloaded and valid. + */ + private fun checkCachedPatches(release: Release): File? { + val asset = patchRepository.findMppAsset(release) ?: return null + val patchesDir = app.morphe.gui.util.FileUtils.getPatchesDir() + val cachedFile = File(patchesDir, asset.name) + + // Verify file exists and size matches (size check acts as basic integrity verification) + return if (cachedFile.exists() && cachedFile.length() == asset.size) { + Logger.info("Found cached patches: ${cachedFile.absolutePath}") + cachedFile + } else { + null + } + } + + fun setChannel(channel: ReleaseChannel) { + val newRelease = when (channel) { + ReleaseChannel.STABLE -> _uiState.value.stableReleases.firstOrNull() + ReleaseChannel.DEV -> _uiState.value.devReleases.firstOrNull() + } + + // Check if patches for the new release are already cached + val cachedFile = newRelease?.let { checkCachedPatches(it) } + + _uiState.value = _uiState.value.copy( + selectedChannel = channel, + selectedRelease = newRelease, + downloadedPatchFile = cachedFile + ) + } + + fun downloadPatches() { + val release = _uiState.value.selectedRelease ?: return + + screenModelScope.launch { + _uiState.value = _uiState.value.copy( + isDownloading = true, + downloadProgress = 0f, + error = null + ) + + val result = patchRepository.downloadPatches(release) { progress -> + _uiState.value = _uiState.value.copy(downloadProgress = progress) + } + + result.fold( + onSuccess = { patchFile -> + _uiState.value = _uiState.value.copy( + isDownloading = false, + downloadedPatchFile = patchFile, + downloadProgress = 1f + ) + Logger.info("Patches downloaded: ${patchFile.absolutePath}") + + // Save the selected version to config so HomeScreen can pick it up + configRepository.setLastPatchesVersion(release.tagName) + Logger.info("Saved selected patches version to config: ${release.tagName}") + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isDownloading = false, + error = e.message ?: "Failed to download patches" + ) + Logger.error("Failed to download patches", e) + } + ) + } + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + /** + * Confirm the current selection and save it to config. + * Called when user clicks "Select" button. + */ + fun confirmSelection() { + val release = _uiState.value.selectedRelease ?: return + screenModelScope.launch { + configRepository.setLastPatchesVersion(release.tagName) + Logger.info("Confirmed patches selection: ${release.tagName}") + } + } + + fun getApkPath(): String = apkPath + fun getApkName(): String = apkName +} + +enum class ReleaseChannel { + STABLE, + DEV +} + +data class PatchesUiState( + val isLoading: Boolean = false, + val stableReleases: List = emptyList(), + val devReleases: List = emptyList(), + val selectedChannel: ReleaseChannel = ReleaseChannel.STABLE, + val selectedRelease: Release? = null, + val isDownloading: Boolean = false, + val downloadProgress: Float = 0f, + val downloadedPatchFile: File? = null, + val error: String? = null +) { + val currentReleases: List + get() = when (selectedChannel) { + ReleaseChannel.STABLE -> stableReleases + ReleaseChannel.DEV -> devReleases + } + + val isReady: Boolean + get() = downloadedPatchFile != null +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt new file mode 100644 index 0000000..8e0978e --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt @@ -0,0 +1,457 @@ +package app.morphe.gui.ui.screens.patching + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.koin.koinScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import app.morphe.gui.data.model.PatchConfig +import org.koin.core.parameter.parametersOf +import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.screens.result.ResultScreen +import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.FileUtils +import app.morphe.gui.util.Logger +import java.awt.Desktop + +/** + * Screen showing patching progress with real-time logs. + */ +data class PatchingScreen( + val config: PatchConfig +) : Screen { + + @Composable + override fun Content() { + val viewModel = koinScreenModel { parametersOf(config) } + PatchingScreenContent(viewModel = viewModel) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PatchingScreenContent(viewModel: PatchingViewModel) { + val navigator = LocalNavigator.currentOrThrow + val uiState by viewModel.uiState.collectAsState() + + // Auto-start patching when screen loads + LaunchedEffect(Unit) { + viewModel.startPatching() + } + + // Auto-scroll to bottom of logs + val listState = rememberLazyListState() + LaunchedEffect(uiState.logs.size) { + if (uiState.logs.isNotEmpty()) { + listState.animateScrollToItem(uiState.logs.size - 1) + } + } + + // Auto-navigate to result screen on successful completion + LaunchedEffect(uiState.status) { + if (uiState.status == PatchingStatus.COMPLETED && uiState.outputPath != null) { + // Small delay to let user see the success message + kotlinx.coroutines.delay(1500) + navigator.push(ResultScreen(outputPath = uiState.outputPath!!)) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("Patching", fontWeight = FontWeight.SemiBold) + Text( + text = getStatusText(uiState.status), + style = MaterialTheme.typography.bodySmall, + color = getStatusColor(uiState.status) + ) + } + }, + navigationIcon = { + IconButton( + onClick = { navigator.pop() }, + enabled = !uiState.isInProgress + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + if (uiState.canCancel) { + TextButton( + onClick = { viewModel.cancelPatching() }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Cancel") + } + } + SettingsButton(allowCacheClear = false) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Progress indicator + if (uiState.isInProgress) { + Column { + if (uiState.hasProgress) { + // Show determinate progress when we have progress info + LinearProgressIndicator( + progress = { uiState.progress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = MorpheColors.Blue, + ) + // Show progress text + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = uiState.currentPatch ?: "Applying patches...", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.weight(1f) + ) + Text( + text = "${uiState.patchedCount}/${uiState.totalPatches}", + fontSize = 11.sp, + color = MorpheColors.Blue, + fontWeight = FontWeight.Medium + ) + } + } else { + // Show indeterminate progress when we don't have progress info + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = MorpheColors.Blue + ) + } + } + } + + // Log output + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(uiState.logs, key = { it.id }) { entry -> + LogEntryRow(entry) + } + } + + // Bottom action bar (only for failed/cancelled - success auto-navigates) + when (uiState.status) { + PatchingStatus.COMPLETED -> { + // Show brief success message while auto-navigating + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MorpheColors.Teal + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Patching completed! Loading result...", + color = MorpheColors.Teal, + fontWeight = FontWeight.Medium + ) + } + } + } + + PatchingStatus.FAILED, PatchingStatus.CANCELLED -> { + FailureBottomBar( + status = uiState.status, + error = uiState.error, + onStartOver = { navigator.popUntilRoot() }, + onGoBack = { navigator.pop() } + ) + } + + else -> { + // Show nothing for in-progress states + } + } + } + } +} + +@Composable +private fun FailureBottomBar( + status: PatchingStatus, + error: String?, + onStartOver: () -> Unit, + onGoBack: () -> Unit +) { + var tempFilesCleared by remember { mutableStateOf(false) } + val hasTempFiles = remember { FileUtils.hasTempFiles() } + val tempFilesSize = remember { FileUtils.getTempDirSize() } + val logFile = remember { Logger.getLogFile() } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // Error message + Text( + text = if (status == PatchingStatus.CANCELLED) + "Patching was cancelled" + else + error ?: "Patching failed", + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Log file location + if (logFile != null && logFile.exists()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Log file", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = logFile.absolutePath, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + maxLines = 1 + ) + } + TextButton( + onClick = { + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(logFile.parentFile) + } + } catch (e: Exception) { + Logger.error("Failed to open logs folder", e) + } + } + ) { + Text("Open", fontSize = 12.sp) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + } + + // Cleanup option + if (hasTempFiles && !tempFilesCleared) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Temporary files", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${formatFileSize(tempFilesSize)} can be freed", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + TextButton( + onClick = { + FileUtils.cleanupAllTempDirs() + tempFilesCleared = true + Logger.info("Cleaned temp files after failed patching") + } + ) { + Text("Clean up", fontSize = 12.sp) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + } else if (tempFilesCleared) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MorpheColors.Teal.copy(alpha = 0.1f)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Temp files cleaned", + fontSize = 12.sp, + color = MorpheColors.Teal + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onStartOver, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text("Start Over") + } + Button( + onClick = onGoBack, + modifier = Modifier + .weight(1f) + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Blue + ), + shape = RoundedCornerShape(12.dp) + ) { + Text("Go Back", fontWeight = FontWeight.Medium) + } + } + } + } +} + +private fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) + } +} + +@Composable +private fun LogEntryRow(entry: LogEntry) { + val color = when (entry.level) { + LogLevel.SUCCESS -> MorpheColors.Teal + LogLevel.ERROR -> MaterialTheme.colorScheme.error + LogLevel.WARNING -> Color(0xFFFF9800) + LogLevel.PROGRESS -> MorpheColors.Blue + LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant + } + + val prefix = when (entry.level) { + LogLevel.SUCCESS -> "[OK]" + LogLevel.ERROR -> "[ERR]" + LogLevel.WARNING -> "[WARN]" + LogLevel.PROGRESS -> "[...]" + LogLevel.INFO -> "[i]" + } + + Text( + text = "$prefix ${entry.message}", + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = color, + lineHeight = 18.sp + ) +} + +private fun getStatusText(status: PatchingStatus): String { + return when (status) { + PatchingStatus.IDLE -> "Ready" + PatchingStatus.PREPARING -> "Preparing..." + PatchingStatus.PATCHING -> "Patching in progress..." + PatchingStatus.COMPLETED -> "Completed" + PatchingStatus.FAILED -> "Failed" + PatchingStatus.CANCELLED -> "Cancelled" + } +} + +@Composable +private fun getStatusColor(status: PatchingStatus): Color { + return when (status) { + PatchingStatus.COMPLETED -> MorpheColors.Teal + PatchingStatus.FAILED -> MaterialTheme.colorScheme.error + PatchingStatus.CANCELLED -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant + } +} 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 new file mode 100644 index 0000000..30726bf --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -0,0 +1,236 @@ +package app.morphe.gui.ui.screens.patching + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import app.morphe.gui.data.model.PatchConfig +import app.morphe.gui.data.repository.ConfigRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import app.morphe.gui.util.Logger +import app.morphe.gui.util.PatchService +import java.io.File + +class PatchingViewModel( + private val config: PatchConfig, + private val patchService: PatchService, + private val configRepository: ConfigRepository +) : ScreenModel { + + private val _uiState = MutableStateFlow(PatchingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var patchingJob: Job? = null + + fun startPatching() { + if (_uiState.value.status != PatchingStatus.IDLE) return + + patchingJob = screenModelScope.launch { + _uiState.value = _uiState.value.copy( + status = PatchingStatus.PREPARING, + logs = listOf(LogEntry("Preparing to patch...", LogLevel.INFO)) + ) + + addLog("Initializing patcher...", LogLevel.INFO) + + // Start patching + _uiState.value = _uiState.value.copy( + status = PatchingStatus.PATCHING, + totalPatches = config.enabledPatches.size, + patchedCount = 0, + progress = 0f + ) + addLog("Starting patch process...", LogLevel.INFO) + addLog("Input: ${File(config.inputApkPath).name}", LogLevel.INFO) + addLog("Output: ${File(config.outputApkPath).name}", LogLevel.INFO) + addLog("Patches: ${config.enabledPatches.size} enabled", LogLevel.INFO) + + // Use PatchService for direct library patching + val result = patchService.patch( + patchesFilePath = config.patchesFilePath, + inputApkPath = config.inputApkPath, + outputApkPath = config.outputApkPath, + enabledPatches = config.enabledPatches, + disabledPatches = config.disabledPatches, + options = config.patchOptions, + exclusiveMode = config.useExclusiveMode, + onProgress = { message -> + parseAndAddLog(message) + } + ) + + result.fold( + onSuccess = { patchResult -> + if (patchResult.success) { + addLog("Patching completed successfully!", LogLevel.SUCCESS) + addLog("Applied ${patchResult.appliedPatches.size} patches", LogLevel.SUCCESS) + _uiState.value = _uiState.value.copy( + status = PatchingStatus.COMPLETED, + outputPath = config.outputApkPath, + progress = 1f + ) + Logger.info("Patching completed: ${config.outputApkPath}") + } else { + val failedMsg = if (patchResult.failedPatches.isNotEmpty()) { + "Failed patches: ${patchResult.failedPatches.joinToString(", ")}" + } else { + "Patching failed" + } + addLog(failedMsg, LogLevel.ERROR) + _uiState.value = _uiState.value.copy( + status = PatchingStatus.FAILED, + error = "Patching failed. Check logs for details." + ) + Logger.error("Patching failed: ${patchResult.failedPatches}") + } + }, + onFailure = { e -> + addLog("Error: ${e.message}", LogLevel.ERROR) + _uiState.value = _uiState.value.copy( + status = PatchingStatus.FAILED, + error = e.message ?: "Unknown error occurred" + ) + Logger.error("Patching error", e) + } + ) + } + } + + fun cancelPatching() { + patchingJob?.cancel() + patchingJob = null + addLog("Patching cancelled by user", LogLevel.WARNING) + _uiState.value = _uiState.value.copy( + status = PatchingStatus.CANCELLED + ) + Logger.info("Patching cancelled by user") + } + + private fun addLog(message: String, level: LogLevel) { + val entry = LogEntry(message, level) + _uiState.value = _uiState.value.copy( + logs = _uiState.value.logs + entry + ) + } + + private fun parseAndAddLog(line: String) { + val level = when { + line.contains("error", ignoreCase = true) -> LogLevel.ERROR + line.contains("warning", ignoreCase = true) -> LogLevel.WARNING + line.contains("success", ignoreCase = true) || + line.contains("completed", ignoreCase = true) || + line.contains("done", ignoreCase = true) -> LogLevel.SUCCESS + line.contains("patching", ignoreCase = true) || + line.contains("applying", ignoreCase = true) -> LogLevel.PROGRESS + else -> LogLevel.INFO + } + addLog(line, level) + + // Try to extract progress information + parseProgress(line) + } + + private fun parseProgress(line: String) { + // Pattern: "Executing patch X of Y: PatchName" or similar + val executingPattern = Regex("""(?:Executing|Applying)\s+patch\s+(\d+)\s+of\s+(\d+)(?::\s*(.+))?""", RegexOption.IGNORE_CASE) + val executingMatch = executingPattern.find(line) + if (executingMatch != null) { + val current = executingMatch.groupValues[1].toIntOrNull() ?: 0 + val total = executingMatch.groupValues[2].toIntOrNull() ?: 0 + val patchName = executingMatch.groupValues.getOrNull(3)?.trim() + + if (total > 0) { + val progress = current.toFloat() / total.toFloat() + _uiState.value = _uiState.value.copy( + progress = progress, + patchedCount = current, + totalPatches = total, + currentPatch = patchName, + hasReceivedProgressUpdate = true + ) + } + return + } + + // Pattern: "[X/Y]" or "(X/Y)" + val fractionPattern = Regex("""[\[\(](\d+)/(\d+)[\]\)]""") + val fractionMatch = fractionPattern.find(line) + if (fractionMatch != null) { + val current = fractionMatch.groupValues[1].toIntOrNull() ?: 0 + val total = fractionMatch.groupValues[2].toIntOrNull() ?: 0 + + if (total > 0) { + val progress = current.toFloat() / total.toFloat() + _uiState.value = _uiState.value.copy( + progress = progress, + patchedCount = current, + totalPatches = total, + hasReceivedProgressUpdate = true + ) + } + return + } + + // Pattern: "X%" percentage + val percentPattern = Regex("""(\d+(?:\.\d+)?)\s*%""") + val percentMatch = percentPattern.find(line) + if (percentMatch != null) { + val percent = percentMatch.groupValues[1].toFloatOrNull() ?: 0f + if (percent > 0) { + _uiState.value = _uiState.value.copy( + progress = percent / 100f, + hasReceivedProgressUpdate = true + ) + } + } + } + + fun getConfig(): PatchConfig = config +} + +enum class PatchingStatus { + IDLE, + PREPARING, + PATCHING, + COMPLETED, + FAILED, + CANCELLED +} + +enum class LogLevel { + INFO, + SUCCESS, + WARNING, + ERROR, + PROGRESS +} + +data class LogEntry( + val message: String, + val level: LogLevel, + val id: String = "${System.currentTimeMillis()}_${System.nanoTime()}" +) + +data class PatchingUiState( + val status: PatchingStatus = PatchingStatus.IDLE, + val logs: List = emptyList(), + val outputPath: String? = null, + val error: String? = null, + val progress: Float = 0f, + val currentPatch: String? = null, + val patchedCount: Int = 0, + val totalPatches: Int = 0, + val hasReceivedProgressUpdate: Boolean = false +) { + val isInProgress: Boolean + get() = status == PatchingStatus.PREPARING || status == PatchingStatus.PATCHING + + val canCancel: Boolean + get() = isInProgress + + // Only show determinate progress if we've actually received progress updates from CLI + val hasProgress: Boolean + get() = hasReceivedProgressUpdate && progress > 0f +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt new file mode 100644 index 0000000..4b5e298 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -0,0 +1,963 @@ +package app.morphe.gui.ui.screens.quick + +import androidx.compose.animation.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +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.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import app.morphe.morphe_cli.generated.resources.Res +import app.morphe.morphe_cli.generated.resources.reddit +import app.morphe.morphe_cli.generated.resources.youtube +import app.morphe.morphe_cli.generated.resources.youtube_music +import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.gui.data.repository.PatchRepository +import app.morphe.gui.util.PatchService +import org.jetbrains.compose.resources.painterResource +import org.koin.compose.koinInject +import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.AdbManager +import app.morphe.gui.util.ChecksumStatus +import java.awt.Desktop +import java.awt.datatransfer.DataFlavor +import java.io.File +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter + +/** + * Quick Patch Mode - Single screen simplified patching. + */ +class QuickPatchScreen : Screen { + @Composable + override fun Content() { + val patchRepository: PatchRepository = koinInject() + val patchService: PatchService = koinInject() + val configRepository: ConfigRepository = koinInject() + + val viewModel = remember { + QuickPatchViewModel(patchRepository, patchService, configRepository) + } + + QuickPatchContent(viewModel) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun QuickPatchContent(viewModel: QuickPatchViewModel) { + val uiState by viewModel.uiState.collectAsState() + val uriHandler = LocalUriHandler.current + + // Compose drag and drop target + val dragAndDropTarget = remember { + object : DragAndDropTarget { + override fun onStarted(event: DragAndDropEvent) { + viewModel.setDragHover(true) + } + + override fun onEnded(event: DragAndDropEvent) { + viewModel.setDragHover(false) + } + + override fun onExited(event: DragAndDropEvent) { + viewModel.setDragHover(false) + } + + override fun onEntered(event: DragAndDropEvent) { + viewModel.setDragHover(true) + } + + override fun onDrop(event: DragAndDropEvent): Boolean { + viewModel.setDragHover(false) + val transferable = event.awtTransferable + return try { + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + @Suppress("UNCHECKED_CAST") + val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List + val apkFile = files.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) } + if (apkFile != null) { + viewModel.onFileSelected(apkFile) + true + } else { + false + } + } else { + false + } + } catch (e: Exception) { + false + } + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .dragAndDropTarget( + shouldStartDragAndDrop = { true }, + target = dragAndDropTarget + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Morphe Quick Patch", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Mode indicator + Surface( + color = MorpheColors.Blue.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "QUICK MODE", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = MorpheColors.Blue, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + // Settings button + SettingsButton() + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Main content based on phase + // Remember last valid data for safe animation transitions + val lastApkInfo = remember(uiState.apkInfo) { uiState.apkInfo } + val lastOutputPath = remember(uiState.outputPath) { uiState.outputPath } + + AnimatedContent( + targetState = uiState.phase, + modifier = Modifier.weight(1f) + ) { phase -> + when (phase) { + QuickPatchPhase.IDLE, QuickPatchPhase.ANALYZING -> { + IdleContent( + isAnalyzing = phase == QuickPatchPhase.ANALYZING, + isDragHovering = uiState.isDragHovering, + error = uiState.error, + onFileSelected = { viewModel.onFileSelected(it) }, + onDragHover = { viewModel.setDragHover(it) }, + onClearError = { viewModel.clearError() } + ) + } + QuickPatchPhase.READY -> { + // Use current or last known apkInfo to prevent crash during animation + val apkInfo = uiState.apkInfo ?: lastApkInfo + if (apkInfo != null) { + ReadyContent( + apkInfo = apkInfo, + error = uiState.error, + onPatch = { viewModel.startPatching() }, + onClear = { viewModel.reset() }, + onClearError = { viewModel.clearError() } + ) + } + } + QuickPatchPhase.DOWNLOADING, QuickPatchPhase.PATCHING -> { + PatchingContent( + phase = phase, + progress = uiState.progress, + statusMessage = uiState.statusMessage, + onCancel = { viewModel.cancelPatching() } + ) + } + QuickPatchPhase.COMPLETED -> { + val apkInfo = uiState.apkInfo ?: lastApkInfo + val outputPath = uiState.outputPath ?: lastOutputPath + if (apkInfo != null && outputPath != null) { + CompletedContent( + outputPath = outputPath, + apkInfo = apkInfo, + onPatchAnother = { viewModel.reset() } + ) + } + } + } + } + + // Bottom app cards (only show in IDLE phase) + if (uiState.phase == QuickPatchPhase.IDLE) { + Spacer(modifier = Modifier.height(16.dp)) + SupportedAppsRow( + supportedApps = uiState.supportedApps, + isLoading = uiState.isLoadingPatches, + patchesVersion = uiState.patchesVersion, + onOpenUrl = { url -> uriHandler.openUri(url) } + ) + } + } + + // Error snackbar + uiState.error?.let { error -> + Snackbar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + action = { + TextButton(onClick = { viewModel.clearError() }) { + Text("Dismiss", color = MaterialTheme.colorScheme.inversePrimary) + } + }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) { + Text(error) + } + } + } +} + +@Composable +private fun IdleContent( + isAnalyzing: Boolean, + isDragHovering: Boolean, + error: String?, + onFileSelected: (File) -> Unit, + onDragHover: (Boolean) -> Unit, + onClearError: () -> Unit +) { + val dropZoneColor = when { + isDragHovering -> MorpheColors.Blue.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + } + + val borderColor = when { + isDragHovering -> MorpheColors.Blue + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + } + + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)) + .background(dropZoneColor) + .border(2.dp, borderColor, RoundedCornerShape(16.dp)) + .clickable(enabled = !isAnalyzing) { + openFilePicker()?.let { onFileSelected(it) } + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isAnalyzing) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MorpheColors.Blue, + strokeWidth = 3.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Analyzing APK...", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Icon( + imageVector = Icons.Default.CloudUpload, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = if (isDragHovering) MorpheColors.Blue else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Drop APK here", + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "or click to browse", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ReadyContent( + apkInfo: QuickApkInfo, + error: String?, + onPatch: () -> Unit, + onClear: () -> Unit, + onClearError: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // APK Info Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // App icon + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource( + when (apkInfo.packageName) { + AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube + AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music + AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit + else -> Res.drawable.youtube // Fallback + } + ), + contentDescription = "${apkInfo.displayName} icon", + modifier = Modifier.size(36.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = apkInfo.displayName, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "v${apkInfo.versionName} • ${apkInfo.formattedSize}", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Checksum status + when (apkInfo.checksumStatus) { + is ChecksumStatus.Verified -> { + Icon( + imageVector = Icons.Default.VerifiedUser, + contentDescription = "Verified", + tint = MorpheColors.Teal, + modifier = Modifier.size(24.dp) + ) + } + is ChecksumStatus.Mismatch -> { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Checksum mismatch", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp) + ) + } + else -> {} + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Verification status banner + VerificationStatusBanner( + checksumStatus = apkInfo.checksumStatus, + isRecommendedVersion = apkInfo.isRecommendedVersion, + currentVersion = apkInfo.versionName, + suggestedVersion = apkInfo.recommendedVersion ?: "Unknown" + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Patch button + Button( + onClick = onPatch, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Blue + ), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + imageVector = Icons.Default.AutoFixHigh, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Patch with Defaults", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Uses latest patches with recommended settings", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun PatchingContent( + phase: QuickPatchPhase, + progress: Float, + statusMessage: String, + onCancel: () -> Unit +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Progress indicator + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = { progress }, + modifier = Modifier.size(100.dp), + strokeWidth = 6.dp, + color = MorpheColors.Teal, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + Text( + text = "${(progress * 100).toInt()}%", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = when (phase) { + QuickPatchPhase.DOWNLOADING -> "Preparing..." + QuickPatchPhase.PATCHING -> "Patching..." + else -> "" + }, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = statusMessage, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + TextButton(onClick = onCancel) { + Text("Cancel", color = MaterialTheme.colorScheme.error) + } + } +} + +@Composable +private fun CompletedContent( + outputPath: String, + apkInfo: QuickApkInfo, + onPatchAnother: () -> Unit +) { + val outputFile = File(outputPath) + val adbManager = remember { AdbManager() } + var isAdbAvailable by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + isAdbAvailable = adbManager.isAdbAvailable() + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Success", + tint = MorpheColors.Teal, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Patching Complete!", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = outputFile.name, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (outputFile.exists()) { + Text( + text = formatFileSize(outputFile.length()), + fontSize = 13.sp, + color = MorpheColors.Teal + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + try { + val folder = outputFile.parentFile + if (folder != null && Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(folder) + } + } catch (e: Exception) { } + }, + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Open Folder") + } + + Button( + onClick = onPatchAnother, + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Blue + ), + shape = RoundedCornerShape(8.dp) + ) { + Text("Patch Another") + } + } + + if (isAdbAvailable == true) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Connect your device via USB to install with ADB", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SupportedAppsRow( + supportedApps: List, + isLoading: Boolean, + patchesVersion: String?, + onOpenUrl: (String) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Get the APK from APKMirror:", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (patchesVersion != null) { + Text( + text = "Patches: $patchesVersion", + fontSize = 11.sp, + color = MorpheColors.Blue.copy(alpha = 0.8f) + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (isLoading) { + // Loading state + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MorpheColors.Blue + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Loading supported apps...", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (supportedApps.isEmpty()) { + // No apps loaded + Text( + text = "Could not load supported apps", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + // Show supported apps dynamically + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + supportedApps.forEach { app -> + val url = app.apkMirrorUrl + if (url != null) { + OutlinedCard( + onClick = { onOpenUrl(url) }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource( + when (app.packageName) { + AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube + AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music + AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit + else -> Res.drawable.youtube // Fallback + } + ), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = app.displayName, + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + app.recommendedVersion?.let { version -> + Text( + text = "v$version", + fontSize = 10.sp, + color = MorpheColors.Teal + ) + } + } + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } + } + } +} + +/** + * Shows verification status (version + checksum) in a compact banner. + */ +@Composable +private fun VerificationStatusBanner( + checksumStatus: ChecksumStatus, + isRecommendedVersion: Boolean, + currentVersion: String, + suggestedVersion: String +) { + when { + // Recommended version with verified checksum + checksumStatus is ChecksumStatus.Verified -> { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MorpheColors.Teal.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.VerifiedUser, + contentDescription = null, + tint = MorpheColors.Teal, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "Recommended version • Verified", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MorpheColors.Teal + ) + Text( + text = "Checksum matches APKMirror", + fontSize = 11.sp, + color = MorpheColors.Teal.copy(alpha = 0.8f) + ) + } + } + } + } + + // Checksum mismatch - warning + checksumStatus is ChecksumStatus.Mismatch -> { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "Checksum mismatch", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error + ) + Text( + text = "File may be corrupted. Re-download from APKMirror.", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + ) + } + } + } + } + + // Recommended version but no checksum configured + isRecommendedVersion && checksumStatus is ChecksumStatus.NotConfigured -> { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MorpheColors.Teal.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MorpheColors.Teal, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Using recommended version", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MorpheColors.Teal + ) + } + } + } + + // Non-recommended version (older or newer) + !isRecommendedVersion -> { + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xFFFF9800).copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = Color(0xFFFF9800), + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "Version $currentVersion", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFFFF9800) + ) + Text( + text = "Recommended: v$suggestedVersion. Patching may have issues.", + fontSize = 11.sp, + color = Color(0xFFFF9800).copy(alpha = 0.8f) + ) + } + } + } + } + + // Checksum error + checksumStatus is ChecksumStatus.Error -> { + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xFFFF9800).copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFFFF9800), + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Recommended version (checksum unavailable)", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFFFF9800) + ) + } + } + } + } +} + +/** + * Open native file picker. + */ +private fun openFilePicker(): File? { + val chooser = JFileChooser().apply { + dialogTitle = "Select APK" + fileFilter = FileNameExtensionFilter("APK Files", "apk") + isAcceptAllFileFilterUsed = false + } + + return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + chooser.selectedFile + } else null +} + +private fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt new file mode 100644 index 0000000..ed391d9 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -0,0 +1,470 @@ +package app.morphe.gui.ui.screens.quick + +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.data.model.Patch +import app.morphe.gui.data.model.PatchConfig +import app.morphe.gui.data.model.SupportedApp +import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.gui.data.repository.PatchRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import net.dongliu.apk.parser.ApkFile +import app.morphe.gui.util.ChecksumStatus +import app.morphe.gui.util.ChecksumUtils +import app.morphe.gui.util.Logger +import app.morphe.gui.util.PatchService +import app.morphe.gui.util.SupportedAppExtractor +import java.io.File + +/** + * ViewModel for Quick Patch mode - handles the entire flow in one screen. + */ +class QuickPatchViewModel( + private val patchRepository: PatchRepository, + private val patchService: PatchService, + private val configRepository: ConfigRepository +) : ScreenModel { + + private val _uiState = MutableStateFlow(QuickPatchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var patchingJob: Job? = null + + // Cached dynamic data from patches + private var cachedPatches: List = emptyList() + private var cachedSupportedApps: List = emptyList() + private var cachedPatchesFile: File? = null + + init { + // Load patches on startup to get dynamic app info + loadPatchesAndSupportedApps() + } + + /** + * Load patches from GitHub and extract supported apps dynamically. + */ + private fun loadPatchesAndSupportedApps() { + screenModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingPatches = true) + + try { + // Check for saved version in config + val config = configRepository.loadConfig() + val savedVersion = config.lastPatchesVersion + + // Fetch releases + val releasesResult = patchRepository.fetchReleases() + val releases = releasesResult.getOrNull() + + if (releases.isNullOrEmpty()) { + Logger.warn("Quick mode: Could not fetch releases") + _uiState.value = _uiState.value.copy(isLoadingPatches = false) + return@launch + } + + // Find release to use + val latestStable = releases.firstOrNull { !it.isDevRelease() } + val release = if (savedVersion != null) { + releases.find { it.tagName == savedVersion } ?: latestStable + } else { + latestStable + } + + if (release == null) { + Logger.warn("Quick mode: No suitable release found") + _uiState.value = _uiState.value.copy(isLoadingPatches = false) + return@launch + } + + // Download patches + val patchFileResult = patchRepository.downloadPatches(release) + val patchFile = patchFileResult.getOrNull() + + if (patchFile == null) { + Logger.warn("Quick mode: Could not download patches") + _uiState.value = _uiState.value.copy(isLoadingPatches = false) + return@launch + } + + cachedPatchesFile = patchFile + + // Load patches using PatchService (direct library call) + val patchesResult = patchService.listPatches(patchFile.absolutePath) + val patches = patchesResult.getOrNull() + + if (patches.isNullOrEmpty()) { + Logger.warn("Quick mode: Could not load patches: ${patchesResult.exceptionOrNull()?.message}") + _uiState.value = _uiState.value.copy(isLoadingPatches = false) + return@launch + } + + cachedPatches = patches + + // Extract supported apps dynamically + val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) + cachedSupportedApps = supportedApps + + Logger.info("Quick mode: Loaded ${supportedApps.size} supported apps: ${supportedApps.map { "${it.displayName} (${it.recommendedVersion})" }}") + + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + supportedApps = supportedApps, + patchesVersion = release.tagName + ) + } catch (e: Exception) { + Logger.error("Quick mode: Failed to load patches", e) + _uiState.value = _uiState.value.copy(isLoadingPatches = false) + } + } + } + + /** + * Handle file drop or selection. + */ + fun onFileSelected(file: File) { + screenModelScope.launch { + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.ANALYZING, + error = null + ) + + val result = analyzeApk(file) + if (result != null) { + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.READY, + apkFile = file, + apkInfo = result + ) + } else { + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.IDLE, + error = _uiState.value.error ?: "Failed to analyze APK" + ) + } + } + } + + /** + * Analyze the APK file using dynamic data from patches. + */ + private suspend fun analyzeApk(file: File): QuickApkInfo? { + if (!file.exists() || !file.name.endsWith(".apk", ignoreCase = true)) { + _uiState.value = _uiState.value.copy(error = "Please select a valid APK file") + return null + } + + return try { + ApkFile(file).use { apk -> + val meta = apk.apkMeta + val packageName = meta.packageName + val versionName = meta.versionName ?: "Unknown" + + // Check if supported using dynamic data + val dynamicAppInfo = cachedSupportedApps.find { it.packageName == packageName } + + if (dynamicAppInfo == null) { + // Fallback to hardcoded check if patches not loaded yet + val supportedPackages = if (cachedSupportedApps.isEmpty()) { + listOf( + AppConstants.YouTube.PACKAGE_NAME, + AppConstants.YouTubeMusic.PACKAGE_NAME, + AppConstants.Reddit.PACKAGE_NAME + ) + } else { + cachedSupportedApps.map { it.packageName } + } + + if (packageName !in supportedPackages) { + _uiState.value = _uiState.value.copy( + error = "Unsupported app: $packageName\n\nSupported apps: ${cachedSupportedApps.map { it.displayName }.ifEmpty { listOf("YouTube", "YouTube Music", "Reddit") }.joinToString(", ")}" + ) + return null + } + } + + // Get display name and recommended version from dynamic data, fallback to constants + val displayName = dynamicAppInfo?.displayName + ?: SupportedApp.getDisplayName(packageName) + + val recommendedVersion = dynamicAppInfo?.recommendedVersion + ?: AppConstants.getSuggestedVersion(packageName) + + // Version check + val isRecommendedVersion = recommendedVersion != null && versionName == recommendedVersion + val versionWarning = if (!isRecommendedVersion && recommendedVersion != null) { + "Version $versionName may have compatibility issues. Recommended: $recommendedVersion" + } else null + + // Checksum verification (still uses AppConstants - checksums are manually maintained) + val checksumStatus = verifyChecksum(file, packageName, versionName, recommendedVersion) + + Logger.info("Quick mode: Analyzed $displayName v$versionName (recommended: $recommendedVersion)") + + QuickApkInfo( + fileName = file.name, + packageName = packageName, + versionName = versionName, + fileSize = file.length(), + displayName = displayName, + recommendedVersion = recommendedVersion, + isRecommendedVersion = isRecommendedVersion, + versionWarning = versionWarning, + checksumStatus = checksumStatus + ) + } + } catch (e: Exception) { + Logger.error("Quick mode: Failed to analyze APK", e) + _uiState.value = _uiState.value.copy(error = "Failed to read APK: ${e.message}") + null + } + } + + /** + * Verify checksum against known values. + */ + private fun verifyChecksum(file: File, packageName: String, version: String, recommendedVersion: String?): ChecksumStatus { + // Check if this is a non-recommended version (use dynamic recommended version) + if (recommendedVersion != null && version != recommendedVersion) { + return ChecksumStatus.NonRecommendedVersion + } + + val expectedChecksum = AppConstants.getChecksum(packageName, version, emptyList()) + ?: return ChecksumStatus.NotConfigured + + return try { + val actualChecksum = ChecksumUtils.calculateSha256(file) + if (actualChecksum.equals(expectedChecksum, ignoreCase = true)) { + ChecksumStatus.Verified + } else { + ChecksumStatus.Mismatch(expectedChecksum, actualChecksum) + } + } catch (e: Exception) { + ChecksumStatus.Error(e.message ?: "Unknown error") + } + } + + /** + * Start the patching process with defaults. + */ + fun startPatching() { + val apkFile = _uiState.value.apkFile ?: return + val apkInfo = _uiState.value.apkInfo ?: return + + patchingJob = screenModelScope.launch { + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.DOWNLOADING, + progress = 0f, + statusMessage = "Preparing patches..." + ) + + // Use cached patches file if available, otherwise download + val patchFile = if (cachedPatchesFile?.exists() == true) { + _uiState.value = _uiState.value.copy(progress = 0.3f) + cachedPatchesFile!! + } else { + // Download patches + val patchesResult = patchRepository.getLatestStableRelease() + val patchRelease = patchesResult.getOrNull() + if (patchRelease == null) { + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.READY, + error = "Failed to fetch patches. Check your internet connection." + ) + return@launch + } + + _uiState.value = _uiState.value.copy( + statusMessage = "Downloading patches ${patchRelease.tagName}..." + ) + + val patchFileResult = patchRepository.downloadPatches(patchRelease) { progress -> + _uiState.value = _uiState.value.copy(progress = progress * 0.3f) + } + + val downloadedFile = patchFileResult.getOrNull() + if (downloadedFile == null) { + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.READY, + error = "Failed to download patches: ${patchFileResult.exceptionOrNull()?.message}" + ) + return@launch + } + cachedPatchesFile = downloadedFile + downloadedFile + } + + // 2. Start patching + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.PATCHING, + statusMessage = "Patching...", + progress = 0.4f + ) + + // Generate output path + val outputDir = apkFile.parentFile ?: File(System.getProperty("user.home")) + val baseName = apkInfo.displayName.replace(" ", "-") + val outputFileName = "$baseName-Morphe-${apkInfo.versionName}.apk" + val outputPath = File(outputDir, outputFileName).absolutePath + + // Use PatchService for direct library patching (no CLI subprocess) + val patchResult = patchService.patch( + patchesFilePath = patchFile.absolutePath, + inputApkPath = apkFile.absolutePath, + outputApkPath = outputPath, + enabledPatches = emptyList(), // Empty = use defaults + disabledPatches = emptyList(), + options = emptyMap(), + exclusiveMode = false, // Include all default patches + onProgress = { message -> + // Update status with current operation + if (message.contains("patch", ignoreCase = true) || + message.contains("applying", ignoreCase = true) || + message.contains("Applied", ignoreCase = true)) { + _uiState.value = _uiState.value.copy(statusMessage = message.take(60)) + } + // Parse progress + parseProgress(message) + } + ) + + patchResult.fold( + onSuccess = { result -> + if (result.success) { + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.COMPLETED, + outputPath = outputPath, + progress = 1f, + statusMessage = "Patching complete! Applied ${result.appliedPatches.size} patches." + ) + Logger.info("Quick mode: Patching completed - $outputPath (${result.appliedPatches.size} patches)") + } else { + val errorMsg = if (result.failedPatches.isNotEmpty()) { + "Patching had failures: ${result.failedPatches.joinToString(", ")}" + } else { + "Patching failed. Please try the full mode for more details." + } + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.READY, + error = errorMsg + ) + } + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.READY, + error = "Error: ${e.message}" + ) + } + ) + } + } + + /** + * Parse progress from CLI output. + */ + private fun parseProgress(line: String) { + // Pattern: "Executing patch X of Y" + val executingPattern = Regex("""(?:Executing|Applying)\s+patch\s+(\d+)\s+of\s+(\d+)""", RegexOption.IGNORE_CASE) + val match = executingPattern.find(line) + if (match != null) { + val current = match.groupValues[1].toIntOrNull() ?: 0 + val total = match.groupValues[2].toIntOrNull() ?: 1 + val patchProgress = current.toFloat() / total.toFloat() + // Patching is 50-100% of total progress + _uiState.value = _uiState.value.copy( + progress = 0.5f + patchProgress * 0.5f + ) + } + } + + /** + * Cancel patching. + */ + fun cancelPatching() { + patchingJob?.cancel() + patchingJob = null + _uiState.value = _uiState.value.copy( + phase = QuickPatchPhase.READY, + statusMessage = "Cancelled" + ) + } + + /** + * Reset to start over. + */ + fun reset() { + patchingJob?.cancel() + patchingJob = null + _uiState.value = QuickPatchUiState() + } + + /** + * Clear error message. + */ + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + fun setDragHover(isHovering: Boolean) { + _uiState.value = _uiState.value.copy(isDragHovering = isHovering) + } +} + +/** + * Phases of the quick patch flow. + */ +enum class QuickPatchPhase { + IDLE, // Waiting for APK + ANALYZING, // Reading APK info + READY, // APK validated, ready to patch + DOWNLOADING, // Downloading patches/CLI + PATCHING, // Running patch command + COMPLETED // Done! +} + +/** + * Simplified APK info for quick mode. + * Uses dynamic data from patches instead of hardcoded values. + */ +data class QuickApkInfo( + val fileName: String, + val packageName: String, + val versionName: String, + val fileSize: Long, + val displayName: String, + val recommendedVersion: String?, + val isRecommendedVersion: Boolean, + val versionWarning: String?, + val checksumStatus: ChecksumStatus +) { + val formattedSize: String + get() = when { + fileSize < 1024 -> "$fileSize B" + fileSize < 1024 * 1024 -> "%.1f KB".format(fileSize / 1024.0) + fileSize < 1024 * 1024 * 1024 -> "%.1f MB".format(fileSize / (1024.0 * 1024.0)) + else -> "%.2f GB".format(fileSize / (1024.0 * 1024.0 * 1024.0)) + } +} + +/** + * UI state for quick patch mode. + */ +data class QuickPatchUiState( + val phase: QuickPatchPhase = QuickPatchPhase.IDLE, + val apkFile: File? = null, + val apkInfo: QuickApkInfo? = null, + val error: String? = null, + val isDragHovering: Boolean = false, + val progress: Float = 0f, + val statusMessage: String = "", + val outputPath: String? = null, + // Dynamic data from patches + val isLoadingPatches: Boolean = true, + val supportedApps: List = emptyList(), + val patchesVersion: String? = null +) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt new file mode 100644 index 0000000..ba5a4e2 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -0,0 +1,753 @@ +package app.morphe.gui.ui.screens.result + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.ui.graphics.Color +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Usb +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import app.morphe.gui.data.repository.ConfigRepository +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.AdbDevice +import app.morphe.gui.util.AdbException +import app.morphe.gui.util.AdbManager +import app.morphe.gui.util.DeviceStatus +import app.morphe.gui.util.FileUtils +import app.morphe.gui.util.Logger +import java.awt.Desktop +import java.io.File + +/** + * Screen showing the result of patching. + */ +data class ResultScreen( + val outputPath: String +) : Screen { + + @Composable + override fun Content() { + ResultScreenContent(outputPath = outputPath) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ResultScreenContent(outputPath: String) { + val navigator = LocalNavigator.currentOrThrow + val outputFile = File(outputPath) + val scope = rememberCoroutineScope() + val adbManager = remember { AdbManager() } + val configRepository: ConfigRepository = koinInject() + + // ADB state + var isAdbAvailable by remember { mutableStateOf(null) } + var connectedDevices by remember { mutableStateOf>(emptyList()) } + var selectedDevice by remember { mutableStateOf(null) } + var isLoadingDevices by remember { mutableStateOf(false) } + var isInstalling by remember { mutableStateOf(false) } + var installProgress by remember { mutableStateOf("") } + var installError by remember { mutableStateOf(null) } + var installSuccess by remember { mutableStateOf(false) } + + // Cleanup state + var hasTempFiles by remember { mutableStateOf(false) } + var tempFilesSize by remember { mutableStateOf(0L) } + var tempFilesCleared by remember { mutableStateOf(false) } + var autoCleanupEnabled by remember { mutableStateOf(false) } + + // Check for temp files and auto-cleanup setting + LaunchedEffect(Unit) { + val config = configRepository.loadConfig() + autoCleanupEnabled = config.autoCleanupTempFiles + hasTempFiles = FileUtils.hasTempFiles() + tempFilesSize = FileUtils.getTempDirSize() + + // Auto-cleanup if enabled + if (autoCleanupEnabled && hasTempFiles) { + FileUtils.cleanupAllTempDirs() + hasTempFiles = false + tempFilesCleared = true + Logger.info("Auto-cleaned temp files after successful patching") + } + } + + // Function to refresh device list + fun refreshDevices() { + scope.launch { + isLoadingDevices = true + val result = adbManager.getConnectedDevices() + result.fold( + onSuccess = { devices -> + connectedDevices = devices + // Auto-select if only one ready device + val readyDevices = devices.filter { it.isReady } + if (readyDevices.size == 1) { + selectedDevice = readyDevices.first() + } else if (selectedDevice != null && !readyDevices.any { it.id == selectedDevice?.id }) { + // Clear selection if previously selected device is no longer available + selectedDevice = null + } + }, + onFailure = { + connectedDevices = emptyList() + selectedDevice = null + } + ) + isLoadingDevices = false + } + } + + // Check ADB availability and fetch devices on load + LaunchedEffect(Unit) { + isAdbAvailable = adbManager.isAdbAvailable() + if (isAdbAvailable == true) { + refreshDevices() + } + } + + // Install function + fun installViaAdb() { + val device = selectedDevice ?: return + scope.launch { + isInstalling = true + installError = null + installProgress = "Installing on ${device.displayName}..." + + val result = adbManager.installApk( + apkPath = outputPath, + deviceId = device.id, + onProgress = { installProgress = it } + ) + + result.fold( + onSuccess = { + installSuccess = true + installProgress = "Installation successful!" + }, + onFailure = { exception -> + installError = (exception as? AdbException)?.message ?: exception.message ?: "Unknown error" + } + ) + + isInstalling = false + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + val scrollState = rememberScrollState() + + // Estimate content height for dynamic spacing + val contentHeight = 600.dp // Approximate height of all content + val extraSpace = (maxHeight - contentHeight).coerceAtLeast(0.dp) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(32.dp) + ) { + // Add top spacing to center content on large screens + Spacer(modifier = Modifier.height(extraSpace / 2)) + // Success icon + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Success", + tint = MorpheColors.Teal, + modifier = Modifier.size(80.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Patching Complete!", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Your patched APK is ready", + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Output file info card + Card( + modifier = Modifier.widthIn(max = 500.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + Text( + text = "Output File", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = outputFile.name, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = outputFile.parent ?: "", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (outputFile.exists()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatFileSize(outputFile.length()), + fontSize = 13.sp, + color = MorpheColors.Teal + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // ADB Install Section + if (isAdbAvailable == true) { + AdbInstallSection( + devices = connectedDevices, + selectedDevice = selectedDevice, + isLoadingDevices = isLoadingDevices, + isInstalling = isInstalling, + installProgress = installProgress, + installError = installError, + installSuccess = installSuccess, + onDeviceSelected = { selectedDevice = it }, + onRefreshDevices = { refreshDevices() }, + onInstallClick = { installViaAdb() }, + onRetryClick = { + installError = null + installSuccess = false + installViaAdb() + }, + onDismissError = { installError = null } + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + + // Cleanup section + if (hasTempFiles || tempFilesCleared) { + CleanupSection( + hasTempFiles = hasTempFiles, + tempFilesSize = tempFilesSize, + tempFilesCleared = tempFilesCleared, + autoCleanupEnabled = autoCleanupEnabled, + onCleanupClick = { + FileUtils.cleanupAllTempDirs() + hasTempFiles = false + tempFilesCleared = true + Logger.info("Manually cleaned temp files after patching") + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + + // Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = { + try { + val folder = outputFile.parentFile + if (folder != null && Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(folder) + } + } catch (e: Exception) { + // Ignore errors + } + }, + modifier = Modifier.height(48.dp), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Open Folder") + } + + Button( + onClick = { navigator.popUntilRoot() }, + modifier = Modifier.height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Blue + ), + shape = RoundedCornerShape(12.dp) + ) { + Text("Patch Another", fontWeight = FontWeight.Medium) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Help text (only show when ADB is not available) + if (isAdbAvailable == false) { + Text( + text = "ADB not found. Install Android SDK Platform Tools to enable direct installation.", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + } else if (isAdbAvailable == null) { + Text( + text = "Checking for ADB...", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + } + + // Bottom spacing to center content on large screens + Spacer(modifier = Modifier.height(extraSpace / 2)) + } + } + + // Settings button in top-right corner + SettingsButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(24.dp), + allowCacheClear = false + ) + } +} + +@Composable +private fun AdbInstallSection( + devices: List, + selectedDevice: AdbDevice?, + isLoadingDevices: Boolean, + isInstalling: Boolean, + installProgress: String, + installError: String?, + installSuccess: Boolean, + onDeviceSelected: (AdbDevice) -> Unit, + onRefreshDevices: () -> Unit, + onInstallClick: () -> Unit, + onRetryClick: () -> Unit, + onDismissError: () -> Unit +) { + Card( + modifier = Modifier.widthIn(max = 500.dp), + colors = CardDefaults.cardColors( + containerColor = when { + installSuccess -> MorpheColors.Teal.copy(alpha = 0.1f) + installError != null -> MaterialTheme.colorScheme.error.copy(alpha = 0.1f) + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + } + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Usb, + contentDescription = null, + tint = MorpheColors.Blue, + modifier = Modifier.size(20.dp) + ) + Text( + text = "Install via ADB", + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ) + } + // Refresh button + IconButton( + onClick = onRefreshDevices, + enabled = !isLoadingDevices && !isInstalling + ) { + if (isLoadingDevices) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh devices", + modifier = Modifier.size(20.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + when { + installSuccess -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MorpheColors.Teal, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Installed successfully on ${selectedDevice?.displayName ?: "device"}!", + fontWeight = FontWeight.Medium, + color = MorpheColors.Teal + ) + } + } + + installError != null -> { + Text( + text = installError, + color = MaterialTheme.colorScheme.error, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + TextButton(onClick = onDismissError) { + Text("Dismiss") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = onRetryClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Retry") + } + } + } + + isInstalling -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MorpheColors.Blue + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = installProgress.ifEmpty { "Installing..." }, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + else -> { + // Device list + val readyDevices = devices.filter { it.isReady } + val notReadyDevices = devices.filter { !it.isReady } + + if (devices.isEmpty()) { + // No devices + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No devices connected", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 14.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Connect your Android device via USB with USB debugging enabled", + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + fontSize = 12.sp, + textAlign = TextAlign.Center + ) + } + } else { + // Show device list + Text( + text = if (readyDevices.size == 1) "Connected device:" else "Select a device:", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Ready devices + readyDevices.forEach { device -> + DeviceRow( + device = device, + isSelected = selectedDevice?.id == device.id, + onClick = { onDeviceSelected(device) } + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + // Not ready devices (unauthorized/offline) + notReadyDevices.forEach { device -> + DeviceRow( + device = device, + isSelected = false, + onClick = { }, + enabled = false + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Install button + Button( + onClick = onInstallClick, + modifier = Modifier.fillMaxWidth(), + enabled = selectedDevice != null, + colors = ButtonDefaults.buttonColors( + containerColor = MorpheColors.Teal + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = if (selectedDevice != null) + "Install on ${selectedDevice.displayName}" + else + "Select a device to install", + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + } +} + +@Composable +private fun CleanupSection( + hasTempFiles: Boolean, + tempFilesSize: Long, + tempFilesCleared: Boolean, + autoCleanupEnabled: Boolean, + onCleanupClick: () -> Unit +) { + Card( + modifier = Modifier.widthIn(max = 500.dp), + colors = CardDefaults.cardColors( + containerColor = if (tempFilesCleared) + MorpheColors.Teal.copy(alpha = 0.1f) + else + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (tempFilesCleared) "Temp files cleaned" else "Temporary files", + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = if (tempFilesCleared) + MorpheColors.Teal + else + MaterialTheme.colorScheme.onSurface + ) + Text( + text = when { + tempFilesCleared && autoCleanupEnabled -> "Auto-cleanup is enabled" + tempFilesCleared -> "Freed up ${formatFileSize(tempFilesSize)}" + else -> "${formatFileSize(tempFilesSize)} can be freed" + }, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (hasTempFiles && !tempFilesCleared) { + OutlinedButton( + onClick = onCleanupClick, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + Text("Clean up", fontSize = 13.sp) + } + } else if (tempFilesCleared) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MorpheColors.Teal, + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Composable +private fun DeviceRow( + device: AdbDevice, + isSelected: Boolean, + onClick: () -> Unit, + enabled: Boolean = true +) { + OutlinedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + shape = RoundedCornerShape(8.dp), + border = BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = when { + isSelected -> MorpheColors.Teal + !enabled -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + } + ), + colors = CardDefaults.outlinedCardColors( + containerColor = if (isSelected) + MorpheColors.Teal.copy(alpha = 0.08f) + else + MaterialTheme.colorScheme.surface + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = null, + tint = when { + isSelected -> MorpheColors.Teal + device.isReady -> MorpheColors.Blue + else -> MaterialTheme.colorScheme.error.copy(alpha = 0.6f) + }, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.displayName, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = if (enabled) + MaterialTheme.colorScheme.onSurface + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + fontSize = 14.sp + ) + Text( + text = device.id, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + // Status badge + Surface( + color = when (device.status) { + DeviceStatus.DEVICE -> MorpheColors.Teal.copy(alpha = 0.15f) + DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800).copy(alpha = 0.15f) + else -> MaterialTheme.colorScheme.error.copy(alpha = 0.15f) + }, + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = when (device.status) { + DeviceStatus.DEVICE -> "Ready" + DeviceStatus.UNAUTHORIZED -> "Unauthorized" + DeviceStatus.OFFLINE -> "Offline" + DeviceStatus.UNKNOWN -> "Unknown" + }, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + color = when (device.status) { + DeviceStatus.DEVICE -> MorpheColors.Teal + DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800) + else -> MaterialTheme.colorScheme.error + }, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } +} + +private fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt new file mode 100644 index 0000000..f980d43 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt @@ -0,0 +1,79 @@ +package app.morphe.gui.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +// Morphe Brand Colors +object MorpheColors { + val Blue = Color(0xFF2D62DD) + val Teal = Color(0xFF00A797) + val Cyan = Color(0xFF62E1FF) + val DeepBlack = Color(0xFF121212) + val SurfaceDark = Color(0xFF1E1E1E) + val SurfaceLight = Color(0xFFF5F5F5) + val TextLight = Color(0xFFE3E3E3) + val TextDark = Color(0xFF1C1C1C) +} + +private val MorpheDarkColorScheme = darkColorScheme( + primary = MorpheColors.Blue, + secondary = MorpheColors.Teal, + tertiary = MorpheColors.Cyan, + background = Color(0xFF121212), + surface = Color(0xFF1E1E1E), + surfaceVariant = Color(0xFF2A2A2A), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.Black, + onBackground = MorpheColors.TextLight, + onSurface = MorpheColors.TextLight, + onSurfaceVariant = Color(0xFFB0B0B0), + error = Color(0xFFCF6679), + onError = Color.Black +) + +private val MorpheLightColorScheme = lightColorScheme( + primary = MorpheColors.Blue, + secondary = MorpheColors.Teal, + tertiary = MorpheColors.Cyan, + background = Color(0xFFFAFAFA), + surface = MorpheColors.SurfaceLight, + surfaceVariant = Color(0xFFE8E8E8), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.Black, + onBackground = MorpheColors.TextDark, + onSurface = MorpheColors.TextDark, + onSurfaceVariant = Color(0xFF505050), + error = Color(0xFFB00020), + onError = Color.White +) + +enum class ThemePreference { + LIGHT, + DARK, + SYSTEM +} + +@Composable +fun MorpheTheme( + themePreference: ThemePreference = ThemePreference.SYSTEM, + content: @Composable () -> Unit +) { + val colorScheme = when (themePreference) { + ThemePreference.DARK -> MorpheDarkColorScheme + ThemePreference.LIGHT -> MorpheLightColorScheme + ThemePreference.SYSTEM -> { + if (isSystemInDarkTheme()) MorpheDarkColorScheme else MorpheLightColorScheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/ThemeState.kt b/src/main/kotlin/app/morphe/gui/ui/theme/ThemeState.kt new file mode 100644 index 0000000..838bb19 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/theme/ThemeState.kt @@ -0,0 +1,17 @@ +package app.morphe.gui.ui.theme + +import androidx.compose.runtime.compositionLocalOf + +/** + * Holds the current theme state and callback to change it. + * Provided via CompositionLocal so any screen can access it. + */ +data class ThemeState( + val current: ThemePreference = ThemePreference.SYSTEM, + val onChange: (ThemePreference) -> Unit = {} +) + +/** + * CompositionLocal for accessing theme state from any composable. + */ +val LocalThemeState = compositionLocalOf { ThemeState() } diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt new file mode 100644 index 0000000..94f933c --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -0,0 +1,359 @@ +package app.morphe.gui.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Manages ADB (Android Debug Bridge) operations for installing APKs. + * Works across macOS, Linux, and Windows. + */ +class AdbManager { + + private var adbPath: String? = null + + /** + * Find ADB binary in common locations or PATH. + * Returns the path to ADB if found, null otherwise. + */ + suspend fun findAdb(): String? = withContext(Dispatchers.IO) { + // Return cached path if already found + adbPath?.let { + if (File(it).exists()) return@withContext it + } + + val os = System.getProperty("os.name").lowercase() + val isWindows = os.contains("windows") + val isMac = os.contains("mac") + val adbName = if (isWindows) "adb.exe" else "adb" + + // Common ADB locations by platform + val searchPaths = mutableListOf() + + if (isMac) { + // macOS paths + val home = System.getProperty("user.home") + searchPaths.addAll(listOf( + "$home/Library/Android/sdk/platform-tools/$adbName", + "/opt/homebrew/bin/$adbName", + "/usr/local/bin/$adbName", + "/Applications/Android Studio.app/Contents/platform-tools/$adbName" + )) + } else if (isWindows) { + // Windows paths + val localAppData = System.getenv("LOCALAPPDATA") ?: "" + val userProfile = System.getenv("USERPROFILE") ?: "" + searchPaths.addAll(listOf( + "$localAppData\\Android\\Sdk\\platform-tools\\$adbName", + "$userProfile\\AppData\\Local\\Android\\Sdk\\platform-tools\\$adbName", + "C:\\Android\\sdk\\platform-tools\\$adbName", + "C:\\Program Files\\Android\\platform-tools\\$adbName" + )) + } else { + // Linux paths + val home = System.getProperty("user.home") + searchPaths.addAll(listOf( + "$home/Android/Sdk/platform-tools/$adbName", + "$home/android-sdk/platform-tools/$adbName", + "/opt/android-sdk/platform-tools/$adbName", + "/usr/bin/$adbName", + "/usr/local/bin/$adbName" + )) + } + + // Check each path + for (path in searchPaths) { + val file = File(path) + if (file.exists() && file.canExecute()) { + Logger.info("Found ADB at: $path") + adbPath = path + return@withContext path + } + } + + // Try to find in PATH + try { + val process = ProcessBuilder(if (isWindows) listOf("where", adbName) else listOf("which", adbName)) + .redirectErrorStream(true) + .start() + + val result = process.inputStream.bufferedReader().readText().trim() + process.waitFor() + + if (process.exitValue() == 0 && result.isNotEmpty()) { + val path = result.lines().first() + if (File(path).exists()) { + Logger.info("Found ADB in PATH: $path") + adbPath = path + return@withContext path + } + } + } catch (e: Exception) { + Logger.debug("Could not find ADB in PATH: ${e.message}") + } + + Logger.warn("ADB not found") + null + } + + /** + * Check if ADB is available. + */ + suspend fun isAdbAvailable(): Boolean = findAdb() != null + + /** + * Get list of connected devices. + * Returns list of device IDs and their status. + */ + suspend fun getConnectedDevices(): Result> = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + try { + // Use -l flag to get detailed device info including model + val process = ProcessBuilder(adb, "devices", "-l") + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + + if (exitCode != 0) { + return@withContext Result.failure( + AdbException("Failed to get device list: $output") + ) + } + + val devices = parseDeviceList(output, adb) + Logger.info("Found ${devices.size} device(s)") + Result.success(devices) + } catch (e: Exception) { + Logger.error("Error getting devices", e) + Result.failure(AdbException("Failed to get devices: ${e.message}")) + } + } + + /** + * Install an APK on the specified device (or default device if only one connected). + */ + suspend fun installApk( + apkPath: String, + deviceId: String? = null, + allowDowngrade: Boolean = true, + onProgress: (String) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + val apkFile = File(apkPath) + if (!apkFile.exists()) { + return@withContext Result.failure(AdbException("APK file not found: $apkPath")) + } + + // Check connected devices + val devicesResult = getConnectedDevices() + if (devicesResult.isFailure) { + return@withContext Result.failure(devicesResult.exceptionOrNull()!!) + } + + val devices = devicesResult.getOrThrow() + val authorizedDevices = devices.filter { it.status == DeviceStatus.DEVICE } + + if (authorizedDevices.isEmpty()) { + val unauthorized = devices.filter { it.status == DeviceStatus.UNAUTHORIZED } + return@withContext Result.failure( + if (unauthorized.isNotEmpty()) { + AdbException("Device connected but not authorized. Please accept the USB debugging prompt on your device.") + } else { + AdbException("No devices connected. Please connect your Android device with USB debugging enabled.") + } + ) + } + + // Determine target device + val targetDevice = if (deviceId != null) { + authorizedDevices.find { it.id == deviceId } + ?: return@withContext Result.failure(AdbException("Device $deviceId not found")) + } else if (authorizedDevices.size == 1) { + authorizedDevices.first() + } else { + return@withContext Result.failure( + AdbMultipleDevicesException( + "Multiple devices connected. Please select one.", + authorizedDevices + ) + ) + } + + // Build install command + val command = mutableListOf(adb) + command.add("-s") + command.add(targetDevice.id) + command.add("install") + command.add("-r") // Replace existing + if (allowDowngrade) { + command.add("-d") // Allow downgrade + } + command.add(apkPath) + + onProgress("Installing on ${targetDevice.displayName}...") + Logger.info("Running: ${command.joinToString(" ")}") + + try { + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + + // Read output in real-time + val reader = process.inputStream.bufferedReader() + val output = StringBuilder() + reader.forEachLine { line -> + output.appendLine(line) + onProgress(line) + Logger.debug("ADB: $line") + } + + val exitCode = process.waitFor() + val outputStr = output.toString() + + if (exitCode == 0 && outputStr.contains("Success")) { + Logger.info("APK installed successfully") + Result.success(Unit) + } else { + val errorMessage = parseInstallError(outputStr) + Logger.error("Installation failed: $errorMessage") + Result.failure(AdbException(errorMessage)) + } + } catch (e: Exception) { + Logger.error("Error installing APK", e) + Result.failure(AdbException("Installation failed: ${e.message}")) + } + } + + /** + * Parse output from 'adb devices -l' command. + * Example line: "XXXXXXXX device usb:1-1 product:flame model:Pixel_4 device:flame transport_id:1" + */ + private fun parseDeviceList(output: String, adbPath: String): List { + return output.lines() + .drop(1) // Skip "List of devices attached" header + .filter { it.isNotBlank() } + .mapNotNull { line -> + val parts = line.split("\\s+".toRegex()) + if (parts.size >= 2) { + val id = parts[0] + val status = when (parts[1]) { + "device" -> DeviceStatus.DEVICE + "unauthorized" -> DeviceStatus.UNAUTHORIZED + "offline" -> DeviceStatus.OFFLINE + else -> DeviceStatus.UNKNOWN + } + + // Parse model from the -l output (format: model:Device_Name) + var model: String? = null + var product: String? = null + for (part in parts.drop(2)) { + when { + part.startsWith("model:") -> model = part.removePrefix("model:").replace("_", " ") + part.startsWith("product:") -> product = part.removePrefix("product:") + } + } + + // If device is authorized, try to get friendly device name + val deviceName = if (status == DeviceStatus.DEVICE) { + model ?: product ?: getDeviceName(adbPath, id) + } else { + model ?: product + } + + AdbDevice(id, status, deviceName) + } else null + } + } + + /** + * Get device name using adb shell command. + */ + private fun getDeviceName(adbPath: String, deviceId: String): String? { + return try { + val process = ProcessBuilder(adbPath, "-s", deviceId, "shell", "getprop", "ro.product.model") + .redirectErrorStream(true) + .start() + val result = process.inputStream.bufferedReader().readText().trim() + process.waitFor() + if (process.exitValue() == 0 && result.isNotBlank()) result else null + } catch (e: Exception) { + null + } + } + + private fun parseInstallError(output: String): String { + // Common ADB install errors + return when { + output.contains("INSTALL_FAILED_VERSION_DOWNGRADE") -> + "Cannot downgrade - a newer version is installed. Uninstall the existing app first." + output.contains("INSTALL_FAILED_ALREADY_EXISTS") -> + "App already exists. Try uninstalling it first." + output.contains("INSTALL_FAILED_INSUFFICIENT_STORAGE") -> + "Not enough storage space on device." + output.contains("INSTALL_FAILED_INVALID_APK") -> + "Invalid APK file." + output.contains("INSTALL_PARSE_FAILED_NO_CERTIFICATES") -> + "APK is not signed properly." + output.contains("INSTALL_FAILED_UPDATE_INCOMPATIBLE") -> + "Incompatible update - signatures don't match. Uninstall the existing app first." + output.contains("INSTALL_FAILED_USER_RESTRICTED") -> + "Installation restricted by user settings." + output.contains("INSTALL_FAILED_VERIFICATION_FAILURE") -> + "Package verification failed." + output.contains("Failure") -> { + // Extract the failure reason + val match = Regex("Failure \\[(.+)]").find(output) + match?.groupValues?.get(1) ?: "Installation failed: $output" + } + else -> "Installation failed: $output" + } + } +} + +data class AdbDevice( + val id: String, + val status: DeviceStatus, + val model: String? = null +) { + /** Device name (model or ID if model unknown) */ + val displayName: String + get() = model?.takeIf { it.isNotBlank() } ?: id + + /** Full display with status for UI */ + val displayNameWithStatus: String + get() { + val name = displayName + return when (status) { + DeviceStatus.DEVICE -> "$name (Connected)" + DeviceStatus.UNAUTHORIZED -> "$name (Unauthorized - check device)" + DeviceStatus.OFFLINE -> "$name (Offline)" + DeviceStatus.UNKNOWN -> "$name (Unknown status)" + } + } + + /** Whether device is ready for installation */ + val isReady: Boolean + get() = status == DeviceStatus.DEVICE +} + +enum class DeviceStatus { + DEVICE, // Connected and authorized + UNAUTHORIZED, // Connected but not authorized for debugging + OFFLINE, // Device offline + UNKNOWN // Unknown status +} + +open class AdbException(message: String) : Exception(message) + +class AdbMultipleDevicesException( + message: String, + val devices: List +) : AdbException(message) diff --git a/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt b/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt new file mode 100644 index 0000000..67cfa15 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt @@ -0,0 +1,58 @@ +package app.morphe.gui.util + +import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest + +/** + * Utility for calculating and verifying file checksums. + */ +object ChecksumUtils { + + /** + * Calculate SHA-256 checksum of a file. + * @return Lowercase hex string of the checksum + */ + fun calculateSha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + + FileInputStream(file).use { fis -> + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + + return digest.digest().joinToString("") { "%02x".format(it) } + } + + /** + * Verify a file's checksum against expected value. + * @return true if checksums match (case-insensitive comparison) + */ + fun verifyChecksum(file: File, expectedChecksum: String): Boolean { + val actualChecksum = calculateSha256(file) + return actualChecksum.equals(expectedChecksum, ignoreCase = true) + } +} + +/** + * Result of checksum verification. + */ +sealed class ChecksumStatus { + /** Checksum matches the expected value - file is verified */ + data object Verified : ChecksumStatus() + + /** Checksum doesn't match - file may be corrupted or modified */ + data class Mismatch(val expected: String, val actual: String) : ChecksumStatus() + + /** No checksum configured for this version - cannot verify */ + data object NotConfigured : ChecksumStatus() + + /** Non-recommended version - checksum verification not applicable */ + data object NonRecommendedVersion : ChecksumStatus() + + /** Checksum calculation failed */ + data class Error(val message: String) : ChecksumStatus() +} diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt new file mode 100644 index 0000000..3906045 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -0,0 +1,149 @@ +package app.morphe.gui.util + +import java.io.File +import java.nio.file.Path +import java.nio.file.Paths + +/** + * Platform-agnostic file utilities. + * Handles app directories, temp files, and cross-platform path operations. + */ +object FileUtils { + + private const val APP_NAME = "morphe-gui" + + /** + * Get the app data directory based on OS. + * - Windows: %APPDATA%/morphe-gui + * - macOS: ~/Library/Application Support/morphe-gui + * - Linux: ~/.config/morphe-gui + */ + fun getAppDataDir(): File { + val osName = System.getProperty("os.name").lowercase() + val userHome = System.getProperty("user.home") + + val appDataPath = when { + osName.contains("win") -> { + val appData = System.getenv("APPDATA") ?: Paths.get(userHome, "AppData", "Roaming").toString() + Paths.get(appData, APP_NAME) + } + osName.contains("mac") -> { + Paths.get(userHome, "Library", "Application Support", APP_NAME) + } + else -> { + // Linux and others + Paths.get(userHome, ".config", APP_NAME) + } + } + + return appDataPath.toFile().also { it.mkdirs() } + } + + /** + * Get the patches cache directory. + */ + fun getPatchesDir(): File { + return File(getAppDataDir(), "patches").also { it.mkdirs() } + } + + /** + * Get the logs directory. + */ + fun getLogsDir(): File { + return File(getAppDataDir(), "logs").also { it.mkdirs() } + } + + /** + * Get the config file path. + */ + fun getConfigFile(): File { + return File(getAppDataDir(), "config.json") + } + + /** + * Get the app temp directory for patching operations. + */ + fun getTempDir(): File { + val systemTemp = System.getProperty("java.io.tmpdir") + return File(systemTemp, APP_NAME).also { it.mkdirs() } + } + + /** + * Create a unique temp directory for a patching session. + */ + fun createPatchingTempDir(): File { + val timestamp = System.currentTimeMillis() + return File(getTempDir(), "patching-$timestamp").also { it.mkdirs() } + } + + /** + * Clean up a temp directory. + */ + fun cleanupTempDir(dir: File): Boolean { + return try { + if (dir.exists() && dir.startsWith(getTempDir())) { + dir.deleteRecursively() + } else { + false + } + } catch (e: Exception) { + false + } + } + + /** + * Clean up all temp directories (call on app exit). + */ + fun cleanupAllTempDirs(): Boolean { + return try { + getTempDir().deleteRecursively() + true + } catch (e: Exception) { + false + } + } + + /** + * Get the size of all temp directories. + */ + fun getTempDirSize(): Long { + return try { + getTempDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + } catch (e: Exception) { + 0L + } + } + + /** + * Check if there are any temp files to clean. + */ + fun hasTempFiles(): Boolean { + return try { + val tempDir = getTempDir() + tempDir.exists() && (tempDir.listFiles()?.isNotEmpty() == true) + } catch (e: Exception) { + false + } + } + + /** + * Build a path using the system file separator. + */ + fun buildPath(vararg parts: String): String { + return parts.joinToString(File.separator) + } + + /** + * Get file extension. + */ + fun getExtension(file: File): String { + return file.extension.lowercase() + } + + /** + * Check if file is an APK. + */ + fun isApkFile(file: File): Boolean { + return file.isFile && getExtension(file) == "apk" + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/Logger.kt b/src/main/kotlin/app/morphe/gui/util/Logger.kt new file mode 100644 index 0000000..f8c310c --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/Logger.kt @@ -0,0 +1,219 @@ +package app.morphe.gui.util + +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.* + +/** + * Simple file logger with rotation support. + * Logs to ~/.morphe-gui/logs/morphe-gui.log + */ +object Logger { + + private const val MAX_LOG_SIZE = 2 * 1024 * 1024 // 2 MB + private const val MAX_LOG_FILES = 3 + private const val MAX_LINES_TO_KEEP = 5000 // Keep last 5000 lines on startup + private const val LOG_FILE_NAME = "morphe-gui.log" + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + private var logFile: File? = null + private var initialized = false + + enum class Level { + DEBUG, INFO, WARN, ERROR + } + + /** + * Initialize the logger. Call once at app startup. + */ + fun init() { + if (initialized) return + + try { + val logsDir = FileUtils.getLogsDir() + logFile = File(logsDir, LOG_FILE_NAME) + + // Trim log file if it's too large (keep only last N lines) + trimLogFile() + + // Rotate if needed + rotateIfNeeded() + + // Log startup info + info("=".repeat(60)) + info("Morphe-GUI Started") + info("Version: 1.0.0") + info("OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")}") + info("Java: ${System.getProperty("java.version")}") + info("User: ${System.getProperty("user.name")}") + info("App Data: ${FileUtils.getAppDataDir().absolutePath}") + info("=".repeat(60)) + + initialized = true + } catch (e: Exception) { + System.err.println("Failed to initialize logger: ${e.message}") + } + } + + /** + * Trim log file to keep only the last MAX_LINES_TO_KEEP lines. + */ + private fun trimLogFile() { + val file = logFile ?: return + if (!file.exists()) return + + try { + val lines = file.readLines() + if (lines.size > MAX_LINES_TO_KEEP) { + val trimmedLines = lines.takeLast(MAX_LINES_TO_KEEP) + file.writeText(trimmedLines.joinToString("\n") + "\n") + } + } catch (e: Exception) { + System.err.println("Failed to trim log file: ${e.message}") + } + } + + fun debug(message: String) = log(Level.DEBUG, message) + fun info(message: String) = log(Level.INFO, message) + fun warn(message: String) = log(Level.WARN, message) + fun error(message: String) = log(Level.ERROR, message) + + fun error(message: String, throwable: Throwable) { + val sw = StringWriter() + throwable.printStackTrace(PrintWriter(sw)) + log(Level.ERROR, "$message\n$sw") + } + + /** + * Log a CLI command execution. + */ + fun logCliCommand(command: List) { + info("CLI Command: ${command.joinToString(" ")}") + } + + /** + * Log CLI output. + */ + fun logCliOutput(output: String) { + if (output.isNotBlank()) { + debug("CLI Output: $output") + } + } + + private fun log(level: Level, message: String) { + val timestamp = dateFormat.format(Date()) + val logLine = "[$timestamp] [${level.name.padEnd(5)}] $message" + + // Print to console + when (level) { + Level.ERROR -> System.err.println(logLine) + else -> println(logLine) + } + + // Write to file + try { + logFile?.let { file -> + rotateIfNeeded() + file.appendText("$logLine\n") + } + } catch (e: Exception) { + System.err.println("Failed to write to log file: ${e.message}") + } + } + + private fun rotateIfNeeded() { + val file = logFile ?: return + if (!file.exists()) return + if (file.length() < MAX_LOG_SIZE) return + + try { + // Shift existing log files + for (i in MAX_LOG_FILES - 1 downTo 1) { + val older = File(file.parent, "$LOG_FILE_NAME.$i") + val newer = if (i == 1) file else File(file.parent, "$LOG_FILE_NAME.${i - 1}") + if (newer.exists()) { + if (older.exists()) older.delete() + newer.renameTo(older) + } + } + + // Create fresh log file + file.createNewFile() + } catch (e: Exception) { + System.err.println("Failed to rotate logs: ${e.message}") + } + } + + /** + * Get the current log file for export. + */ + fun getLogFile(): File? = logFile + + /** + * Get all log files for export. + */ + fun getAllLogFiles(): List { + val logsDir = FileUtils.getLogsDir() + return logsDir.listFiles() + ?.filter { it.name.startsWith(LOG_FILE_NAME) } + ?.sortedByDescending { it.lastModified() } + ?: emptyList() + } + + /** + * Export logs to a specified location. + */ + fun exportLogs(destination: File): Boolean { + return try { + val logs = getAllLogFiles() + if (logs.isEmpty()) return false + + if (logs.size == 1) { + logs.first().copyTo(destination, overwrite = true) + } else { + // Combine all logs into one file + destination.writeText("") + logs.reversed().forEach { log -> + destination.appendText("=== ${log.name} ===\n") + destination.appendText(log.readText()) + destination.appendText("\n") + } + } + true + } catch (e: Exception) { + error("Failed to export logs", e) + false + } + } + + /** + * Clear all log files. + */ + fun clearLogs(): Boolean { + return try { + val logsDir = FileUtils.getLogsDir() + logsDir.listFiles()?.forEach { it.delete() } + logFile?.createNewFile() + info("Logs cleared") + true + } catch (e: Exception) { + System.err.println("Failed to clear logs: ${e.message}") + false + } + } + + /** + * Get the total size of all log files. + */ + fun getLogsSize(): Long { + return try { + FileUtils.getLogsDir().walkTopDown() + .filter { it.isFile } + .sumOf { it.length() } + } catch (e: Exception) { + 0L + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt new file mode 100644 index 0000000..7fc5b87 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -0,0 +1,307 @@ +package app.morphe.gui.util + +import app.morphe.gui.data.model.CompatiblePackage +import app.morphe.gui.data.model.Patch +import app.morphe.gui.data.model.PatchOption +import app.morphe.gui.data.model.PatchOptionType +import app.morphe.library.ApkUtils +import app.morphe.library.ApkUtils.applyTo +import app.morphe.library.setOptions +import app.morphe.patcher.Patcher +import app.morphe.patcher.PatcherConfig +import app.morphe.patcher.patch.loadPatchesFromJar +import com.reandroid.apkeditor.merge.Merger +import com.reandroid.apkeditor.merge.MergerOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import kotlin.reflect.KType +import app.morphe.patcher.patch.Patch as LibraryPatch + +/** + * Bridge between GUI and morphe-patcher library. + * Replaces CliRunner with direct library calls. + */ +class PatchService { + + /** + * Load patches from an .mpp file and convert to GUI model. + * Optionally filter by package name. + */ + suspend fun listPatches( + patchesFilePath: String, + packageName: String? = null + ): Result> = withContext(Dispatchers.IO) { + try { + val patchFile = File(patchesFilePath) + if (!patchFile.exists()) { + return@withContext Result.failure(Exception("Patches file not found: $patchesFilePath")) + } + + Logger.info("Loading patches from: $patchesFilePath") + val patches = loadPatchesFromJar(setOf(patchFile)) + + // Convert library patches to GUI model + val guiPatches = patches.map { it.toGuiPatch() } + + // Filter by package name if specified + val filtered = if (packageName != null) { + guiPatches.filter { patch -> + patch.compatiblePackages.isEmpty() || // Universal patches + patch.compatiblePackages.any { it.name == packageName } + } + } else { + guiPatches + } + + Logger.info("Loaded ${filtered.size} patches" + (packageName?.let { " for $it" } ?: "")) + Result.success(filtered) + } catch (e: Exception) { + Logger.error("Failed to load patches", e) + Result.failure(e) + } + } + + /** + * Execute patching operation with progress callbacks. + */ + suspend fun patch( + patchesFilePath: String, + inputApkPath: String, + outputApkPath: String, + enabledPatches: List = emptyList(), + disabledPatches: List = emptyList(), + options: Map = emptyMap(), + exclusiveMode: Boolean = false, + onProgress: (String) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { + val tempDir = FileUtils.createPatchingTempDir() + val tempOutputPath = File(tempDir, File(outputApkPath).name) + + try { + val patchFile = File(patchesFilePath) + val inputApk = File(inputApkPath) + val outputFile = File(outputApkPath) + + if (!patchFile.exists()) { + return@withContext Result.failure(Exception("Patches file not found")) + } + if (!inputApk.exists()) { + return@withContext Result.failure(Exception("Input APK not found")) + } + + onProgress("Loading patches...") + val patches = loadPatchesFromJar(setOf(patchFile)) + + // Handle APKM format (split APK bundle) + var mergedApkToCleanup: File? = null + val actualInputApk = if (inputApk.extension.equals("apkm", ignoreCase = true)) { + onProgress("Converting APKM to APK...") + val mergedApk = File(tempDir, "${inputApk.nameWithoutExtension}-merged.apk") + val mergerOptions = MergerOptions().apply { + this.inputFile = inputApk + this.outputFile = mergedApk + cleanMeta = true + } + Merger(mergerOptions).run() + mergedApkToCleanup = mergedApk + mergedApk + } else { + inputApk + } + + val patcherTempDir = File(tempDir, "patcher") + patcherTempDir.mkdirs() + + onProgress("Initializing patcher...") + val patcherConfig = PatcherConfig( + actualInputApk, + patcherTempDir, + null, // aapt binary path + patcherTempDir.absolutePath + ) + + val appliedPatches = mutableListOf() + val failedPatches = mutableListOf>() + + Patcher(patcherConfig).use { patcher -> + val packageName = patcher.context.packageMetadata.packageName + val packageVersion = patcher.context.packageMetadata.packageVersion + + onProgress("Filtering patches for $packageName v$packageVersion...") + + // Filter patches based on compatibility and selection + val filteredPatches = patches.filter { patch -> + val patchName = patch.name ?: return@filter false + + // Check if explicitly disabled + if (patchName in disabledPatches) { + onProgress("Skipping disabled: $patchName") + return@filter false + } + + // Check package compatibility + val isCompatible = patch.compatiblePackages?.let { packages -> + packages.any { (name, versions) -> + name == packageName && (versions?.isEmpty() != false || versions.contains(packageVersion)) + } + } ?: true // Universal patches + + if (!isCompatible) { + return@filter false + } + + // In exclusive mode, only include explicitly enabled patches + if (exclusiveMode) { + patchName in enabledPatches + } else { + // Include if: enabled by default OR explicitly enabled + patch.use || patchName in enabledPatches + } + }.toSet() + + onProgress("Applying ${filteredPatches.size} patches...") + + // Set patch options if any + if (options.isNotEmpty()) { + val optionsMap = enabledPatches.associateWith { patchName -> + options.filterKeys { it.startsWith("$patchName.") } + .mapKeys { it.key.removePrefix("$patchName.") } + .mapValues { it.value as Any? } + .toMutableMap() + }.filter { it.value.isNotEmpty() } + + if (optionsMap.isNotEmpty()) { + filteredPatches.setOptions(optionsMap) + } + } + + patcher += filteredPatches + + // Execute patches + runBlocking { + patcher().collect { patchResult -> + val patchName = patchResult.patch.name ?: "Unknown" + patchResult.exception?.let { exception -> + val error = StringWriter().use { writer -> + exception.printStackTrace(PrintWriter(writer)) + writer.toString() + } + onProgress("FAILED: $patchName") + Logger.error("Patch failed: $patchName\n$error") + failedPatches.add(patchName to error) + } ?: run { + onProgress("Applied: $patchName") + Logger.info("Patch applied: $patchName") + appliedPatches.add(patchName) + } + } + } + + // Get patcher result + val patcherResult = patcher.get() + + onProgress("Rebuilding APK...") + val rebuiltApk = File(tempDir, "rebuilt.apk") + actualInputApk.copyTo(rebuiltApk, overwrite = true) + patcherResult.applyTo(rebuiltApk) + + onProgress("Signing APK...") + val keystorePath = File(tempDir, "morphe.keystore") + ApkUtils.signApk( + rebuiltApk, + tempOutputPath, + "Morphe", + ApkUtils.KeyStoreDetails( + keystorePath, + null, // password + "Morphe Key", + "" // entry password + ) + ) + + // Move to final location + outputFile.parentFile?.mkdirs() + tempOutputPath.copyTo(outputFile, overwrite = true) + + onProgress("Patching complete!") + Logger.info("Patched APK saved to: ${outputFile.absolutePath}") + + // Cleanup merged APK if created + mergedApkToCleanup?.delete() + } + + Result.success(PatchResult( + success = failedPatches.isEmpty(), + outputPath = outputFile.absolutePath, + appliedPatches = appliedPatches, + failedPatches = failedPatches.map { it.first } + )) + } catch (e: Exception) { + Logger.error("Patching failed", e) + Result.failure(e) + } finally { + // Cleanup temp directory + try { + tempDir.deleteRecursively() + } catch (e: Exception) { + Logger.warn("Failed to cleanup temp directory: ${e.message}") + } + } + } + + /** + * Convert library Patch to GUI Patch model. + */ + private fun LibraryPatch<*>.toGuiPatch(): Patch { + return Patch( + name = this.name ?: "Unknown", + description = this.description ?: "", + compatiblePackages = this.compatiblePackages?.map { (name, versions) -> + CompatiblePackage( + name = name, + versions = versions?.toList() ?: emptyList() + ) + } ?: emptyList(), + options = this.options.values.map { opt -> + PatchOption( + key = opt.key, + title = opt.title ?: opt.key, + description = opt.description ?: "", + type = mapKTypeToOptionType(opt.type), + default = opt.default?.toString(), + required = opt.required + ) + }, + isEnabled = this.use + ) + } + + /** + * Map Kotlin KType to GUI PatchOptionType. + */ + private fun mapKTypeToOptionType(kType: KType): PatchOptionType { + val typeName = kType.toString() + return when { + typeName.contains("Boolean") -> PatchOptionType.BOOLEAN + typeName.contains("Int") -> PatchOptionType.INT + typeName.contains("Long") -> PatchOptionType.LONG + typeName.contains("Float") || typeName.contains("Double") -> PatchOptionType.FLOAT + typeName.contains("List") || typeName.contains("Array") || typeName.contains("Set") -> PatchOptionType.LIST + else -> PatchOptionType.STRING + } + } +} + +/** + * Result of a patching operation. + */ +data class PatchResult( + val success: Boolean, + val outputPath: String, + val appliedPatches: List, + val failedPatches: List +) diff --git a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt new file mode 100644 index 0000000..a9802f1 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt @@ -0,0 +1,68 @@ +package app.morphe.gui.util + +import app.morphe.gui.data.model.Patch +import app.morphe.gui.data.model.SupportedApp + +/** + * Extracts supported apps from parsed patch data. + * This allows the app to dynamically determine which apps are supported + * based on the .mpp file contents rather than hardcoding. + */ +object SupportedAppExtractor { + + /** + * Extract all supported apps from a list of patches. + * Groups patches by package name and collects all supported versions. + */ + fun extractSupportedApps(patches: List): List { + // Collect all package names and their versions from all patches + val packageVersionsMap = mutableMapOf>() + + for (patch in patches) { + for (compatiblePackage in patch.compatiblePackages) { + val packageName = compatiblePackage.name + val versions = compatiblePackage.versions + + if (packageName.isNotBlank()) { + val existingVersions = packageVersionsMap.getOrPut(packageName) { mutableSetOf() } + existingVersions.addAll(versions) + } + } + } + + // Convert to SupportedApp list + return packageVersionsMap.map { (packageName, versions) -> + val versionList = versions.toList().sortedDescending() + SupportedApp( + packageName = packageName, + displayName = SupportedApp.getDisplayName(packageName), + supportedVersions = versionList, + recommendedVersion = SupportedApp.getRecommendedVersion(versionList), + apkMirrorUrl = SupportedApp.getApkMirrorUrl(packageName) + ) + }.sortedBy { it.displayName } + } + + /** + * Get supported app by package name. + */ + fun getSupportedApp(patches: List, packageName: String): SupportedApp? { + return extractSupportedApps(patches).find { it.packageName == packageName } + } + + /** + * Check if a package is supported by the patches. + */ + fun isPackageSupported(patches: List, packageName: String): Boolean { + return patches.any { patch -> + patch.compatiblePackages.any { it.name == packageName } + } + } + + /** + * Get recommended version for a package from patches. + */ + fun getRecommendedVersion(patches: List, packageName: String): String? { + return getSupportedApp(patches, packageName)?.recommendedVersion + } +} diff --git a/src/main/resources/morphe_logo.icns b/src/main/resources/morphe_logo.icns new file mode 100644 index 0000000000000000000000000000000000000000..9cbef394c7255367a1a37f0120cc5f12e84e31eb GIT binary patch literal 230839 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 Date: Fri, 6 Feb 2026 19:30:00 +0530 Subject: [PATCH 06/23] Removed hush hush svgs --- src/main/composeResources/drawable/reddit.svg | 10 - .../composeResources/drawable/youtube.svg | 1 - .../drawable/youtube_music.svg | 1 - src/main/kotlin/app/morphe/gui/GuiMain.kt | 17 ++ .../morphe/gui/ui/screens/home/HomeScreen.kt | 228 +++++++----------- .../ui/screens/home/components/ApkInfoCard.kt | 13 +- .../gui/ui/screens/quick/QuickPatchScreen.kt | 48 +--- 7 files changed, 123 insertions(+), 195 deletions(-) delete mode 100644 src/main/composeResources/drawable/reddit.svg delete mode 100644 src/main/composeResources/drawable/youtube.svg delete mode 100644 src/main/composeResources/drawable/youtube_music.svg diff --git a/src/main/composeResources/drawable/reddit.svg b/src/main/composeResources/drawable/reddit.svg deleted file mode 100644 index 9976548..0000000 --- a/src/main/composeResources/drawable/reddit.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/main/composeResources/drawable/youtube.svg b/src/main/composeResources/drawable/youtube.svg deleted file mode 100644 index f125ec3..0000000 --- a/src/main/composeResources/drawable/youtube.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/main/composeResources/drawable/youtube_music.svg b/src/main/composeResources/drawable/youtube_music.svg deleted file mode 100644 index 2257e05..0000000 --- a/src/main/composeResources/drawable/youtube_music.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt index 7e3b77a..1891611 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -34,6 +34,23 @@ fun launchGui(args: Array) = application { val appIcon = remember { loadAppIcon() } + // Set macOS dock icon + remember { + try { + if (java.awt.Taskbar.isTaskbarSupported()) { + val stream = Thread.currentThread().contextClassLoader + .getResourceAsStream("morphe_logo.png") + ?: ClassLoader.getSystemResourceAsStream("morphe_logo.png") + if (stream != null) { + java.awt.Taskbar.getTaskbar().iconImage = + javax.imageio.ImageIO.read(stream) + } + } + } catch (_: Exception) { + // Taskbar not supported or icon loading failed + } + } + Window( onCloseRequest = ::exitApplication, title = "Morphe", diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 5844a51..6a19f6b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -23,9 +23,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe -import app.morphe.morphe_cli.generated.resources.reddit -import app.morphe.morphe_cli.generated.resources.youtube -import app.morphe.morphe_cli.generated.resources.youtube_music import org.jetbrains.compose.resources.painterResource import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel @@ -119,121 +116,111 @@ fun HomeScreenContent( val scrollState = rememberScrollState() - // Estimate content heights to calculate flexible spacer - val brandingHeight = if (isCompact) 48.dp else 60.dp - val topSpacing = if (isSmall) 24.dp else 48.dp // top spacer + after branding - val middleContentHeight = if (uiState.apkInfo != null) { - // ApkInfoCard (~250dp) + buttons (~72dp) + spacer - if (isCompact) 340.dp else 380.dp - } else { - // Drop prompt section - if (isCompact) 160.dp else 200.dp - } - val supportedAppsHeight = if (isCompact) 220.dp else 280.dp - val bottomSpacing = if (isSmall) 24.dp else 40.dp // spacers around supported apps - - val totalFixedHeight = brandingHeight + topSpacing + middleContentHeight + supportedAppsHeight + bottomSpacing + (padding * 2) - - // Extra space to push supported apps to bottom on large screens - val extraSpace = (maxHeight - totalFixedHeight).coerceAtLeast(0.dp) - Box(modifier = Modifier.fillMaxSize()) { - // Always scrollable - but on large screens extraSpace fills the gap + // SpaceBetween + fillMaxSize pushes supported apps to the bottom + // when there's room; verticalScroll kicks in when content overflows. Column( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) .padding(padding), + verticalArrangement = Arrangement.SpaceBetween, horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) - BrandingSection(isCompact = isCompact) - - // Patches version selector card - right under logo - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = uiState.patchesVersion!!, - isLatest = uiState.isUsingLatestPatches, - onChangePatchesClick = { - // Navigate to patches version selection screen - // Pass empty apk info since user hasn't selected an APK yet - navigator.push(PatchesScreen( - apkPath = uiState.apkInfo?.filePath ?: "", - apkName = uiState.apkInfo?.appName ?: "Select APK first" - )) - }, - isCompact = isCompact, - modifier = Modifier - .widthIn(max = 400.dp) - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - ) - } else if (uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MorpheColors.Blue - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Loading patches...", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + // Top group: branding + patches version + middle content + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) + BrandingSection(isCompact = isCompact) + + // Patches version selector card - right under logo + if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + PatchesVersionCard( + patchesVersion = uiState.patchesVersion!!, + isLatest = uiState.isUsingLatestPatches, + onChangePatchesClick = { + // Navigate to patches version selection screen + // Pass empty apk info since user hasn't selected an APK yet + navigator.push(PatchesScreen( + apkPath = uiState.apkInfo?.filePath ?: "", + apkName = uiState.apkInfo?.appName ?: "Select APK first" + )) + }, + isCompact = isCompact, + modifier = Modifier + .widthIn(max = 400.dp) + .padding(horizontal = if (isCompact) 8.dp else 16.dp) ) + } else if (uiState.isLoadingPatches) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MorpheColors.Blue + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Loading patches...", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - } - Spacer(modifier = Modifier.height(if (isSmall) 16.dp else 32.dp)) + Spacer(modifier = Modifier.height(if (isSmall) 16.dp else 32.dp)) - MiddleContent( - uiState = uiState, - isCompact = isCompact, - patchesLoaded = !uiState.isLoadingPatches && viewModel.getCachedPatchesFile() != null, - onClearClick = { viewModel.clearSelection() }, - onChangeClick = { - openFilePicker()?.let { file -> - viewModel.onFileSelected(file) - } - }, - onContinueClick = { - val patchesFile = viewModel.getCachedPatchesFile() - if (patchesFile == null) { - // Patches not ready yet - return@MiddleContent - } + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = !uiState.isLoadingPatches && viewModel.getCachedPatchesFile() != null, + onClearClick = { viewModel.clearSelection() }, + onChangeClick = { + openFilePicker()?.let { file -> + viewModel.onFileSelected(file) + } + }, + onContinueClick = { + val patchesFile = viewModel.getCachedPatchesFile() + if (patchesFile == null) { + // Patches not ready yet + return@MiddleContent + } - val versionStatus = uiState.apkInfo?.versionStatus - if (versionStatus != null && versionStatus != VersionStatus.EXACT_MATCH && versionStatus != VersionStatus.UNKNOWN) { - showVersionWarningDialog = true - } else { - uiState.apkInfo?.let { info -> - navigator.push(PatchSelectionScreen( - apkPath = info.filePath, - apkName = info.appName, - patchesFilePath = patchesFile.absolutePath - )) + val versionStatus = uiState.apkInfo?.versionStatus + if (versionStatus != null && versionStatus != VersionStatus.EXACT_MATCH && versionStatus != VersionStatus.UNKNOWN) { + showVersionWarningDialog = true + } else { + uiState.apkInfo?.let { info -> + navigator.push(PatchSelectionScreen( + apkPath = info.filePath, + apkName = info.appName, + patchesFilePath = patchesFile.absolutePath + )) + } } } - } - ) - - // Flexible spacer - expands on large screens, minimal on small screens - Spacer(modifier = Modifier.height(extraSpace + if (isSmall) 16.dp else 24.dp)) + ) + } - SupportedAppsSection( - isCompact = isCompact, - maxWidth = this@BoxWithConstraints.maxWidth, - isLoading = uiState.isLoadingPatches, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = { viewModel.retryLoadPatches() } - ) - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) + // Bottom group: supported apps section + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(top = if (isSmall) 16.dp else 24.dp) + ) { + SupportedAppsSection( + isCompact = isCompact, + maxWidth = this@BoxWithConstraints.maxWidth, + isLoading = uiState.isLoadingPatches, + supportedApps = uiState.supportedApps, + loadError = uiState.patchLoadError, + onRetry = { viewModel.retryLoadPatches() } + ) + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) + } } // Settings button in top-right corner @@ -787,16 +774,6 @@ private fun SupportedAppCardDynamic( var showAllVersions by remember { mutableStateOf(false) } val cardPadding = if (isCompact) 12.dp else 16.dp - val iconSize = if (isCompact) 48.dp else 56.dp - val iconInnerSize = if (isCompact) 32.dp else 40.dp - - // Get icon resource based on package name - val iconRes = when (supportedApp.packageName) { - AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube - AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music - AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit - else -> null - } // Get APKMirror URL from AppConstants (still hardcoded) val apkMirrorUrl = when (supportedApp.packageName) { @@ -819,33 +796,6 @@ private fun SupportedAppCardDynamic( .padding(cardPadding), horizontalAlignment = Alignment.CenterHorizontally ) { - // App icon - Box( - modifier = Modifier - .size(iconSize) - .clip(RoundedCornerShape(if (isCompact) 10.dp else 12.dp)) - .background(Color.White), - contentAlignment = Alignment.Center - ) { - if (iconRes != null) { - Image( - painter = painterResource(iconRes), - contentDescription = "${supportedApp.displayName} icon", - modifier = Modifier.size(iconInnerSize) - ) - } else { - // Fallback: show first letter of app name - Text( - text = supportedApp.displayName.first().toString(), - fontSize = if (isCompact) 20.sp else 24.sp, - fontWeight = FontWeight.Bold, - color = MorpheColors.Blue - ) - } - } - - Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) - // App name Text( text = supportedApp.displayName, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt index cf4202e..f4b0365 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt @@ -19,9 +19,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.morphe_cli.generated.resources.Res -import app.morphe.morphe_cli.generated.resources.reddit -import app.morphe.morphe_cli.generated.resources.youtube -import app.morphe.morphe_cli.generated.resources.youtube_music import org.jetbrains.compose.resources.painterResource import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.ui.screens.home.ApkInfo @@ -59,11 +56,11 @@ fun ApkInfoCard( ) { // App icon - determine from appType or packageName val iconRes = when { - apkInfo.appType == AppType.YOUTUBE -> Res.drawable.youtube - apkInfo.appType == AppType.YOUTUBE_MUSIC -> Res.drawable.youtube_music - apkInfo.packageName == AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube - apkInfo.packageName == AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music - apkInfo.packageName == AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit + apkInfo.appType == AppType.YOUTUBE -> null + apkInfo.appType == AppType.YOUTUBE_MUSIC -> null + apkInfo.packageName == AppConstants.YouTube.PACKAGE_NAME -> null + apkInfo.packageName == AppConstants.YouTubeMusic.PACKAGE_NAME -> null + apkInfo.packageName == AppConstants.Reddit.PACKAGE_NAME -> null else -> null } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 4b5e298..bec551a 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -31,9 +31,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import app.morphe.morphe_cli.generated.resources.Res -import app.morphe.morphe_cli.generated.resources.reddit -import app.morphe.morphe_cli.generated.resources.youtube -import app.morphe.morphe_cli.generated.resources.youtube_music import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository @@ -361,18 +358,18 @@ private fun ReadyContent( .background(Color.White), contentAlignment = Alignment.Center ) { - Image( - painter = painterResource( - when (apkInfo.packageName) { - AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube - AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music - AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit - else -> Res.drawable.youtube // Fallback - } - ), - contentDescription = "${apkInfo.displayName} icon", - modifier = Modifier.size(36.dp) - ) +// Image( +// painter = painterResource( +// when (apkInfo.packageName) { +// AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube +// AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music +// AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit +// else -> Res.drawable.youtube // Fallback +// } +// ), +// contentDescription = "${apkInfo.displayName} icon", +// modifier = Modifier.size(36.dp) +// ) } Spacer(modifier = Modifier.width(16.dp)) @@ -711,27 +708,6 @@ private fun SupportedAppsRow( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Box( - modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(4.dp)) - .background(Color.White), - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource( - when (app.packageName) { - AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube - AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music - AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit - else -> Res.drawable.youtube // Fallback - } - ), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(8.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = app.displayName, From 3c9c11a1f660745161c626f3e8723840cfd684d9 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:29:05 +0530 Subject: [PATCH 07/23] Simplified version fixes + .apkm support fixed Fixed a bunch of issues with the simplified version. It should behave better now. --- .../morphe/gui/data/constants/AppConstants.kt | 10 +- .../morphe/gui/ui/screens/home/HomeScreen.kt | 4 +- .../gui/ui/screens/home/HomeViewModel.kt | 21 +- .../screens/patches/PatchSelectionScreen.kt | 40 +- .../gui/ui/screens/quick/QuickPatchScreen.kt | 380 +++++++++++------- .../ui/screens/quick/QuickPatchViewModel.kt | 81 ++-- .../kotlin/app/morphe/gui/util/FileUtils.kt | 28 +- 7 files changed, 383 insertions(+), 181 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt index 95163f2..8a42ca9 100644 --- a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -124,7 +124,6 @@ object AppConstants { // "Premium heading" to "Changes the YouTube logo/heading appearance", // "Navigation buttons" to "Modifies bottom navigation bar layout", // "Spoof client" to "May cause playback issues on some devices", -// "Change start page" to "Modifies the default landing page", // "Disable auto captions" to "Some users rely on auto-generated captions" ) @@ -136,6 +135,14 @@ object AppConstants { // "Spoof client" to "May cause playback issues on some devices" ) + /** + * Patches commonly disabled for Reddit. + */ + val REDDIT_COMMONLY_DISABLED: List> = listOf( + "Change package name" to "Doesn't work for reddit", + "Spoof signature" to "May cause issues on some devices" + ) + /** * Get commonly disabled patches for a package. */ @@ -143,6 +150,7 @@ object AppConstants { return when (packageName) { YouTube.PACKAGE_NAME -> YOUTUBE_COMMONLY_DISABLED YouTubeMusic.PACKAGE_NAME -> YOUTUBE_MUSIC_COMMONLY_DISABLED + Reddit.PACKAGE_NAME -> REDDIT_COMMONLY_DISABLED else -> emptyList() } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 6a19f6b..b91bbc6 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -556,7 +556,7 @@ private fun DropPromptSection( Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) Text( - text = "Supported: .apk files from APKMirror", + text = "Supported: .apk and .apkm files from APKMirror", fontSize = if (isCompact) 11.sp else 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -1100,7 +1100,7 @@ private fun DragOverlay() { private fun openFilePicker(): File? { val fileDialog = FileDialog(null as Frame?, "Select APK File", FileDialog.LOAD).apply { isMultipleMode = false - setFilenameFilter { _, name -> name.lowercase().endsWith(".apk") } + setFilenameFilter { _, name -> name.lowercase().let { it.endsWith(".apk") || it.endsWith(".apkm") } } isVisible = true } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 1a622a8..d403f6d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -218,7 +218,7 @@ class HomeViewModel( onFileSelected(apkFile) } else { _uiState.value = _uiState.value.copy( - error = "No valid APK file found. Please drop an .apk file.", + error = "Please drop a valid .apk or .apkm file", isReady = false ) } @@ -255,7 +255,7 @@ class HomeViewModel( } if (!FileUtils.isApkFile(file)) { - return ApkValidationResult(false, errorMessage = "File must have .apk extension") + return ApkValidationResult(false, errorMessage = "File must have .apk or .apkm extension") } if (file.length() < 1024) { @@ -277,8 +277,19 @@ class HomeViewModel( * This works with APKs from any source, not just APKMirror. */ private fun parseApkManifest(file: File): ApkInfo? { + // For .apkm files, extract base.apk first + val isApkm = file.extension.equals("apkm", ignoreCase = true) + val apkToParse = if (isApkm) { + FileUtils.extractBaseApkFromApkm(file) ?: run { + Logger.error("Failed to extract base.apk from APKM: ${file.name}") + return null + } + } else { + file + } + return try { - ApkFile(file).use { apk -> + ApkFile(apkToParse).use { apk -> val meta = apk.apkMeta val packageName = meta.packageName @@ -321,7 +332,7 @@ class HomeViewModel( } // Get supported architectures from native libraries in the APK - val architectures = extractArchitectures(file) + val architectures = extractArchitectures(apkToParse) // Verify checksum (still uses AppConstants for now) val checksumStatus = verifyChecksum(file, packageName, versionName, architectures, suggestedVersion) @@ -347,6 +358,8 @@ class HomeViewModel( } catch (e: Exception) { Logger.error("Failed to parse APK manifest", e) null + } finally { + if (isApkm) apkToParse.delete() } } 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 0ccab99..6a3b7fe 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 @@ -429,14 +429,26 @@ private fun PatchListItem( } } - // Show options indicator if patch has options + // Show options if patch has any if (patch.options.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${patch.options.size} option${if (patch.options.size > 1) "s" else ""} available", - fontSize = 11.sp, - color = MorpheColors.Teal - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + patch.options.forEach { option -> + Surface( + color = MorpheColors.Teal.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = option.title.ifBlank { option.key }, + fontSize = 10.sp, + color = MorpheColors.Teal, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } } } } @@ -568,9 +580,11 @@ private fun CommandPreview( modifier: Modifier = Modifier ) { val terminalBackground = Color(0xFF1E1E1E) - val terminalGreen = Color(0xFF4EC9B0) +// val terminalGreen = Color(0xFF4EC9B0) + val terminalGreen = Color(0xFF6A9955) val terminalText = Color(0xFFD4D4D4) val terminalDim = Color(0xFF6A9955) +// val terminalDim = Color(0xFF4EC9B0) var showCopied by remember { mutableStateOf(false) } @@ -613,8 +627,8 @@ private fun CommandPreview( ) Text( text = "Command Preview", - fontSize = 11.sp, - fontWeight = FontWeight.Medium, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, color = terminalGreen ) Icon( @@ -652,7 +666,8 @@ private fun CommandPreview( ) Text( text = if (showCopied) "Copied!" else "Copy", - fontSize = 10.sp, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, color = if (showCopied) terminalGreen else terminalDim ) } @@ -666,8 +681,9 @@ private fun CommandPreview( shape = RoundedCornerShape(4.dp) ) { Text( - text = if (cleanMode) "compact" else "expand", - fontSize = 10.sp, + text = if (cleanMode) "Compact" else "Expand", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, color = terminalDim, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) ) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index bec551a..0520ee0 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import app.morphe.morphe_cli.generated.resources.Res +import app.morphe.morphe_cli.generated.resources.morphe import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository @@ -39,7 +40,10 @@ import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject import app.morphe.gui.ui.components.SettingsButton import app.morphe.gui.ui.theme.MorpheColors +import androidx.compose.runtime.rememberCoroutineScope +import app.morphe.gui.util.AdbDevice import app.morphe.gui.util.AdbManager +import kotlinx.coroutines.launch import app.morphe.gui.util.ChecksumStatus import java.awt.Desktop import java.awt.datatransfer.DataFlavor @@ -97,7 +101,7 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { @Suppress("UNCHECKED_CAST") val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List - val apkFile = files.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) } + val apkFile = files.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) || it.name.endsWith(".apkm", ignoreCase = true) } if (apkFile != null) { viewModel.onFileSelected(apkFile) true @@ -123,132 +127,120 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { target = dragAndDropTarget ) ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { + // Branding + Spacer(modifier = Modifier.height(8.dp)) + Image( + painter = painterResource(Res.drawable.morphe), + contentDescription = "Morphe Logo", + modifier = Modifier.height(48.dp) + ) Text( - text = "Morphe Quick Patch", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + text = "Quick Patch", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Mode indicator - Surface( - color = MorpheColors.Blue.copy(alpha = 0.1f), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = "QUICK MODE", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - color = MorpheColors.Blue, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) - } + Spacer(modifier = Modifier.height(16.dp)) - // Settings button - SettingsButton() - } - } + // Main content based on phase + // Remember last valid data for safe animation transitions + val lastApkInfo = remember(uiState.apkInfo) { uiState.apkInfo } + val lastOutputPath = remember(uiState.outputPath) { uiState.outputPath } - Spacer(modifier = Modifier.height(20.dp)) - - // Main content based on phase - // Remember last valid data for safe animation transitions - val lastApkInfo = remember(uiState.apkInfo) { uiState.apkInfo } - val lastOutputPath = remember(uiState.outputPath) { uiState.outputPath } - - AnimatedContent( - targetState = uiState.phase, - modifier = Modifier.weight(1f) - ) { phase -> - when (phase) { - QuickPatchPhase.IDLE, QuickPatchPhase.ANALYZING -> { - IdleContent( - isAnalyzing = phase == QuickPatchPhase.ANALYZING, - isDragHovering = uiState.isDragHovering, - error = uiState.error, - onFileSelected = { viewModel.onFileSelected(it) }, - onDragHover = { viewModel.setDragHover(it) }, - onClearError = { viewModel.clearError() } - ) - } - QuickPatchPhase.READY -> { - // Use current or last known apkInfo to prevent crash during animation - val apkInfo = uiState.apkInfo ?: lastApkInfo - if (apkInfo != null) { - ReadyContent( - apkInfo = apkInfo, + AnimatedContent( + targetState = uiState.phase, + modifier = Modifier.weight(1f) + ) { phase -> + when (phase) { + QuickPatchPhase.IDLE, QuickPatchPhase.ANALYZING -> { + IdleContent( + isAnalyzing = phase == QuickPatchPhase.ANALYZING, + isDragHovering = uiState.isDragHovering, error = uiState.error, - onPatch = { viewModel.startPatching() }, - onClear = { viewModel.reset() }, + onFileSelected = { viewModel.onFileSelected(it) }, + onDragHover = { viewModel.setDragHover(it) }, onClearError = { viewModel.clearError() } ) } - } - QuickPatchPhase.DOWNLOADING, QuickPatchPhase.PATCHING -> { - PatchingContent( - phase = phase, - progress = uiState.progress, - statusMessage = uiState.statusMessage, - onCancel = { viewModel.cancelPatching() } - ) - } - QuickPatchPhase.COMPLETED -> { - val apkInfo = uiState.apkInfo ?: lastApkInfo - val outputPath = uiState.outputPath ?: lastOutputPath - if (apkInfo != null && outputPath != null) { - CompletedContent( - outputPath = outputPath, - apkInfo = apkInfo, - onPatchAnother = { viewModel.reset() } + QuickPatchPhase.READY -> { + // Use current or last known apkInfo to prevent crash during animation + val apkInfo = uiState.apkInfo ?: lastApkInfo + if (apkInfo != null) { + ReadyContent( + apkInfo = apkInfo, + error = uiState.error, + onPatch = { viewModel.startPatching() }, + onClear = { viewModel.reset() }, + onClearError = { viewModel.clearError() } + ) + } + } + QuickPatchPhase.DOWNLOADING, QuickPatchPhase.PATCHING -> { + PatchingContent( + phase = phase, + progress = uiState.progress, + statusMessage = uiState.statusMessage, + onCancel = { viewModel.cancelPatching() } ) } + QuickPatchPhase.COMPLETED -> { + val apkInfo = uiState.apkInfo ?: lastApkInfo + val outputPath = uiState.outputPath ?: lastOutputPath + if (apkInfo != null && outputPath != null) { + CompletedContent( + outputPath = outputPath, + apkInfo = apkInfo, + onPatchAnother = { viewModel.reset() } + ) + } + } } } - } - // Bottom app cards (only show in IDLE phase) - if (uiState.phase == QuickPatchPhase.IDLE) { - Spacer(modifier = Modifier.height(16.dp)) - SupportedAppsRow( - supportedApps = uiState.supportedApps, - isLoading = uiState.isLoadingPatches, - patchesVersion = uiState.patchesVersion, - onOpenUrl = { url -> uriHandler.openUri(url) } - ) + // Bottom app cards (only show in IDLE phase) + if (uiState.phase == QuickPatchPhase.IDLE) { + Spacer(modifier = Modifier.height(16.dp)) + SupportedAppsRow( + supportedApps = uiState.supportedApps, + isLoading = uiState.isLoadingPatches, + loadError = uiState.patchLoadError, + patchesVersion = uiState.patchesVersion, + onOpenUrl = { url -> uriHandler.openUri(url) }, + onRetry = { viewModel.retryLoadPatches() } + ) + } } - } - // Error snackbar - uiState.error?.let { error -> - Snackbar( + // Settings button in top-right corner + SettingsButton( modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp), - action = { - TextButton(onClick = { viewModel.clearError() }) { - Text("Dismiss", color = MaterialTheme.colorScheme.inversePrimary) - } - }, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) { - Text(error) + .align(Alignment.TopEnd) + .padding(24.dp) + ) + + // Error snackbar + uiState.error?.let { error -> + Snackbar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + action = { + TextButton(onClick = { viewModel.clearError() }) { + Text("Dismiss", color = MaterialTheme.colorScheme.inversePrimary) + } + }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) { + Text(error) + } } } } @@ -350,7 +342,7 @@ private fun ReadyContent( .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - // App icon + // App icon: first letter of display name Box( modifier = Modifier .size(48.dp) @@ -358,18 +350,12 @@ private fun ReadyContent( .background(Color.White), contentAlignment = Alignment.Center ) { -// Image( -// painter = painterResource( -// when (apkInfo.packageName) { -// AppConstants.YouTube.PACKAGE_NAME -> Res.drawable.youtube -// AppConstants.YouTubeMusic.PACKAGE_NAME -> Res.drawable.youtube_music -// AppConstants.Reddit.PACKAGE_NAME -> Res.drawable.reddit -// else -> Res.drawable.youtube // Fallback -// } -// ), -// contentDescription = "${apkInfo.displayName} icon", -// modifier = Modifier.size(36.dp) -// ) + Text( + text = apkInfo.displayName.first().toString(), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MorpheColors.Blue + ) } Spacer(modifier = Modifier.width(16.dp)) @@ -536,11 +522,39 @@ private fun CompletedContent( onPatchAnother: () -> Unit ) { val outputFile = File(outputPath) + val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } var isAdbAvailable by remember { mutableStateOf(null) } + var connectedDevices by remember { mutableStateOf>(emptyList()) } + var selectedDevice by remember { mutableStateOf(null) } + var isInstalling by remember { mutableStateOf(false) } + var installError by remember { mutableStateOf(null) } + var installSuccess by remember { mutableStateOf(false) } + + fun refreshDevices() { + scope.launch { + val result = adbManager.getConnectedDevices() + result.fold( + onSuccess = { devices -> + connectedDevices = devices + val readyDevices = devices.filter { it.isReady } + if (readyDevices.size == 1) { + selectedDevice = readyDevices.first() + } + }, + onFailure = { + connectedDevices = emptyList() + selectedDevice = null + } + ) + } + } LaunchedEffect(Unit) { isAdbAvailable = adbManager.isAdbAvailable() + if (isAdbAvailable == true) { + refreshDevices() + } } Column( @@ -621,12 +635,90 @@ private fun CompletedContent( } if (isAdbAvailable == true) { - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "Connect your device via USB to install with ADB", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Spacer(modifier = Modifier.height(16.dp)) + + val readyDevices = connectedDevices.filter { it.isReady } + + if (installSuccess) { + Surface( + color = MorpheColors.Teal.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "Installed successfully!", + fontSize = 13.sp, + color = MorpheColors.Teal, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } else if (isInstalling) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MorpheColors.Blue + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Installing...", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (readyDevices.isNotEmpty()) { + val device = selectedDevice ?: readyDevices.first() + Button( + onClick = { + scope.launch { + isInstalling = true + installError = null + val result = adbManager.installApk( + apkPath = outputPath, + deviceId = device.id + ) + result.fold( + onSuccess = { installSuccess = true }, + onFailure = { installError = it.message } + ) + isInstalling = false + } + }, + colors = ButtonDefaults.buttonColors(containerColor = MorpheColors.Teal), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Install on ${device.displayName}") + } + } else { + Text( + text = "Connect your device via USB to install with ADB", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + TextButton(onClick = { refreshDevices() }) { + Text("Refresh", fontSize = 12.sp) + } + } + + installError?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } } } } @@ -635,8 +727,10 @@ private fun CompletedContent( private fun SupportedAppsRow( supportedApps: List, isLoading: Boolean, + loadError: String? = null, patchesVersion: String?, - onOpenUrl: (String) -> Unit + onOpenUrl: (String) -> Unit, + onRetry: () -> Unit = {} ) { Column( modifier = Modifier.fillMaxWidth() @@ -681,13 +775,27 @@ private fun SupportedAppsRow( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } else if (supportedApps.isEmpty()) { - // No apps loaded - Text( - text = "Could not load supported apps", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + } else if (loadError != null || supportedApps.isEmpty()) { + // Error or no apps loaded + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = loadError ?: "Could not load supported apps", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = onRetry, + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Text("Retry", fontSize = 12.sp) + } + } } else { // Show supported apps dynamically Row( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index ed391d9..75a1b16 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.launch import net.dongliu.apk.parser.ApkFile import app.morphe.gui.util.ChecksumStatus import app.morphe.gui.util.ChecksumUtils +import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import app.morphe.gui.util.SupportedAppExtractor @@ -50,34 +51,25 @@ class QuickPatchViewModel( */ private fun loadPatchesAndSupportedApps() { screenModelScope.launch { - _uiState.value = _uiState.value.copy(isLoadingPatches = true) + _uiState.value = _uiState.value.copy(isLoadingPatches = true, patchLoadError = null) try { - // Check for saved version in config - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion - // Fetch releases val releasesResult = patchRepository.fetchReleases() val releases = releasesResult.getOrNull() if (releases.isNullOrEmpty()) { Logger.warn("Quick mode: Could not fetch releases") - _uiState.value = _uiState.value.copy(isLoadingPatches = false) + _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not fetch releases. Check your internet connection.") return@launch } - // Find release to use - val latestStable = releases.firstOrNull { !it.isDevRelease() } - val release = if (savedVersion != null) { - releases.find { it.tagName == savedVersion } ?: latestStable - } else { - latestStable - } + // Quick mode always uses the latest stable release + val release = releases.firstOrNull { !it.isDevRelease() } if (release == null) { Logger.warn("Quick mode: No suitable release found") - _uiState.value = _uiState.value.copy(isLoadingPatches = false) + _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "No suitable release found") return@launch } @@ -87,7 +79,7 @@ class QuickPatchViewModel( if (patchFile == null) { Logger.warn("Quick mode: Could not download patches") - _uiState.value = _uiState.value.copy(isLoadingPatches = false) + _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not download patches") return@launch } @@ -99,7 +91,7 @@ class QuickPatchViewModel( if (patches.isNullOrEmpty()) { Logger.warn("Quick mode: Could not load patches: ${patchesResult.exceptionOrNull()?.message}") - _uiState.value = _uiState.value.copy(isLoadingPatches = false) + _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not load patches") return@launch } @@ -114,15 +106,23 @@ class QuickPatchViewModel( _uiState.value = _uiState.value.copy( isLoadingPatches = false, supportedApps = supportedApps, - patchesVersion = release.tagName + patchesVersion = release.tagName, + patchLoadError = null ) } catch (e: Exception) { Logger.error("Quick mode: Failed to load patches", e) - _uiState.value = _uiState.value.copy(isLoadingPatches = false) + _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Failed to load patches: ${e.message}") } } } + /** + * Retry loading patches after a failure. + */ + fun retryLoadPatches() { + loadPatchesAndSupportedApps() + } + /** * Handle file drop or selection. */ @@ -153,13 +153,24 @@ class QuickPatchViewModel( * Analyze the APK file using dynamic data from patches. */ private suspend fun analyzeApk(file: File): QuickApkInfo? { - if (!file.exists() || !file.name.endsWith(".apk", ignoreCase = true)) { - _uiState.value = _uiState.value.copy(error = "Please select a valid APK file") + if (!file.exists() || !(file.name.endsWith(".apk", ignoreCase = true) || file.name.endsWith(".apkm", ignoreCase = true))) { + _uiState.value = _uiState.value.copy(error = "Please drop a valid .apk or .apkm file") return null } + // For .apkm files, extract base.apk first + val isApkm = file.extension.equals("apkm", ignoreCase = true) + val apkToParse = if (isApkm) { + FileUtils.extractBaseApkFromApkm(file) ?: run { + _uiState.value = _uiState.value.copy(error = "Failed to extract base.apk from APKM bundle") + return null + } + } else { + file + } + return try { - ApkFile(file).use { apk -> + ApkFile(apkToParse).use { apk -> val meta = apk.apkMeta val packageName = meta.packageName val versionName = meta.versionName ?: "Unknown" @@ -221,6 +232,8 @@ class QuickPatchViewModel( Logger.error("Quick mode: Failed to analyze APK", e) _uiState.value = _uiState.value.copy(error = "Failed to read APK: ${e.message}") null + } finally { + if (isApkm) apkToParse.delete() } } @@ -311,13 +324,27 @@ class QuickPatchViewModel( val outputFileName = "$baseName-Morphe-${apkInfo.versionName}.apk" val outputPath = File(outputDir, outputFileName).absolutePath + // Auto-deselect commonly disabled patches for this app + val commonlyDisabled = AppConstants.PatchRecommendations.getCommonlyDisabled(apkInfo.packageName) + val disabledPatches = cachedPatches + .filter { patch -> + commonlyDisabled.any { (pattern, _) -> + patch.name.contains(pattern, ignoreCase = true) + } + } + .map { it.name } + + if (disabledPatches.isNotEmpty()) { + Logger.info("Quick mode: Auto-disabling patches: $disabledPatches") + } + // Use PatchService for direct library patching (no CLI subprocess) val patchResult = patchService.patch( patchesFilePath = patchFile.absolutePath, inputApkPath = apkFile.absolutePath, outputApkPath = outputPath, enabledPatches = emptyList(), // Empty = use defaults - disabledPatches = emptyList(), + disabledPatches = disabledPatches, options = emptyMap(), exclusiveMode = false, // Include all default patches onProgress = { message -> @@ -400,7 +427,12 @@ class QuickPatchViewModel( fun reset() { patchingJob?.cancel() patchingJob = null - _uiState.value = QuickPatchUiState() + _uiState.value = QuickPatchUiState( + // Preserve already-loaded patches data + isLoadingPatches = false, + supportedApps = cachedSupportedApps, + patchesVersion = _uiState.value.patchesVersion + ) } /** @@ -466,5 +498,6 @@ data class QuickPatchUiState( // Dynamic data from patches val isLoadingPatches: Boolean = true, val supportedApps: List = emptyList(), - val patchesVersion: String? = null + val patchesVersion: String? = null, + val patchLoadError: String? = null ) diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt index 3906045..245b589 100644 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -3,6 +3,7 @@ package app.morphe.gui.util import java.io.File import java.nio.file.Path import java.nio.file.Paths +import java.util.zip.ZipFile /** * Platform-agnostic file utilities. @@ -141,9 +142,32 @@ object FileUtils { } /** - * Check if file is an APK. + * Check if file is an APK or APKM. */ fun isApkFile(file: File): Boolean { - return file.isFile && getExtension(file) == "apk" + val ext = getExtension(file) + return file.isFile && (ext == "apk" || ext == "apkm") + } + + /** + * Extract base.apk from an .apkm file to a temp directory. + * Returns the extracted base.apk file, or null if extraction fails. + * Caller is responsible for cleaning up the returned temp file. + */ + fun extractBaseApkFromApkm(apkmFile: File): File? { + return try { + ZipFile(apkmFile).use { zip -> + val baseEntry = zip.getEntry("base.apk") ?: return null + val tempFile = File(getTempDir(), "base-${System.currentTimeMillis()}.apk") + zip.getInputStream(baseEntry).use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + tempFile + } + } catch (e: Exception) { + null + } } } From 5199e5ddded3293b1dc825942d7d504dcd439e89 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:59:15 +0530 Subject: [PATCH 08/23] Minor UI updates Fixed minor UI bugs and URL pointing issues --- .../drawable/{morphe.svg => morphe_dark.svg} | 0 .../composeResources/drawable/morphe_light.svg | 15 +++++++++++++++ .../app/morphe/gui/data/constants/AppConstants.kt | 4 ++-- .../app/morphe/gui/data/model/SupportedApp.kt | 9 ++++++--- .../app/morphe/gui/ui/screens/home/HomeScreen.kt | 14 ++++++++++++-- .../gui/ui/screens/quick/QuickPatchScreen.kt | 14 ++++++++++++-- 6 files changed, 47 insertions(+), 9 deletions(-) rename src/main/composeResources/drawable/{morphe.svg => morphe_dark.svg} (100%) create mode 100644 src/main/composeResources/drawable/morphe_light.svg diff --git a/src/main/composeResources/drawable/morphe.svg b/src/main/composeResources/drawable/morphe_dark.svg similarity index 100% rename from src/main/composeResources/drawable/morphe.svg rename to src/main/composeResources/drawable/morphe_dark.svg diff --git a/src/main/composeResources/drawable/morphe_light.svg b/src/main/composeResources/drawable/morphe_light.svg new file mode 100644 index 0000000..b34f20c --- /dev/null +++ b/src/main/composeResources/drawable/morphe_light.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt index 8a42ca9..3dcd174 100644 --- a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -8,7 +8,7 @@ object AppConstants { // ==================== APP INFO ==================== const val APP_NAME = "Morphe GUI" - const val APP_VERSION = "1.4.0" // Keep in sync with build.gradle.kts + const val APP_VERSION = "1.4.0" // Keep in sync with the release version numbers // ==================== YOUTUBE ==================== object YouTube { @@ -41,7 +41,7 @@ object AppConstants { const val DISPLAY_NAME = "Reddit" const val PACKAGE_NAME = "com.reddit.frontpage" // APKMirror URL - to be updated with specific version when known - const val APK_MIRROR_URL = "https://www.apkmirror.com/apk/redditinc/reddit/" + const val APK_MIRROR_URL = "https://www.apkmirror.com/apk/redditinc/reddit/reddit-2026-03-0-release/" } /** diff --git a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt index d32745a..5bc573b 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt @@ -1,5 +1,7 @@ package app.morphe.gui.data.model +import app.morphe.gui.data.constants.AppConstants + /** * Represents a supported app extracted dynamically from patch metadata. * This is populated by parsing the .mpp file's compatible packages. @@ -30,12 +32,13 @@ data class SupportedApp( /** * Get APK Mirror URL for a package name. + * Uses the same version-specific URLs from AppConstants. */ fun getApkMirrorUrl(packageName: String): String? { return when (packageName) { - "com.google.android.youtube" -> "https://www.apkmirror.com/apk/google-inc/youtube/" - "com.google.android.apps.youtube.music" -> "https://www.apkmirror.com/apk/google-inc/youtube-music/" - "com.reddit.frontpage" -> "https://www.apkmirror.com/apk/redditinc/reddit/" + AppConstants.YouTube.PACKAGE_NAME -> AppConstants.YouTube.APK_MIRROR_URL + AppConstants.YouTubeMusic.PACKAGE_NAME -> AppConstants.YouTubeMusic.APK_MIRROR_URL + AppConstants.Reddit.PACKAGE_NAME -> AppConstants.Reddit.APK_MIRROR_URL else -> null } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index b91bbc6..c4b98bd 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -21,8 +21,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.foundation.isSystemInDarkTheme import app.morphe.morphe_cli.generated.resources.Res -import app.morphe.morphe_cli.generated.resources.morphe +import app.morphe.morphe_cli.generated.resources.morphe_dark +import app.morphe.morphe_cli.generated.resources.morphe_light +import app.morphe.gui.ui.theme.LocalThemeState +import app.morphe.gui.ui.theme.ThemePreference import org.jetbrains.compose.resources.painterResource import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel @@ -500,8 +504,14 @@ private fun VersionWarningDialog( @Composable private fun BrandingSection(isCompact: Boolean = false) { + val themeState = LocalThemeState.current + val isDark = when (themeState.current) { + ThemePreference.DARK -> true + ThemePreference.LIGHT -> false + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } Image( - painter = painterResource(Res.drawable.morphe), + painter = painterResource(if (isDark) Res.drawable.morphe_dark else Res.drawable.morphe_light), contentDescription = "Morphe Logo", modifier = Modifier.height(if (isCompact) 48.dp else 60.dp) ) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 0520ee0..e38cd2d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -30,8 +30,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen +import androidx.compose.foundation.isSystemInDarkTheme import app.morphe.morphe_cli.generated.resources.Res -import app.morphe.morphe_cli.generated.resources.morphe +import app.morphe.morphe_cli.generated.resources.morphe_dark +import app.morphe.morphe_cli.generated.resources.morphe_light +import app.morphe.gui.ui.theme.LocalThemeState +import app.morphe.gui.ui.theme.ThemePreference import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository @@ -136,8 +140,14 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { ) { // Branding Spacer(modifier = Modifier.height(8.dp)) + val themeState = LocalThemeState.current + val isDark = when (themeState.current) { + ThemePreference.DARK -> true + ThemePreference.LIGHT -> false + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } Image( - painter = painterResource(Res.drawable.morphe), + painter = painterResource(if (isDark) Res.drawable.morphe_dark else Res.drawable.morphe_light), contentDescription = "Morphe Logo", modifier = Modifier.height(48.dp) ) From 73151ae44b495c1574ee0d9ef3c156e73283f2b6 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:45:31 +0530 Subject: [PATCH 09/23] apkmirror link builder Builds an apkmirror link to link the app . Might want to change the implementation later --- .../morphe/gui/data/constants/AppConstants.kt | 7 +-- .../app/morphe/gui/data/model/SupportedApp.kt | 16 ++--- .../morphe/gui/ui/screens/home/HomeScreen.kt | 62 +++++++++---------- .../gui/ui/screens/home/HomeViewModel.kt | 9 +-- .../morphe/gui/util/DownloadUrlResolver.kt | 29 +++++++++ .../morphe/gui/util/SupportedAppExtractor.kt | 5 +- 6 files changed, 73 insertions(+), 55 deletions(-) create mode 100644 src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt diff --git a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt index 3dcd174..3ca5a23 100644 --- a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -10,12 +10,14 @@ object AppConstants { const val APP_NAME = "Morphe GUI" const val APP_VERSION = "1.4.0" // Keep in sync with the release version numbers + // ==================== API ==================== + const val MORPHE_API_URL = "https://api.morphe.software" + // ==================== YOUTUBE ==================== object YouTube { const val DISPLAY_NAME = "YouTube" const val PACKAGE_NAME = "com.google.android.youtube" const val SUGGESTED_VERSION = "20.40.45" - const val APK_MIRROR_URL = "https://www.apkmirror.com/apk/google-inc/youtube/youtube-20-40-45-release/youtube-20-40-45-2-android-apk-download/" // SHA-256 checksum from APKMirror (leave null if not verified) // You can find this on the APKMirror download page under "File SHA-256" @@ -27,7 +29,6 @@ object AppConstants { const val DISPLAY_NAME = "YouTube Music" const val PACKAGE_NAME = "com.google.android.apps.youtube.music" const val SUGGESTED_VERSION = "8.40.54" - const val APK_MIRROR_URL = "https://www.apkmirror.com/apk/google-inc/youtube-music/youtube-music-8-40-54-release/" val SHA256_CHECKSUMS: Map = mapOf( "arm64-v8a" to "d5b44919a5cd5648b01e392115fe68b9569b1c7847f3cdf65b1ace1302d005d2", "armeabi-v7a" to "6f5181e8aaa2595af6c421b86ffffcc1c7a4e97968d7be89d04b46776392eaec", @@ -40,8 +41,6 @@ object AppConstants { object Reddit { const val DISPLAY_NAME = "Reddit" const val PACKAGE_NAME = "com.reddit.frontpage" - // APKMirror URL - to be updated with specific version when known - const val APK_MIRROR_URL = "https://www.apkmirror.com/apk/redditinc/reddit/reddit-2026-03-0-release/" } /** diff --git a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt index 5bc573b..69adf66 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt @@ -1,6 +1,6 @@ package app.morphe.gui.data.model -import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.util.DownloadUrlResolver /** * Represents a supported app extracted dynamically from patch metadata. @@ -31,16 +31,12 @@ data class SupportedApp( } /** - * Get APK Mirror URL for a package name. - * Uses the same version-specific URLs from AppConstants. + * Get download URL for a package name and version. + * Returns a direct APKMirror search URL for the app + version. */ - fun getApkMirrorUrl(packageName: String): String? { - return when (packageName) { - AppConstants.YouTube.PACKAGE_NAME -> AppConstants.YouTube.APK_MIRROR_URL - AppConstants.YouTubeMusic.PACKAGE_NAME -> AppConstants.YouTubeMusic.APK_MIRROR_URL - AppConstants.Reddit.PACKAGE_NAME -> AppConstants.Reddit.APK_MIRROR_URL - else -> null - } + fun getDownloadUrl(packageName: String, version: String?): String? { + if (version == null) return null + return DownloadUrlResolver.buildUrl(packageName, version) } /** diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index c4b98bd..07c67d2 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -32,7 +32,6 @@ import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.ui.components.SettingsButton import app.morphe.gui.ui.screens.home.components.ApkInfoCard @@ -785,13 +784,7 @@ private fun SupportedAppCardDynamic( val cardPadding = if (isCompact) 12.dp else 16.dp - // Get APKMirror URL from AppConstants (still hardcoded) - val apkMirrorUrl = when (supportedApp.packageName) { - AppConstants.YouTube.PACKAGE_NAME -> AppConstants.YouTube.APK_MIRROR_URL - AppConstants.YouTubeMusic.PACKAGE_NAME -> AppConstants.YouTubeMusic.APK_MIRROR_URL - AppConstants.Reddit.PACKAGE_NAME -> AppConstants.Reddit.APK_MIRROR_URL - else -> null - } + val apkMirrorUrl = supportedApp.apkMirrorUrl Card( modifier = modifier, @@ -1030,32 +1023,35 @@ private fun SupportedAppCard( Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) // Download from APKMirror button - OutlinedButton( - onClick = { - try { - java.awt.Desktop.getDesktop().browse(java.net.URI(appType.apkMirrorUrl)) - } catch (e: Exception) { - // Ignore errors - } - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp), - contentPadding = PaddingValues( - horizontal = if (isCompact) 8.dp else 12.dp, - vertical = if (isCompact) 6.dp else 8.dp - ), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MorpheColors.Blue - ) - ) { - Text( - text = if (isCompact) "APKMirror" else "Get from APKMirror", - fontSize = if (isCompact) 11.sp else 12.sp, - fontWeight = FontWeight.Medium - ) - } + val downloadUrl = SupportedApp.getDownloadUrl(appType.packageName, appType.suggestedVersion) + if (downloadUrl != null) { + OutlinedButton( + onClick = { + try { + java.awt.Desktop.getDesktop().browse(java.net.URI(downloadUrl)) + } catch (e: Exception) { + // Ignore errors + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp), + contentPadding = PaddingValues( + horizontal = if (isCompact) 8.dp else 12.dp, + vertical = if (isCompact) 6.dp else 8.dp + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MorpheColors.Blue + ) + ) { + Text( + text = if (isCompact) "APKMirror" else "Get from APKMirror", + fontSize = if (isCompact) 11.sp else 12.sp, + fontWeight = FontWeight.Medium + ) + } - Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) + Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) + } // Package name Text( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index d403f6d..7e33e9b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -485,20 +485,17 @@ data class HomeUiState( enum class AppType( val displayName: String, val packageName: String, - val suggestedVersion: String, - val apkMirrorUrl: String + val suggestedVersion: String ) { YOUTUBE( displayName = app.morphe.gui.data.constants.AppConstants.YouTube.DISPLAY_NAME, packageName = app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME, - suggestedVersion = app.morphe.gui.data.constants.AppConstants.YouTube.SUGGESTED_VERSION, - apkMirrorUrl = app.morphe.gui.data.constants.AppConstants.YouTube.APK_MIRROR_URL + suggestedVersion = app.morphe.gui.data.constants.AppConstants.YouTube.SUGGESTED_VERSION ), YOUTUBE_MUSIC( displayName = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.DISPLAY_NAME, packageName = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME, - suggestedVersion = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.SUGGESTED_VERSION, - apkMirrorUrl = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.APK_MIRROR_URL + suggestedVersion = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.SUGGESTED_VERSION ) } diff --git a/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt b/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt new file mode 100644 index 0000000..9a854e3 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt @@ -0,0 +1,29 @@ +package app.morphe.gui.util + +/** + * Builds direct APKMirror release page URLs from package name + version. + * Pattern: https://www.apkmirror.com/apk/{publisher}/{app}/{app}-{version}-release/ + */ +object DownloadUrlResolver { + + private data class ApkMirrorApp(val publisher: String, val name: String) + + private val PACKAGE_MAP = mapOf( + "com.google.android.youtube" to ApkMirrorApp("google-inc", "youtube"), + "com.google.android.apps.youtube.music" to ApkMirrorApp("google-inc", "youtube-music"), + "com.reddit.frontpage" to ApkMirrorApp("redditinc", "reddit") + ) + + fun buildUrl(packageName: String, version: String?): String { + if (version == null) return fallbackUrl(packageName) + + val app = PACKAGE_MAP[packageName] ?: return fallbackUrl(packageName) + val versionSlug = version.replace(".", "-") + + return "https://www.apkmirror.com/apk/${app.publisher}/${app.name}/${app.name}-$versionSlug-release/" + } + + private fun fallbackUrl(packageName: String): String { + return "${app.morphe.gui.data.constants.AppConstants.MORPHE_API_URL}/v2/web-search/$packageName" + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt index a9802f1..e7aa5ef 100644 --- a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt +++ b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt @@ -33,12 +33,13 @@ object SupportedAppExtractor { // Convert to SupportedApp list return packageVersionsMap.map { (packageName, versions) -> val versionList = versions.toList().sortedDescending() + val recommendedVersion = SupportedApp.getRecommendedVersion(versionList) SupportedApp( packageName = packageName, displayName = SupportedApp.getDisplayName(packageName), supportedVersions = versionList, - recommendedVersion = SupportedApp.getRecommendedVersion(versionList), - apkMirrorUrl = SupportedApp.getApkMirrorUrl(packageName) + recommendedVersion = recommendedVersion, + apkMirrorUrl = SupportedApp.getDownloadUrl(packageName, recommendedVersion) ) }.sortedBy { it.displayName } } From 67a8aab6be62c389b3e53f7ca72ba88f9ca339c5 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:16:02 +0100 Subject: [PATCH 10/23] fix build --- build.gradle.kts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 8af331b..bc728b6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -175,6 +175,14 @@ tasks { mergeServiceFiles() } + distTar { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } + + distZip { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } + publish { dependsOn(shadowJar) } From d30d4a5feae2b9bfde92d02a1cde52f54f8a59e4 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:16:20 +0100 Subject: [PATCH 11/23] Add IDE launcher preset --- .run/CLI GUI.run.xml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .run/CLI GUI.run.xml diff --git a/.run/CLI GUI.run.xml b/.run/CLI GUI.run.xml new file mode 100644 index 0000000..048db85 --- /dev/null +++ b/.run/CLI GUI.run.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file From b6de86ba87b90807ced9cb81f53261f2aaa185da Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:03:08 +0100 Subject: [PATCH 12/23] Use web-search api --- .../app/morphe/gui/data/model/SupportedApp.kt | 4 +- .../morphe/gui/ui/screens/home/HomeScreen.kt | 4 +- .../gui/ui/screens/quick/QuickPatchScreen.kt | 12 ++- .../morphe/gui/util/DownloadUrlResolver.kt | 82 +++++++++++++++---- .../morphe/gui/util/SupportedAppExtractor.kt | 2 +- 5 files changed, 77 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt index 69adf66..a9d82c1 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt @@ -11,7 +11,7 @@ data class SupportedApp( val displayName: String, val supportedVersions: List, val recommendedVersion: String?, - val apkMirrorUrl: String? = null + val apkDownloadUrl: String? = null ) { companion object { /** @@ -36,7 +36,7 @@ data class SupportedApp( */ fun getDownloadUrl(packageName: String, version: String?): String? { if (version == null) return null - return DownloadUrlResolver.buildUrl(packageName, version) + return DownloadUrlResolver.getWebSearchDownloadLink(packageName, version) } /** diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 07c67d2..7dc4ed0 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -565,7 +565,7 @@ private fun DropPromptSection( Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) Text( - text = "Supported: .apk and .apkm files from APKMirror", + text = "Supported: .apk and .apkm files", fontSize = if (isCompact) 11.sp else 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -784,7 +784,7 @@ private fun SupportedAppCardDynamic( val cardPadding = if (isCompact) 12.dp else 16.dp - val apkMirrorUrl = supportedApp.apkMirrorUrl + val apkMirrorUrl = supportedApp.apkDownloadUrl Card( modifier = modifier, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index e38cd2d..fc2df41 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -36,7 +36,6 @@ import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light import app.morphe.gui.ui.theme.LocalThemeState import app.morphe.gui.ui.theme.ThemePreference -import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.util.PatchService @@ -49,6 +48,7 @@ import app.morphe.gui.util.AdbDevice import app.morphe.gui.util.AdbManager import kotlinx.coroutines.launch import app.morphe.gui.util.ChecksumStatus +import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects import java.awt.Desktop import java.awt.datatransfer.DataFlavor import java.io.File @@ -222,7 +222,11 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { isLoading = uiState.isLoadingPatches, loadError = uiState.patchLoadError, patchesVersion = uiState.patchesVersion, - onOpenUrl = { url -> uriHandler.openUri(url) }, + onOpenUrl = { url -> + openUrlAndFollowRedirects(url) { urlResolved -> + uriHandler.openUri(urlResolved) + } + }, onRetry = { viewModel.retryLoadPatches() } ) } @@ -751,7 +755,7 @@ private fun SupportedAppsRow( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Get the APK from APKMirror:", + text = "Download original APK:", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -813,7 +817,7 @@ private fun SupportedAppsRow( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { supportedApps.forEach { app -> - val url = app.apkMirrorUrl + val url = app.apkDownloadUrl if (url != null) { OutlinedCard( onClick = { onOpenUrl(url) }, diff --git a/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt b/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt index 9a854e3..97b7abf 100644 --- a/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt +++ b/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt @@ -1,29 +1,75 @@ package app.morphe.gui.util -/** - * Builds direct APKMirror release page URLs from package name + version. - * Pattern: https://www.apkmirror.com/apk/{publisher}/{app}/{app}-{version}-release/ - */ +import app.morphe.gui.data.constants.AppConstants.MORPHE_API_URL +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.SocketTimeoutException +import java.net.URL + object DownloadUrlResolver { - private data class ApkMirrorApp(val publisher: String, val name: String) + fun getWebSearchDownloadLink(packageName: String, version: String, architecture: String? = null): String { + val architectureString = architecture ?: "all" + return "$MORPHE_API_URL/v2/web-search/$packageName:$version:$architectureString" + } + + fun openUrlAndFollowRedirects(url: String, handleResolvedUrl: (String) -> Unit) { + CoroutineScope(Dispatchers.Main).launch { + val result = withContext(Dispatchers.IO) { + resolveRedirects(url) + } - private val PACKAGE_MAP = mapOf( - "com.google.android.youtube" to ApkMirrorApp("google-inc", "youtube"), - "com.google.android.apps.youtube.music" to ApkMirrorApp("google-inc", "youtube-music"), - "com.reddit.frontpage" to ApkMirrorApp("redditinc", "reddit") - ) + handleResolvedUrl(result) + } + } - fun buildUrl(packageName: String, version: String?): String { - if (version == null) return fallbackUrl(packageName) + fun resolveRedirects(url: String, maxRedirectsToFollow : Int = 5): String { + if (maxRedirectsToFollow <= 0) return url - val app = PACKAGE_MAP[packageName] ?: return fallbackUrl(packageName) - val versionSlug = version.replace(".", "-") + try { + val originalUrl = URL(url) + val connection = originalUrl.openConnection() as HttpURLConnection + connection.instanceFollowRedirects = false + connection.requestMethod = "HEAD" + connection.connectTimeout = 5_000 + connection.readTimeout = 5_000 - return "https://www.apkmirror.com/apk/${app.publisher}/${app.name}/${app.name}-$versionSlug-release/" - } + val responseCode = connection.responseCode + if (responseCode in 300..399) { + val location = connection.getHeaderField("Location") + + if (location.isNullOrBlank()) { + // Log.d("Location tag is blank: ${connection.responseMessage}") + return url + } - private fun fallbackUrl(packageName: String): String { - return "${app.morphe.gui.data.constants.AppConstants.MORPHE_API_URL}/v2/web-search/$packageName" + val resolved = + if (location.startsWith("http://") || location.startsWith("https://")) { + location + } else { + val prefix = "${originalUrl.protocol}://${originalUrl.host}" + if (location.startsWith("/")) "$prefix$location" else "$prefix/$location" + } + //Log.d("Result: $resolved") + + if (!resolved.startsWith(MORPHE_API_URL)) { + return resolved + } + + return resolveRedirects(resolved, maxRedirectsToFollow - 1) + } + + //Log.d("Unexpected response code: $responseCode") + } catch (ex: SocketTimeoutException) { + //Log.d("Timeout while resolving search redirect: $ex") + } catch (ex: Exception) { + //Log.d("Exception while resolving search redirect: $ex") + } + + return url } + } diff --git a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt index e7aa5ef..dc713a4 100644 --- a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt +++ b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt @@ -39,7 +39,7 @@ object SupportedAppExtractor { displayName = SupportedApp.getDisplayName(packageName), supportedVersions = versionList, recommendedVersion = recommendedVersion, - apkMirrorUrl = SupportedApp.getDownloadUrl(packageName, recommendedVersion) + apkDownloadUrl = SupportedApp.getDownloadUrl(packageName, recommendedVersion) ) }.sortedBy { it.displayName } } From afb884622e33ba5d7aa56c947f65ca17d2146212 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:58:28 +0100 Subject: [PATCH 13/23] Follow redirects in non simple mode --- .../app/morphe/gui/data/model/SupportedApp.kt | 3 +-- .../app/morphe/gui/ui/screens/home/HomeScreen.kt | 15 ++++++++------- .../gui/ui/screens/quick/QuickPatchScreen.kt | 2 +- .../app/morphe/gui/util/DownloadUrlResolver.kt | 7 +++---- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt index a9d82c1..e4a3b48 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt @@ -31,8 +31,7 @@ data class SupportedApp( } /** - * Get download URL for a package name and version. - * Returns a direct APKMirror search URL for the app + version. + * Get a web download URL for a package name and version. */ fun getDownloadUrl(packageName: String, version: String?): String? { if (version == null) return null diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 7dc4ed0..3cf33d9 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.platform.LocalUriHandler import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light @@ -39,6 +40,7 @@ import app.morphe.gui.ui.screens.home.components.FullScreenDropZone import app.morphe.gui.ui.screens.patches.PatchesScreen import app.morphe.gui.ui.screens.patches.PatchSelectionScreen import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -784,7 +786,7 @@ private fun SupportedAppCardDynamic( val cardPadding = if (isCompact) 12.dp else 16.dp - val apkMirrorUrl = supportedApp.apkDownloadUrl + val downloadUrl = supportedApp.apkDownloadUrl Card( modifier = modifier, @@ -901,13 +903,12 @@ private fun SupportedAppCardDynamic( Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) // Download from APKMirror button (only if URL is configured) - if (apkMirrorUrl != null) { + if (downloadUrl != null) { + val uriHandler = LocalUriHandler.current OutlinedButton( onClick = { - try { - java.awt.Desktop.getDesktop().browse(java.net.URI(apkMirrorUrl)) - } catch (e: Exception) { - // Ignore errors + openUrlAndFollowRedirects(downloadUrl) { urlResolved -> + uriHandler.openUri(urlResolved) } }, modifier = Modifier.fillMaxWidth(), @@ -921,7 +922,7 @@ private fun SupportedAppCardDynamic( ) ) { Text( - text = if (isCompact) "APKMirror" else "Get from APKMirror", + text = "Download original APK", fontSize = if (isCompact) 11.sp else 12.sp, fontWeight = FontWeight.Medium ) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index fc2df41..e5d3482 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -755,7 +755,7 @@ private fun SupportedAppsRow( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Download original APK:", + text = "Download original APK", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt b/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt index 97b7abf..c0a6922 100644 --- a/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt +++ b/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt @@ -42,7 +42,7 @@ object DownloadUrlResolver { val location = connection.getHeaderField("Location") if (location.isNullOrBlank()) { - // Log.d("Location tag is blank: ${connection.responseMessage}") + Logger.info("Location tag is blank: ${connection.responseMessage}") return url } @@ -53,7 +53,6 @@ object DownloadUrlResolver { val prefix = "${originalUrl.protocol}://${originalUrl.host}" if (location.startsWith("/")) "$prefix$location" else "$prefix/$location" } - //Log.d("Result: $resolved") if (!resolved.startsWith(MORPHE_API_URL)) { return resolved @@ -64,9 +63,9 @@ object DownloadUrlResolver { //Log.d("Unexpected response code: $responseCode") } catch (ex: SocketTimeoutException) { - //Log.d("Timeout while resolving search redirect: $ex") + Logger.info("Timeout while resolving search redirect: $ex") } catch (ex: Exception) { - //Log.d("Exception while resolving search redirect: $ex") + Logger.info("Exception while resolving search redirect: $ex") } return url From 3bfc2231b4854459b3726569038ea8e639411250 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 8 Feb 2026 09:56:28 +0530 Subject: [PATCH 14/23] Minor UI updates Fixed "Download original APK" button UI for reddit. Added more extensive timestamp for when patches are published. Added Patch notes section in the patches screen. Fixed minor UI problems --- .../morphe/gui/ui/screens/home/HomeScreen.kt | 2 +- .../screens/patches/PatchSelectionScreen.kt | 1 + .../gui/ui/screens/patches/PatchesScreen.kt | 245 ++++++++++++++---- 3 files changed, 200 insertions(+), 48 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 3cf33d9..1a0ad2f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -693,7 +693,7 @@ private fun SupportedAppsSection( verticalAlignment = Alignment.Top, modifier = Modifier .padding(horizontal = if (isCompact) 8.dp else 16.dp) - .widthIn(max = 600.dp) + .widthIn(max = 700.dp) ) { supportedApps.forEach { app -> SupportedAppCardDynamic( 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 6a3b7fe..c89e9ce 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 @@ -51,6 +51,7 @@ import java.awt.datatransfer.StringSelection /** * Screen for selecting which patches to apply. + * This screen is the one that selects which patch options need to be applied. Eg: Custom Branding, Spoof App Version, etc. */ data class PatchSelectionScreen( val apkPath: String, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt index 7149f72..4a46b47 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* @@ -32,7 +34,8 @@ import app.morphe.gui.ui.theme.MorpheColors import java.io.File /** - * Screen for selecting patches to apply. + * Screen for selecting patch version to apply. + * This is the screen that selects the patches.mpp file */ data class PatchesScreen( val apkPath: String, @@ -299,77 +302,216 @@ private fun ReleaseCard( MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) } + var isExpanded by remember { mutableStateOf(false) } + val hasNotes = !release.body.isNullOrBlank() + Card( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick), colors = CardDefaults.cardColors(containerColor = backgroundColor), shape = RoundedCornerShape(12.dp) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = release.tagName, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + if (release.isDevRelease()) { + Surface( + color = MorpheColors.Teal.copy(alpha = 0.2f), + shape = RoundedCornerShape(4.dp) + ) { + Text( + text = "DEV", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = MorpheColors.Teal, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Show .mpp file info if available + release.assets.find { it.isMpp() }?.let { mppAsset -> + Text( + text = "${mppAsset.name} (${mppAsset.getFormattedSize()})", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( - text = release.tagName, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + text = "Published: ${formatDate(release.publishedAt)}", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) - if (release.isDevRelease()) { + + if (hasNotes) { + Spacer(modifier = Modifier.height(4.dp)) Surface( - color = MorpheColors.Teal.copy(alpha = 0.2f), - shape = RoundedCornerShape(4.dp) + color = MorpheColors.Blue.copy(alpha = 0.1f), + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable { isExpanded = !isExpanded } ) { - Text( - text = "DEV", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - color = MorpheColors.Teal, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = if (isExpanded) "Hide patch notes" else "Patch notes", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = MorpheColors.Blue + ) + Icon( + imageVector = if (isExpanded) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MorpheColors.Blue, + modifier = Modifier.size(16.dp) + ) + } } } } - Spacer(modifier = Modifier.height(4.dp)) - - // Show .mpp file info if available - release.assets.find { it.isMpp() }?.let { mppAsset -> - Text( - text = "${mppAsset.name} (${mppAsset.getFormattedSize()})", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MorpheColors.Blue, + modifier = Modifier.size(24.dp) ) } + } - Text( - text = "Published: ${formatDate(release.publishedAt)}", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + // Expandable release notes + if (isExpanded && hasNotes) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + FormattedReleaseNotes( + markdown = release.body.orEmpty(), + modifier = Modifier.padding(16.dp) ) } + } + } +} - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Selected", - tint = MorpheColors.Blue, - modifier = Modifier.size(24.dp) +/** + * Renders GitHub release notes markdown as formatted Compose text. + */ +@Composable +private fun FormattedReleaseNotes(markdown: String, modifier: Modifier = Modifier) { + val lines = parseMarkdown(markdown) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + lines.forEach { line -> + when (line) { + is MdLine.Header -> Text( + text = line.text, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + is MdLine.SubHeader -> Text( + text = line.text, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + is MdLine.Bullet -> { + Row { + Text( + text = "\u2022 ", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = line.text, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + } + } + is MdLine.Plain -> Text( + text = line.text, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp ) } } } } +private sealed class MdLine { + data class Header(val text: String) : MdLine() + data class SubHeader(val text: String) : MdLine() + data class Bullet(val text: String) : MdLine() + data class Plain(val text: String) : MdLine() +} + +private fun parseMarkdown(markdown: String): List { + return markdown.lines() + .filter { it.isNotBlank() } + .map { line -> + val trimmed = line.trim() + when { + trimmed.startsWith("# ") -> MdLine.Header(cleanMarkdown(trimmed.removePrefix("# "))) + trimmed.startsWith("## ") -> MdLine.Header(cleanMarkdown(trimmed.removePrefix("## "))) + trimmed.startsWith("### ") -> MdLine.SubHeader(cleanMarkdown(trimmed.removePrefix("### "))) + trimmed.startsWith("* ") -> MdLine.Bullet(cleanMarkdown(trimmed.removePrefix("* "))) + trimmed.startsWith("- ") -> MdLine.Bullet(cleanMarkdown(trimmed.removePrefix("- "))) + else -> MdLine.Plain(cleanMarkdown(trimmed)) + } + } +} + +/** + * Strip markdown syntax to plain readable text: + * - **bold** → bold + * - [text](url) → text + * - ([hash](url)) → remove entirely (commit refs) + */ +private fun cleanMarkdown(text: String): String { + var result = text + // Remove commit refs like ([abc1234](https://...)) + result = result.replace(Regex("""\(\[[\da-f]{7,}]\([^)]*\)\)"""), "") + // [text](url) → text + result = result.replace(Regex("""\[([^\]]*?)]\([^)]*\)"""), "$1") + // **bold** → bold + result = result.replace(Regex("""\*\*(.+?)\*\*"""), "$1") + // Clean up extra whitespace + result = result.replace(Regex("""\s+"""), " ").trim() + return result +} + @Composable private fun BottomActionBar( uiState: PatchesUiState, @@ -460,15 +602,24 @@ private fun BottomActionBar( private fun formatDate(isoDate: String): String { return try { - // Simple date formatting - takes "2024-01-15T10:30:00Z" and returns "Jan 15, 2024" + // Takes "2024-01-15T10:30:00Z" and returns "Jan 15, 2024 at 10:30 AM" val datePart = isoDate.substringBefore("T") + val timePart = isoDate.substringAfter("T").substringBefore("Z").substringBefore("+") val parts = datePart.split("-") if (parts.size == 3) { val months = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") val month = months.getOrElse(parts[1].toInt() - 1) { "???" } val day = parts[2].toInt() val year = parts[0] - "$month $day, $year" + val timeParts = timePart.split(":") + val timeStr = if (timeParts.size >= 2) { + val hour = timeParts[0].toInt() + val minute = timeParts[1] + val amPm = if (hour >= 12) "PM" else "AM" + val hour12 = if (hour == 0) 12 else if (hour > 12) hour - 12 else hour + " at $hour12:$minute $amPm UTC" + } else "" + "$month $day, $year$timeStr" } else { datePart } From 930d4b35946db41e39eac9ee116a04a409b6355b Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:03:04 +0530 Subject: [PATCH 15/23] Connected devices update Added a device indicator on the top which allows to see if a device is connected or not. Other minor UI and logic fixes. --- src/main/kotlin/app/morphe/gui/App.kt | 9 + .../gui/ui/components/DeviceIndicator.kt | 301 ++++++++++++++++++ .../gui/ui/components/SettingsButton.kt | 38 ++- .../gui/ui/components/SettingsDialog.kt | 42 ++- .../morphe/gui/ui/screens/home/HomeScreen.kt | 77 +++-- .../home/components/FullScreenDropZone.kt | 2 + .../screens/patches/PatchSelectionScreen.kt | 8 +- .../gui/ui/screens/patches/PatchesScreen.kt | 2 + .../gui/ui/screens/patching/PatchingScreen.kt | 5 +- .../gui/ui/screens/quick/QuickPatchScreen.kt | 49 +-- .../gui/ui/screens/result/ResultScreen.kt | 66 +--- .../kotlin/app/morphe/gui/util/AdbManager.kt | 30 +- .../app/morphe/gui/util/DeviceMonitor.kt | 83 +++++ 13 files changed, 579 insertions(+), 133 deletions(-) create mode 100644 src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt create mode 100644 src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index 8bd8504..5bd715d 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -21,6 +21,7 @@ import app.morphe.gui.ui.theme.LocalThemeState import app.morphe.gui.ui.theme.MorpheTheme import app.morphe.gui.ui.theme.ThemePreference import app.morphe.gui.ui.theme.ThemeState +import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.Logger /** @@ -95,6 +96,14 @@ private fun AppContent(initialSimplifiedMode: Boolean) { onChange = onModeChange ) + // Start/stop DeviceMonitor with app lifecycle + DisposableEffect(Unit) { + DeviceMonitor.startMonitoring() + onDispose { + DeviceMonitor.stopMonitoring() + } + } + MorpheTheme(themePreference = themePreference) { CompositionLocalProvider( LocalThemeState provides themeState, diff --git a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt new file mode 100644 index 0000000..697ebd5 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt @@ -0,0 +1,301 @@ +package app.morphe.gui.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.UsbOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.DeviceMonitor +import app.morphe.gui.util.DeviceStatus + +@Composable +fun DeviceIndicator(modifier: Modifier = Modifier) { + val monitorState by DeviceMonitor.state.collectAsState() + + val isAdbAvailable = monitorState.isAdbAvailable + val readyDevices = monitorState.devices.filter { it.isReady } + val unauthorizedDevices = monitorState.devices.filter { it.status == DeviceStatus.UNAUTHORIZED } + val selectedDevice = monitorState.selectedDevice + val hasDevices = monitorState.devices.isNotEmpty() + + var showPopup by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + Surface( + onClick = { showPopup = !showPopup }, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Status dot + val dotColor = when { + isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.6f) + selectedDevice != null && selectedDevice.isReady -> MorpheColors.Teal + unauthorizedDevices.isNotEmpty() -> Color(0xFFFF9800) + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + } + + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(dotColor) + ) + + // Display text + val displayText = when { + isAdbAvailable == null -> "Checking..." + isAdbAvailable == false -> "No ADB" + selectedDevice != null -> { + val arch = selectedDevice.architecture?.let { " \u2022 $it" } ?: "" + "${selectedDevice.displayName}$arch" + } + unauthorizedDevices.isNotEmpty() -> "Unauthorized" + else -> "No device" + } + + Text( + text = displayText, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = when { + isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) + selectedDevice != null -> MaterialTheme.colorScheme.onSurface + unauthorizedDevices.isNotEmpty() -> Color(0xFFFF9800) + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 180.dp) + ) + + // Always show dropdown arrow — popup has useful info in every state + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Device details", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Popup with device list / status info + DropdownMenu( + expanded = showPopup, + onDismissRequest = { showPopup = false } + ) { + when { + isAdbAvailable == false -> { + // ADB not found + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.UsbOff, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.error + ) + Column { + Text( + text = "ADB not found", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.error + ) + Text( + text = "Install Android SDK Platform Tools", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + onClick = { showPopup = false } + ) + } + + monitorState.devices.isEmpty() -> { + // ADB available but no devices visible + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Column { + Text( + text = "No devices detected", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Only devices with USB debugging enabled will appear here", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + }, + onClick = { showPopup = false } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MorpheColors.Blue.copy(alpha = 0.7f) + ) + Column { + Text( + text = "How to enable USB debugging", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = MorpheColors.Blue + ) + Text( + text = "Settings > Developer Options > USB Debugging", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + }, + onClick = { showPopup = false } + ) + } + + else -> { + // Device list + monitorState.devices.forEach { device -> + val isSelected = device.id == selectedDevice?.id + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = when { + isSelected -> MorpheColors.Teal + device.isReady -> MorpheColors.Blue + device.status == DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800) + else -> MaterialTheme.colorScheme.error + } + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.displayName, + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal + ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + device.architecture?.let { arch -> + Text( + text = arch, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = when (device.status) { + DeviceStatus.DEVICE -> "Connected" + DeviceStatus.UNAUTHORIZED -> "Unauthorized" + DeviceStatus.OFFLINE -> "Offline" + DeviceStatus.UNKNOWN -> "Unknown" + }, + fontSize = 11.sp, + color = when (device.status) { + DeviceStatus.DEVICE -> MorpheColors.Teal + DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800) + else -> MaterialTheme.colorScheme.error + } + ) + } + } + } + }, + onClick = { + if (device.isReady) { + DeviceMonitor.selectDevice(device) + } + showPopup = false + } + ) + } + + // USB debugging hint + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Column { + Text( + text = "Device connected but not listed?", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Enable USB Debugging in Developer Options", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + }, + onClick = { showPopup = false } + ) + } + } + } + } +} 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 ac1411b..97827e9 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -2,7 +2,9 @@ package app.morphe.gui.ui.components import app.morphe.gui.LocalModeState import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -10,7 +12,9 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp @@ -44,20 +48,19 @@ fun SettingsButton( } } - Box(modifier = modifier) { - IconButton( - onClick = { showSettingsDialog = true }, - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + Surface( + onClick = { showSettingsDialog = true }, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = modifier ) { Icon( imageVector = Icons.Default.Settings, contentDescription = "Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(8.dp) ) } - } if (showSettingsDialog) { SettingsDialog( @@ -79,3 +82,22 @@ fun SettingsButton( ) } } + +/** + * Top bar row that places DeviceIndicator + SettingsButton together. + * Use this instead of standalone SettingsButton on screens. + */ +@Composable +fun TopBarRow( + modifier: Modifier = Modifier, + allowCacheClear: Boolean = true, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + DeviceIndicator() + SettingsButton(allowCacheClear = allowCacheClear) + } +} 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 dd88e88..30b395d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -1,5 +1,7 @@ package app.morphe.gui.ui.components +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -12,6 +14,8 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -65,11 +69,29 @@ fun SettingsDialog( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { ThemePreference.entries.forEach { theme -> - FilterChip( - selected = currentTheme == theme, - onClick = { onThemeChange(theme) }, - label = { Text(theme.toDisplayName()) } - ) + val isSelected = currentTheme == theme + Surface( + shape = RoundedCornerShape(8.dp), + color = if (isSelected) MorpheColors.Blue.copy(alpha = 0.15f) + else Color.Transparent, + border = BorderStroke( + width = 1.dp, + color = if (isSelected) MorpheColors.Blue.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onThemeChange(theme) } + ) { + Text( + text = theme.toDisplayName(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + fontSize = 13.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MorpheColors.Blue + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } @@ -238,8 +260,14 @@ fun SettingsDialog( } }, confirmButton = { - TextButton(onClick = onDismiss) { - Text("Close") + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(8.dp) + ) { + Text( + "Close", + color = MaterialTheme.colorScheme.error + ) } } ) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 1a0ad2f..0354d76 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -34,7 +34,7 @@ import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.SupportedApp -import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.screens.home.components.ApkInfoCard import app.morphe.gui.ui.screens.home.components.FullScreenDropZone import app.morphe.gui.ui.screens.patches.PatchesScreen @@ -84,7 +84,8 @@ fun HomeScreenContent( FullScreenDropZone( isDragHovering = uiState.isDragHovering, onDragHoverChange = { viewModel.setDragHover(it) }, - onFilesDropped = { viewModel.onFilesDropped(it) } + onFilesDropped = { viewModel.onFilesDropped(it) }, + enabled = !uiState.isAnalyzing ) { BoxWithConstraints( modifier = Modifier @@ -228,8 +229,8 @@ fun HomeScreenContent( } } - // Settings button in top-right corner - SettingsButton( + // Top bar (device indicator + settings) in top-right corner + TopBarRow( modifier = Modifier .align(Alignment.TopEnd) .padding(padding), @@ -260,21 +261,27 @@ private fun MiddleContent( onChangeClick: () -> Unit, onContinueClick: () -> Unit ) { - if (uiState.apkInfo != null) { - ApkSelectedSection( - patchesLoaded = patchesLoaded, - apkInfo = uiState.apkInfo, - isCompact = isCompact, - onClearClick = onClearClick, - onChangeClick = onChangeClick, - onContinueClick = onContinueClick - ) - } else { - DropPromptSection( - isDragHovering = uiState.isDragHovering, - isCompact = isCompact, - onBrowseClick = onChangeClick - ) + when { + uiState.isAnalyzing -> { + AnalyzingSection(isCompact = isCompact) + } + uiState.apkInfo != null -> { + ApkSelectedSection( + patchesLoaded = patchesLoaded, + apkInfo = uiState.apkInfo, + isCompact = isCompact, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick + ) + } + else -> { + DropPromptSection( + isDragHovering = uiState.isDragHovering, + isCompact = isCompact, + onBrowseClick = onChangeClick + ) + } } } @@ -574,6 +581,38 @@ private fun DropPromptSection( } } +@Composable +private fun AnalyzingSection(isCompact: Boolean = false) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = if (isCompact) 16.dp else 32.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(if (isCompact) 36.dp else 44.dp), + color = MorpheColors.Blue, + strokeWidth = 3.dp + ) + + Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) + + Text( + text = "Analyzing APK...", + fontSize = if (isCompact) 16.sp else 18.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Reading app information", + fontSize = if (isCompact) 12.sp else 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + @Composable private fun SupportedAppsSection( isCompact: Boolean = false, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt index 8db0374..489bc91 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt @@ -18,6 +18,7 @@ fun FullScreenDropZone( isDragHovering: Boolean, onDragHoverChange: (Boolean) -> Unit, onFilesDropped: (List) -> Unit, + enabled: Boolean = true, content: @Composable () -> Unit ) { val dragAndDropTarget = remember { @@ -40,6 +41,7 @@ fun FullScreenDropZone( override fun onDrop(event: DragAndDropEvent): Boolean { onDragHoverChange(false) + if (!enabled) return false val transferable = event.awtTransferable return try { if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { 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 c89e9ce..67f5296 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 @@ -41,7 +41,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.Patch import org.koin.core.parameter.parametersOf import app.morphe.gui.ui.components.ErrorDialog -import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage import app.morphe.gui.ui.screens.patching.PatchingScreen @@ -137,7 +137,8 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { color = MorpheColors.Blue ) } - SettingsButton(allowCacheClear = false) + TopBarRow(allowCacheClear = false) + Spacer(Modifier.width(12.dp)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface @@ -369,6 +370,7 @@ private fun PatchListItem( Card( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onToggle), colors = CardDefaults.cardColors(containerColor = backgroundColor), shape = RoundedCornerShape(12.dp) @@ -382,7 +384,7 @@ private fun PatchListItem( ) { Checkbox( checked = isSelected, - onCheckedChange = { onToggle() }, + onCheckedChange = null, colors = CheckboxDefaults.colors( checkedColor = MorpheColors.Blue, uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt index 4a46b47..3289d04 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt @@ -27,6 +27,7 @@ import app.morphe.gui.data.model.Release import org.koin.core.parameter.parametersOf import cafe.adriel.voyager.koin.koinScreenModel import app.morphe.gui.ui.components.ErrorDialog +import app.morphe.gui.ui.components.DeviceIndicator import app.morphe.gui.ui.components.SettingsButton import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage @@ -105,6 +106,7 @@ fun PatchesScreenContent(viewModel: PatchesViewModel) { } }, actions = { + DeviceIndicator() IconButton( onClick = { viewModel.loadReleases() }, enabled = !uiState.isLoading diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt index 8e0978e..be0d351 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt @@ -25,7 +25,9 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.PatchConfig import org.koin.core.parameter.parametersOf +import app.morphe.gui.ui.components.DeviceIndicator import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.screens.result.ResultScreen import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.util.FileUtils @@ -115,7 +117,8 @@ fun PatchingScreenContent(viewModel: PatchingViewModel) { Text("Cancel") } } - SettingsButton(allowCacheClear = false) + TopBarRow(allowCacheClear = false) + Spacer(Modifier.width(12.dp)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index e5d3482..5c336c7 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -41,11 +41,11 @@ import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.util.PatchService import org.jetbrains.compose.resources.painterResource import org.koin.compose.koinInject -import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.theme.MorpheColors import androidx.compose.runtime.rememberCoroutineScope -import app.morphe.gui.util.AdbDevice import app.morphe.gui.util.AdbManager +import app.morphe.gui.util.DeviceMonitor import kotlinx.coroutines.launch import app.morphe.gui.util.ChecksumStatus import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects @@ -232,8 +232,8 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { } } - // Settings button in top-right corner - SettingsButton( + // Top bar (device indicator + settings) in top-right corner + TopBarRow( modifier = Modifier .align(Alignment.TopEnd) .padding(24.dp) @@ -538,39 +538,11 @@ private fun CompletedContent( val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } - var isAdbAvailable by remember { mutableStateOf(null) } - var connectedDevices by remember { mutableStateOf>(emptyList()) } - var selectedDevice by remember { mutableStateOf(null) } + val monitorState by DeviceMonitor.state.collectAsState() var isInstalling by remember { mutableStateOf(false) } var installError by remember { mutableStateOf(null) } var installSuccess by remember { mutableStateOf(false) } - fun refreshDevices() { - scope.launch { - val result = adbManager.getConnectedDevices() - result.fold( - onSuccess = { devices -> - connectedDevices = devices - val readyDevices = devices.filter { it.isReady } - if (readyDevices.size == 1) { - selectedDevice = readyDevices.first() - } - }, - onFailure = { - connectedDevices = emptyList() - selectedDevice = null - } - ) - } - } - - LaunchedEffect(Unit) { - isAdbAvailable = adbManager.isAdbAvailable() - if (isAdbAvailable == true) { - refreshDevices() - } - } - Column( modifier = Modifier .fillMaxSize() @@ -648,10 +620,11 @@ private fun CompletedContent( } } - if (isAdbAvailable == true) { + if (monitorState.isAdbAvailable == true) { Spacer(modifier = Modifier.height(16.dp)) - val readyDevices = connectedDevices.filter { it.isReady } + val readyDevices = monitorState.devices.filter { it.isReady } + val selectedDevice = monitorState.selectedDevice if (installSuccess) { Surface( @@ -718,10 +691,6 @@ private fun CompletedContent( fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(4.dp)) - TextButton(onClick = { refreshDevices() }) { - Text("Refresh", fontSize = 12.sp) - } } installError?.let { error -> @@ -1042,7 +1011,7 @@ private fun VerificationStatusBanner( private fun openFilePicker(): File? { val chooser = JFileChooser().apply { dialogTitle = "Select APK" - fileFilter = FileNameExtensionFilter("APK Files", "apk") + fileFilter = FileNameExtensionFilter("APK Files (*.apk, *.apkm)", "apk", "apkm") isAcceptAllFileFilterUsed = false } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index ba5a4e2..0409c25 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -27,11 +27,12 @@ import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.repository.ConfigRepository import kotlinx.coroutines.launch import org.koin.compose.koinInject -import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.util.AdbDevice import app.morphe.gui.util.AdbException import app.morphe.gui.util.AdbManager +import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.DeviceStatus import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger @@ -60,11 +61,8 @@ fun ResultScreenContent(outputPath: String) { val adbManager = remember { AdbManager() } val configRepository: ConfigRepository = koinInject() - // ADB state - var isAdbAvailable by remember { mutableStateOf(null) } - var connectedDevices by remember { mutableStateOf>(emptyList()) } - var selectedDevice by remember { mutableStateOf(null) } - var isLoadingDevices by remember { mutableStateOf(false) } + // ADB state from DeviceMonitor + val monitorState by DeviceMonitor.state.collectAsState() var isInstalling by remember { mutableStateOf(false) } var installProgress by remember { mutableStateOf("") } var installError by remember { mutableStateOf(null) } @@ -92,43 +90,9 @@ fun ResultScreenContent(outputPath: String) { } } - // Function to refresh device list - fun refreshDevices() { - scope.launch { - isLoadingDevices = true - val result = adbManager.getConnectedDevices() - result.fold( - onSuccess = { devices -> - connectedDevices = devices - // Auto-select if only one ready device - val readyDevices = devices.filter { it.isReady } - if (readyDevices.size == 1) { - selectedDevice = readyDevices.first() - } else if (selectedDevice != null && !readyDevices.any { it.id == selectedDevice?.id }) { - // Clear selection if previously selected device is no longer available - selectedDevice = null - } - }, - onFailure = { - connectedDevices = emptyList() - selectedDevice = null - } - ) - isLoadingDevices = false - } - } - - // Check ADB availability and fetch devices on load - LaunchedEffect(Unit) { - isAdbAvailable = adbManager.isAdbAvailable() - if (isAdbAvailable == true) { - refreshDevices() - } - } - // Install function fun installViaAdb() { - val device = selectedDevice ?: return + val device = monitorState.selectedDevice ?: return scope.launch { isInstalling = true installError = null @@ -248,17 +212,17 @@ fun ResultScreenContent(outputPath: String) { Spacer(modifier = Modifier.height(24.dp)) // ADB Install Section - if (isAdbAvailable == true) { + if (monitorState.isAdbAvailable == true) { AdbInstallSection( - devices = connectedDevices, - selectedDevice = selectedDevice, - isLoadingDevices = isLoadingDevices, + devices = monitorState.devices, + selectedDevice = monitorState.selectedDevice, + isLoadingDevices = false, isInstalling = isInstalling, installProgress = installProgress, installError = installError, installSuccess = installSuccess, - onDeviceSelected = { selectedDevice = it }, - onRefreshDevices = { refreshDevices() }, + onDeviceSelected = { DeviceMonitor.selectDevice(it) }, + onRefreshDevices = { }, onInstallClick = { installViaAdb() }, onRetryClick = { installError = null @@ -331,14 +295,14 @@ fun ResultScreenContent(outputPath: String) { Spacer(modifier = Modifier.height(24.dp)) // Help text (only show when ADB is not available) - if (isAdbAvailable == false) { + if (monitorState.isAdbAvailable == false) { Text( text = "ADB not found. Install Android SDK Platform Tools to enable direct installation.", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center ) - } else if (isAdbAvailable == null) { + } else if (monitorState.isAdbAvailable == null) { Text( text = "Checking for ADB...", fontSize = 13.sp, @@ -352,8 +316,8 @@ fun ResultScreenContent(outputPath: String) { } } - // Settings button in top-right corner - SettingsButton( + // Top bar (device indicator + settings) in top-right corner + TopBarRow( modifier = Modifier .align(Alignment.TopEnd) .padding(24.dp), diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt index 94f933c..998f4c0 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -261,14 +261,18 @@ class AdbManager { } } - // If device is authorized, try to get friendly device name + // If device is authorized, try to get friendly device name and architecture val deviceName = if (status == DeviceStatus.DEVICE) { model ?: product ?: getDeviceName(adbPath, id) } else { model ?: product } - AdbDevice(id, status, deviceName) + val architecture = if (status == DeviceStatus.DEVICE) { + getDeviceArchitecture(adbPath, id) + } else null + + AdbDevice(id, status, deviceName, architecture) } else null } } @@ -289,6 +293,22 @@ class AdbManager { } } + /** + * Get device CPU architecture using adb shell command. + */ + private fun getDeviceArchitecture(adbPath: String, deviceId: String): String? { + return try { + val process = ProcessBuilder(adbPath, "-s", deviceId, "shell", "getprop", "ro.product.cpu.abi") + .redirectErrorStream(true) + .start() + val result = process.inputStream.bufferedReader().readText().trim() + process.waitFor() + if (process.exitValue() == 0 && result.isNotBlank()) result else null + } catch (e: Exception) { + null + } + } + private fun parseInstallError(output: String): String { // Common ADB install errors return when { @@ -321,7 +341,8 @@ class AdbManager { data class AdbDevice( val id: String, val status: DeviceStatus, - val model: String? = null + val model: String? = null, + val architecture: String? = null ) { /** Device name (model or ID if model unknown) */ val displayName: String @@ -331,8 +352,9 @@ data class AdbDevice( val displayNameWithStatus: String get() { val name = displayName + val arch = architecture?.let { " ($it)" } ?: "" return when (status) { - DeviceStatus.DEVICE -> "$name (Connected)" + DeviceStatus.DEVICE -> "$name$arch (Connected)" DeviceStatus.UNAUTHORIZED -> "$name (Unauthorized - check device)" DeviceStatus.OFFLINE -> "$name (Offline)" DeviceStatus.UNKNOWN -> "$name (Unknown status)" diff --git a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt new file mode 100644 index 0000000..8f892b3 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt @@ -0,0 +1,83 @@ +package app.morphe.gui.util + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class DeviceMonitorState( + val devices: List = emptyList(), + val selectedDevice: AdbDevice? = null, + val isAdbAvailable: Boolean? = null +) + +object DeviceMonitor { + private val _state = MutableStateFlow(DeviceMonitorState()) + val state: StateFlow = _state.asStateFlow() + + private val adbManager = AdbManager() + private var pollingJob: Job? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + fun startMonitoring() { + if (pollingJob?.isActive == true) return + + pollingJob = scope.launch { + // Initial ADB check + val adbAvailable = adbManager.isAdbAvailable() + _state.value = _state.value.copy(isAdbAvailable = adbAvailable) + + if (!adbAvailable) return@launch + + // Poll every 5 seconds + while (isActive) { + refreshDevices() + delay(5000) + } + } + } + + fun stopMonitoring() { + pollingJob?.cancel() + pollingJob = null + } + + fun selectDevice(device: AdbDevice) { + _state.value = _state.value.copy(selectedDevice = device) + } + + private suspend fun refreshDevices() { + val result = adbManager.getConnectedDevices() + result.fold( + onSuccess = { devices -> + val currentState = _state.value + val readyDevices = devices.filter { it.isReady } + + // Determine selected device + val selected = when { + // Keep current selection if it's still available + currentState.selectedDevice != null && + readyDevices.any { it.id == currentState.selectedDevice.id } -> + readyDevices.first { it.id == currentState.selectedDevice.id } + // Auto-select if only one ready device + readyDevices.size == 1 -> readyDevices.first() + // Clear selection if no ready devices + readyDevices.isEmpty() -> null + // Keep null if multiple devices and no prior selection + else -> currentState.selectedDevice + } + + _state.value = currentState.copy( + devices = devices, + selectedDevice = selected + ) + }, + onFailure = { + _state.value = _state.value.copy( + devices = emptyList(), + selectedDevice = null + ) + } + ) + } +} From 783dd7a8902d2377ff1dc3cb318cfc89d6eb03cd Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:53:23 +0530 Subject: [PATCH 16/23] Patch Screen UI Improvements No more hardcoded disabled patches, directly read from the patches files Made a bunch of UI improvements for the patch screen. Simplified mode loading now shows a circular icon instead of making up progress numbers. --- .../gui/ui/components/SettingsButton.kt | 6 +- .../gui/ui/components/SettingsDialog.kt | 14 +- .../screens/patches/PatchSelectionScreen.kt | 300 +++++++----------- .../patches/PatchSelectionViewModel.kt | 40 +-- .../gui/ui/screens/quick/QuickPatchScreen.kt | 23 +- .../ui/screens/quick/QuickPatchViewModel.kt | 21 +- 6 files changed, 152 insertions(+), 252 deletions(-) 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 97827e9..b5f70bd 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -73,9 +73,9 @@ fun SettingsButton( configRepository.setAutoCleanupTempFiles(enabled) } }, - useSimplifiedMode = modeState.isSimplified, - onSimplifiedModeChange = { enabled -> - modeState.onChange(enabled) + useExpertMode = !modeState.isSimplified, + onExpertModeChange = { enabled -> + modeState.onChange(!enabled) }, onDismiss = { showSettingsDialog = false }, allowCacheClear = allowCacheClear 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 30b395d..6730662 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -33,8 +33,8 @@ fun SettingsDialog( onThemeChange: (ThemePreference) -> Unit, autoCleanupTempFiles: Boolean, onAutoCleanupChange: (Boolean) -> Unit, - useSimplifiedMode: Boolean, - onSimplifiedModeChange: (Boolean) -> Unit, + useExpertMode: Boolean, + onExpertModeChange: (Boolean) -> Unit, onDismiss: () -> Unit, allowCacheClear: Boolean = true ) { @@ -97,7 +97,7 @@ fun SettingsDialog( HorizontalDivider() - // Simplified mode setting + // Expert mode setting Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -105,20 +105,20 @@ fun SettingsDialog( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = "Simplified mode", + text = "Expert mode", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Quick one-click patching with default settings", + text = "Full control over patch selection and configuration", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( - checked = useSimplifiedMode, - onCheckedChange = onSimplifiedModeChange, + checked = useExpertMode, + onCheckedChange = onExpertModeChange, colors = SwitchDefaults.colors( checkedThumbColor = MorpheColors.Blue, checkedTrackColor = MorpheColors.Blue.copy(alpha = 0.5f) 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 67f5296..2acc44e 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 @@ -3,6 +3,7 @@ package app.morphe.gui.ui.screens.patches import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -20,8 +21,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.* import androidx.compose.runtime.* @@ -52,6 +51,7 @@ import java.awt.datatransfer.StringSelection /** * Screen for selecting which patches to apply. * This screen is the one that selects which patch options need to be applied. Eg: Custom Branding, Spoof App Version, etc. + * TODO: Maybe relocate the 'Suggested Deselected Patches' section to the TopBar? */ data class PatchSelectionScreen( val apkPath: String, @@ -102,6 +102,10 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { ) } + // State for command preview + var cleanMode by remember { mutableStateOf(false) } + var showCommandPreview by remember { mutableStateOf(false) } + Scaffold( topBar = { TopAppBar( @@ -125,19 +129,51 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { }, actions = { // Select all / Deselect all - TextButton(onClick = { + TextButton( + onClick = { if (uiState.selectedPatches.size == uiState.allPatches.size) { viewModel.deselectAll() } else { viewModel.selectAll() } - }) { + }, + shape = RoundedCornerShape(12.dp) + ) { Text( if (uiState.selectedPatches.size == uiState.allPatches.size) "Deselect All" else "Select All", color = MorpheColors.Blue ) } + + Spacer(Modifier.width(12.dp)) + + // Command preview toggle + if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { + val isActive = showCommandPreview + Surface( + onClick = { showCommandPreview = !showCommandPreview }, + shape = RoundedCornerShape(8.dp), + color = if (isActive) MorpheColors.Teal.copy(alpha = 0.15f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke( + width = 1.dp, + color = if (isActive) MorpheColors.Teal.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = "Command Preview", + tint = if (isActive) MorpheColors.Teal else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(8.dp).size(20.dp) + ) + } + } + + Spacer(Modifier.width(12.dp)) + TopBarRow(allowCacheClear = false) + Spacer(Modifier.width(12.dp)) }, colors = TopAppBarDefaults.topAppBarColors( @@ -146,32 +182,32 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { ) }, ) { paddingValues -> - // State for command preview - var cleanMode by remember { mutableStateOf(false) } - var isCollapsed by remember { mutableStateOf(false) } - Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { - // Command preview at the top - updates in real-time + // Command preview - collapsible via top bar button if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { val commandPreview = remember(uiState.selectedPatches, cleanMode) { viewModel.getCommandPreview(cleanMode) } - CommandPreview( - command = commandPreview, - cleanMode = cleanMode, - isCollapsed = isCollapsed, - onToggleMode = { cleanMode = !cleanMode }, - onToggleCollapse = { isCollapsed = !isCollapsed }, - onCopy = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(commandPreview), null) - }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) + AnimatedVisibility( + visible = showCommandPreview, + enter = expandVertically(), + exit = shrinkVertically() + ) { + CommandPreview( + command = commandPreview, + cleanMode = cleanMode, + onToggleMode = { cleanMode = !cleanMode }, + onCopy = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(commandPreview), null) + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } } // Search bar @@ -183,21 +219,20 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) - // Commonly disabled patches suggestion - val commonlyDisabledPatches = remember(uiState.selectedPatches, uiState.allPatches) { - viewModel.getCommonlyDisabledPatches() + // Info card about default-disabled patches + val defaultDisabledCount = remember(uiState.allPatches) { + viewModel.getDefaultDisabledCount() } - var suggestionDismissed by remember { mutableStateOf(false) } + var infoDismissed by remember { mutableStateOf(false) } AnimatedVisibility( - visible = commonlyDisabledPatches.isNotEmpty() && !suggestionDismissed && !uiState.isLoading, + visible = defaultDisabledCount > 0 && !infoDismissed && !uiState.isLoading, enter = expandVertically(), exit = shrinkVertically() ) { - CommonlyDisabledSuggestion( - patches = commonlyDisabledPatches, - onDeselectAll = { viewModel.deselectCommonlyDisabled() }, - onDismiss = { suggestionDismissed = true }, + DefaultDisabledInfoCard( + count = defaultDisabledCount, + onDismiss = { infoDismissed = true }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) ) } @@ -459,111 +494,47 @@ private fun PatchListItem( } @Composable -private fun CommonlyDisabledSuggestion( - patches: List>, - onDeselectAll: () -> Unit, +private fun DefaultDisabledInfoCard( + count: Int, onDismiss: () -> Unit, modifier: Modifier = Modifier ) { Card( modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = Color(0xFFFF9800).copy(alpha = 0.1f) + containerColor = MorpheColors.Blue.copy(alpha = 0.08f) ), shape = RoundedCornerShape(12.dp) ) { - Column( - modifier = Modifier.padding(12.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = Color(0xFFFF9800), - modifier = Modifier.size(18.dp) - ) - Text( - text = "Commonly disabled patches", - fontWeight = FontWeight.Medium, - fontSize = 13.sp, - color = Color(0xFFFF9800) - ) - } - IconButton( - onClick = onDismiss, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Dismiss", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MorpheColors.Blue, + modifier = Modifier.size(18.dp) + ) Text( - text = "These ${patches.size} patch${if (patches.size > 1) "es are" else " is"} commonly disabled by users:", + text = "$count patch${if (count > 1) "es are" else " is"} unselected by default as they may cause issues or are not recommended by the patches team.", fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) ) - - Spacer(modifier = Modifier.height(6.dp)) - - // List patch names - patches.take(4).forEach { (patch, _) -> - Text( - text = "• ${patch.name}", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (patches.size > 4) { - Text( - text = "• +${patches.size - 4} more", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp) ) { - TextButton( - onClick = onDismiss, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) - ) { - Text("Keep all", fontSize = 12.sp) - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { - onDeselectAll() - onDismiss() - }, - colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFF9800) - ), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), - shape = RoundedCornerShape(8.dp) - ) { - Text("Deselect these", fontSize = 12.sp) - } + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) } } } @@ -576,18 +547,14 @@ private fun CommonlyDisabledSuggestion( private fun CommandPreview( command: String, cleanMode: Boolean, - isCollapsed: Boolean, onToggleMode: () -> Unit, - onToggleCollapse: () -> Unit, onCopy: () -> Unit, modifier: Modifier = Modifier ) { val terminalBackground = Color(0xFF1E1E1E) -// val terminalGreen = Color(0xFF4EC9B0) val terminalGreen = Color(0xFF6A9955) val terminalText = Color(0xFFD4D4D4) val terminalDim = Color(0xFF6A9955) -// val terminalDim = Color(0xFF4EC9B0) var showCopied by remember { mutableStateOf(false) } @@ -607,20 +574,16 @@ private fun CommandPreview( Column( modifier = Modifier.padding(12.dp) ) { - // Header with terminal icon, controls, and collapse toggle + // Header with terminal icon and controls Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - // Left side - icon, title, and collapse toggle + // Left side - icon and title Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onToggleCollapse) - .padding(end = 8.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( imageVector = Icons.Default.Terminal, @@ -634,12 +597,6 @@ private fun CommandPreview( fontWeight = FontWeight.Bold, color = terminalGreen ) - Icon( - imageVector = if (isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess, - contentDescription = if (isCollapsed) "Expand" else "Collapse", - tint = terminalDim, - modifier = Modifier.size(16.dp) - ) } // Right side - controls @@ -676,51 +633,40 @@ private fun CommandPreview( } } - // Mode toggle (only show when not collapsed) - if (!isCollapsed) { - Surface( - onClick = onToggleMode, - color = Color.Transparent, - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = if (cleanMode) "Compact" else "Expand", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = terminalDim, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - } - - // Command text - collapsible, vertically scrollable - AnimatedVisibility( - visible = !isCollapsed, - enter = expandVertically(), - exit = shrinkVertically() - ) { - Column { - Spacer(modifier = Modifier.height(8.dp)) - - // Vertically scrollable command text with max height - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 120.dp) - .verticalScroll(rememberScrollState()) + // Mode toggle + Surface( + onClick = onToggleMode, + color = Color.Transparent, + shape = RoundedCornerShape(4.dp) ) { Text( - text = command, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = terminalText, - lineHeight = 16.sp + text = if (cleanMode) "Compact" else "Expand", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = terminalDim, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) ) } } } + + Spacer(modifier = Modifier.height(8.dp)) + + // Vertically scrollable command text with max height + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 120.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = command, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = terminalText, + lineHeight = 16.sp + ) + } } } } 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 3136327..a4a9540 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 @@ -2,7 +2,6 @@ package app.morphe.gui.ui.screens.patches import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchConfig import kotlinx.coroutines.flow.MutableStateFlow @@ -69,11 +68,17 @@ class PatchSelectionViewModel( Logger.info("Loaded ${deduplicatedPatches.size} patches for $packageName") + // Only select patches that are enabled by default in the .mpp file + val defaultSelected = deduplicatedPatches + .filter { it.isEnabled } + .map { it.uniqueId } + .toSet() + _uiState.value = _uiState.value.copy( isLoading = false, allPatches = deduplicatedPatches, filteredPatches = deduplicatedPatches, - selectedPatches = deduplicatedPatches.map { it.uniqueId }.toSet() + selectedPatches = defaultSelected ) }, onFailure = { e -> @@ -143,35 +148,10 @@ class PatchSelectionViewModel( } /** - * Get patches that match the commonly disabled list and are currently selected. - * Returns list of (patch, reason) pairs. + * Count of patches that are disabled by default (from .mpp metadata). */ - fun getCommonlyDisabledPatches(): List> { - val packageName = getPackageNameFromApk() - val commonlyDisabled = AppConstants.PatchRecommendations.getCommonlyDisabled(packageName) - - return _uiState.value.allPatches - .filter { patch -> _uiState.value.selectedPatches.contains(patch.uniqueId) } - .mapNotNull { patch -> - // Find matching commonly disabled entry - val match = commonlyDisabled.find { (pattern, _) -> - patch.name.contains(pattern, ignoreCase = true) - } - if (match != null) { - patch to match.second - } else { - null - } - } - } - - /** - * Deselect all commonly disabled patches at once. - */ - fun deselectCommonlyDisabled() { - val patchesToDeselect = getCommonlyDisabledPatches().map { it.first.uniqueId }.toSet() - val newSelection = _uiState.value.selectedPatches - patchesToDeselect - _uiState.value = _uiState.value.copy(selectedPatches = newSelection) + fun getDefaultDisabledCount(): Int { + return _uiState.value.allPatches.count { !it.isEnabled } } fun createPatchConfig(): PatchConfig { diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 5c336c7..1845e3d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -195,7 +195,6 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { QuickPatchPhase.DOWNLOADING, QuickPatchPhase.PATCHING -> { PatchingContent( phase = phase, - progress = uiState.progress, statusMessage = uiState.statusMessage, onCancel = { viewModel.cancelPatching() } ) @@ -471,7 +470,6 @@ private fun ReadyContent( @Composable private fun PatchingContent( phase: QuickPatchPhase, - progress: Float, statusMessage: String, onCancel: () -> Unit ) { @@ -480,22 +478,11 @@ private fun PatchingContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - // Progress indicator - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator( - progress = { progress }, - modifier = Modifier.size(100.dp), - strokeWidth = 6.dp, - color = MorpheColors.Teal, - trackColor = MaterialTheme.colorScheme.surfaceVariant - ) - Text( - text = "${(progress * 100).toInt()}%", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - } + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + strokeWidth = 4.dp, + color = MorpheColors.Teal + ) Spacer(modifier = Modifier.height(24.dp)) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index 75a1b16..2907fd7 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -324,29 +324,16 @@ class QuickPatchViewModel( val outputFileName = "$baseName-Morphe-${apkInfo.versionName}.apk" val outputPath = File(outputDir, outputFileName).absolutePath - // Auto-deselect commonly disabled patches for this app - val commonlyDisabled = AppConstants.PatchRecommendations.getCommonlyDisabled(apkInfo.packageName) - val disabledPatches = cachedPatches - .filter { patch -> - commonlyDisabled.any { (pattern, _) -> - patch.name.contains(pattern, ignoreCase = true) - } - } - .map { it.name } - - if (disabledPatches.isNotEmpty()) { - Logger.info("Quick mode: Auto-disabling patches: $disabledPatches") - } - // Use PatchService for direct library patching (no CLI subprocess) + // exclusiveMode = false means the library's patch.use field determines defaults val patchResult = patchService.patch( patchesFilePath = patchFile.absolutePath, inputApkPath = apkFile.absolutePath, outputApkPath = outputPath, - enabledPatches = emptyList(), // Empty = use defaults - disabledPatches = disabledPatches, + enabledPatches = emptyList(), + disabledPatches = emptyList(), options = emptyMap(), - exclusiveMode = false, // Include all default patches + exclusiveMode = false, onProgress = { message -> // Update status with current operation if (message.contains("patch", ignoreCase = true) || From fdba2c614cccd1240b8b5318e99aea09563f39db Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:17:13 +0530 Subject: [PATCH 17/23] --riplibs update User can remove libs if they do not need it --- .../app/morphe/cli/command/PatchCommand.kt | 11 -- .../kotlin/app/morphe/gui/data/model/Patch.kt | 3 +- .../kotlin/app/morphe/gui/di/AppModule.kt | 2 +- .../morphe/gui/ui/screens/home/HomeScreen.kt | 6 +- .../gui/ui/screens/home/HomeViewModel.kt | 31 ++++- .../screens/patches/PatchSelectionScreen.kt | 123 +++++++++++++++++- .../patches/PatchSelectionViewModel.kt | 46 ++++++- .../ui/screens/patching/PatchingViewModel.kt | 1 + .../app/morphe/gui/util/PatchService.kt | 6 + 9 files changed, 199 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index bf88cd4..43be69e 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -415,17 +415,6 @@ internal object PatchCommand : Runnable { patcherResult.applyTo(this) } ) - }.also { rebuiltApk -> - if (striplibs.isNotEmpty()) { - patchingResult.addStepResult( - PatchingStep.STRIPPING_LIBS, - { - ApkLibraryStripper.stripLibraries(rebuiltApk, striplibs) { msg -> - logger.info(msg) - } - } - ) - } }.let { patchedApkFile -> if (!mount && !unsigned) { patchingResult.addStepResult( diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt index 42b1396..e3c11e2 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -79,5 +79,6 @@ data class PatchConfig( val enabledPatches: List = emptyList(), val disabledPatches: List = emptyList(), val patchOptions: Map = emptyMap(), - val useExclusiveMode: Boolean = false + val useExclusiveMode: Boolean = false, + val riplibs: List = emptyList() ) diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index b542bca..b7a31d0 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -58,6 +58,6 @@ val appModule = module { // ViewModels (ScreenModels) factory { HomeViewModel(get(), get(), get()) } factory { params -> PatchesViewModel(params.get(), params.get(), get(), get()) } - factory { params -> PatchSelectionViewModel(params.get(), params.get(), params.get(), get(), get()) } + factory { params -> PatchSelectionViewModel(params.get(), params.get(), params.get(), params.get(), get(), get()) } factory { params -> PatchingViewModel(params.get(), get(), get()) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 0354d76..585aa9a 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -112,7 +112,8 @@ fun HomeScreenContent( navigator.push(PatchSelectionScreen( apkPath = uiState.apkInfo!!.filePath, apkName = uiState.apkInfo!!.appName, - patchesFilePath = patchesFile.absolutePath + patchesFilePath = patchesFile.absolutePath, + apkArchitectures = uiState.apkInfo!!.architectures )) } }, @@ -204,7 +205,8 @@ fun HomeScreenContent( navigator.push(PatchSelectionScreen( apkPath = info.filePath, apkName = info.appName, - patchesFilePath = patchesFile.absolutePath + patchesFilePath = patchesFile.absolutePath, + apkArchitectures = info.architectures )) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 7e33e9b..141527a 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -331,8 +331,9 @@ class HomeViewModel( VersionStatus.UNKNOWN } - // Get supported architectures from native libraries in the APK - val architectures = extractArchitectures(apkToParse) + // Get supported architectures from native libraries + // For .apkm files, scan the original bundle (splits contain the native libs, not base.apk) + val architectures = extractArchitectures(if (isApkm) file else apkToParse) // Verify checksum (still uses AppConstants for now) val checksumStatus = verifyChecksum(file, packageName, versionName, architectures, suggestedVersion) @@ -370,18 +371,34 @@ class HomeViewModel( private fun extractArchitectures(file: File): List { return try { java.util.zip.ZipFile(file).use { zip -> - val archDirs = zip.entries().asSequence() + val archDirs = mutableSetOf() + + // Scan for lib// entries directly (regular APK or merged APK) + zip.entries().asSequence() .map { it.name } .filter { it.startsWith("lib/") } .mapNotNull { path -> val parts = path.split("/") if (parts.size >= 2) parts[1] else null } - .distinct() - .toList() + .forEach { archDirs.add(it) } + + // For .apkm bundles: also detect arch from split APK names + // e.g. split_config.arm64_v8a.apk -> arm64-v8a + if (archDirs.isEmpty()) { + val knownArchs = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + zip.entries().asSequence() + .map { it.name } + .filter { it.endsWith(".apk") } + .forEach { name -> + // Convert split_config.arm64_v8a.apk format to arm64-v8a + val normalized = name.replace("_", "-") + knownArchs.filter { arch -> normalized.contains(arch) } + .forEach { archDirs.add(it) } + } + } - archDirs.ifEmpty { - // No native libs - likely a universal APK + archDirs.toList().ifEmpty { listOf("universal") } } 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 2acc44e..9cbfcd3 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 @@ -45,24 +45,25 @@ import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage import app.morphe.gui.ui.screens.patching.PatchingScreen import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.DeviceMonitor import java.awt.Toolkit import java.awt.datatransfer.StringSelection /** * Screen for selecting which patches to apply. * This screen is the one that selects which patch options need to be applied. Eg: Custom Branding, Spoof App Version, etc. - * TODO: Maybe relocate the 'Suggested Deselected Patches' section to the TopBar? */ data class PatchSelectionScreen( val apkPath: String, val apkName: String, - val patchesFilePath: String + val patchesFilePath: String, + val apkArchitectures: List = emptyList() ) : Screen { @Composable override fun Content() { val viewModel = koinScreenModel { - parametersOf(apkPath, apkName, patchesFilePath) + parametersOf(apkPath, apkName, patchesFilePath, apkArchitectures) } PatchSelectionScreenContent(viewModel = viewModel) } @@ -189,7 +190,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { ) { // Command preview - collapsible via top bar button if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { - val commandPreview = remember(uiState.selectedPatches, cleanMode) { + val commandPreview = remember(uiState.selectedPatches, uiState.selectedArchitectures, cleanMode) { viewModel.getCommandPreview(cleanMode) } AnimatedVisibility( @@ -278,6 +279,22 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + // Architecture selector at the top of the list + // Disabled for .apkm files until properly tested with merged APKs + val isApkm = viewModel.getApkPath().endsWith(".apkm", ignoreCase = true) + val showArchSelector = !isApkm && + 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) } + ) + } + } + items( items = uiState.filteredPatches, key = { it.uniqueId } @@ -670,3 +687,101 @@ private fun CommandPreview( } } } + +@Composable +private fun ArchitectureSelectorCard( + architectures: List, + selectedArchitectures: Set, + onToggleArchitecture: (String) -> Unit, + modifier: Modifier = Modifier +) { + // Get connected device architecture for hint + val deviceState by DeviceMonitor.state.collectAsState() + val deviceArch = deviceState.selectedDevice?.architecture + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MorpheColors.Teal.copy(alpha = 0.08f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MorpheColors.Teal, + modifier = Modifier.size(18.dp) + ) + Text( + text = "Strip native libraries", + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Uncheck architectures to remove from the output APK and reduce file size.", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (deviceArch != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Your device: $deviceArch", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = MorpheColors.Teal + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + architectures.forEach { arch -> + val isSelected = selectedArchitectures.contains(arch) + FilterChip( + selected = isSelected, + onClick = { onToggleArchitecture(arch) }, + label = { + Text( + text = arch, + fontSize = 12.sp + ) + }, + leadingIcon = if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + } + } else null, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MorpheColors.Teal.copy(alpha = 0.2f), + selectedLabelColor = MorpheColors.Teal + ) + ) + } + } + } + } +} 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 a4a9540..3a86325 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 @@ -17,6 +17,7 @@ class PatchSelectionViewModel( private val apkPath: String, private val apkName: String, private val patchesFilePath: String, + private val apkArchitectures: List, private val patchService: PatchService, private val patchRepository: PatchRepository ) : ScreenModel { @@ -24,7 +25,10 @@ class PatchSelectionViewModel( // Actual path to use - may differ from patchesFilePath if we had to re-download private var actualPatchesFilePath: String = patchesFilePath - private val _uiState = MutableStateFlow(PatchSelectionUiState()) + private val _uiState = MutableStateFlow(PatchSelectionUiState( + apkArchitectures = apkArchitectures, + selectedArchitectures = apkArchitectures.toSet() + )) val uiState: StateFlow = _uiState.asStateFlow() init { @@ -147,6 +151,18 @@ 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) + } + /** * Count of patches that are disabled by default (from .mpp metadata). */ @@ -175,13 +191,21 @@ class PatchSelectionViewModel( .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } .map { it.name } + // Only set riplibs if user deselected any architecture (keeps = selected ones) + val riplibs = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { + _uiState.value.selectedArchitectures.toList() + } else { + emptyList() + } + return PatchConfig( inputApkPath = apkPath, outputApkPath = outputPath, patchesFilePath = actualPatchesFilePath, enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, - useExclusiveMode = true + useExclusiveMode = true, + riplibs = riplibs ) } @@ -212,6 +236,13 @@ class PatchSelectionViewModel( .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } .map { it.name } + // riplibs flag: only when user deselected at least one architecture + val riplibsArg = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { + _uiState.value.selectedArchitectures.joinToString(",") + } else { + null + } + return if (cleanMode) { val sb = StringBuilder() sb.append("java -jar morphe-cli.jar patch \\\n") @@ -219,6 +250,10 @@ class PatchSelectionViewModel( sb.append(" -o ${outputFileName} \\\n") sb.append(" --exclusive \\\n") + if (riplibsArg != null) { + sb.append(" --riplibs $riplibsArg \\\n") + } + selectedPatchNames.forEachIndexed { index, patch -> val isLast = index == selectedPatchNames.lastIndex sb.append(" -e \"$patch\"") @@ -233,7 +268,8 @@ class PatchSelectionViewModel( } else { // Compact mode - single line that wraps naturally val patches = selectedPatchNames.joinToString(" ") { "-e \"$it\"" } - "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --exclusive $patches ${inputFile.name}" + val riplibsPart = if (riplibsArg != null) " --riplibs $riplibsArg" else "" + "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --exclusive$riplibsPart $patches ${inputFile.name}" } } @@ -298,7 +334,9 @@ data class PatchSelectionUiState( val selectedPatches: Set = emptySet(), val searchQuery: String = "", val showOnlySelected: Boolean = false, - val error: String? = null + val error: String? = null, + val apkArchitectures: List = emptyList(), + val selectedArchitectures: Set = emptySet() ) { val selectedCount: Int get() = selectedPatches.size val totalCount: Int get() = allPatches.size 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 30726bf..edf093e 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 @@ -56,6 +56,7 @@ class PatchingViewModel( disabledPatches = config.disabledPatches, options = config.patchOptions, exclusiveMode = config.useExclusiveMode, + riplibs = config.riplibs, onProgress = { message -> parseAndAddLog(message) } diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 7fc5b87..535cbb5 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -76,6 +76,7 @@ class PatchService { disabledPatches: List = emptyList(), options: Map = emptyMap(), exclusiveMode: Boolean = false, + riplibs: List = emptyList(), onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { val tempDir = FileUtils.createPatchingTempDir() @@ -209,6 +210,11 @@ class PatchService { actualInputApk.copyTo(rebuiltApk, overwrite = true) patcherResult.applyTo(rebuiltApk) + if (riplibs.isNotEmpty()) { + onProgress("Stripping native libraries...") + ApkLibraryStripper.stripLibraries(rebuiltApk, riplibs) { onProgress(it) } + } + onProgress("Signing APK...") val keystorePath = File(tempDir, "morphe.keystore") ApkUtils.signApk( From ffa14f99f3b9d46a788210f884dd6db67b064030 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:48:28 +0530 Subject: [PATCH 18/23] Minor release and windows fixes We can now generate a single cross platform jar file. Fixed an issue where cache wasn't clearing on windows --- build.gradle.kts | 10 +++-- .../gui/data/repository/PatchRepository.kt | 19 +++++++-- .../gui/ui/components/SettingsDialog.kt | 39 ++++++++++++++---- .../app/morphe/gui/util/PatchService.kt | 40 ++++++++++++------- 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index bc728b6..19c4ce3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -90,9 +90,13 @@ dependencies { implementation(files(strippedApkEditorLib)) // -- Compose Desktop --------------------------------------------------- - // OS-specific: JAR only runs on the OS it was built on. - // Build once per target OS (macOS, Linux, Windows). - implementation(compose.desktop.currentOs) + // Platform-independent: single JAR runs on all supported OSes. + // Skiko auto-detects the OS at runtime and loads the correct native library. + implementation(compose.desktop.macos_arm64) + implementation(compose.desktop.macos_x64) + implementation(compose.desktop.linux_x64) + implementation(compose.desktop.linux_arm64) + implementation(compose.desktop.windows_x64) implementation(compose.components.resources) @Suppress("DEPRECATION") implementation(compose.material3) diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt index 7881939..b2209c3 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -168,9 +168,22 @@ class PatchRepository( */ fun clearCache(): Boolean { return try { - FileUtils.getPatchesDir().listFiles()?.forEach { it.delete() } - Logger.info("Patches cache cleared") - true + var failedCount = 0 + FileUtils.getPatchesDir().listFiles()?.forEach { file -> + try { + java.nio.file.Files.delete(file.toPath()) + } catch (e: Exception) { + failedCount++ + Logger.error("Failed to delete ${file.name}: ${e.message}") + } + } + if (failedCount > 0) { + Logger.error("Patches cache clear incomplete: $failedCount file(s) locked") + false + } else { + Logger.info("Patches cache cleared") + true + } } catch (e: Exception) { Logger.error("Failed to clear patches cache", e) false 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 6730662..99e6e55 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -40,6 +40,7 @@ fun SettingsDialog( ) { var showClearCacheConfirm by remember { mutableStateOf(false) } var cacheCleared by remember { mutableStateOf(false) } + var cacheClearFailed by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, @@ -222,8 +223,13 @@ fun SettingsDialog( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.outlinedButtonColors( - contentColor = if (cacheCleared) MorpheColors.Teal else MaterialTheme.colorScheme.error, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + contentColor = when { + cacheCleared -> MorpheColors.Teal + cacheClearFailed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.error + }, + disabledContentColor = if (cacheCleared) MorpheColors.Teal.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) ) { Icon( @@ -236,6 +242,7 @@ fun SettingsDialog( when { !allowCacheClear -> "Clear Cache (disabled during patching)" cacheCleared -> "Cache Cleared" + cacheClearFailed -> "Clear Cache Failed (files in use)" else -> "Clear Cache" } ) @@ -284,8 +291,9 @@ fun SettingsDialog( confirmButton = { Button( onClick = { - clearAllCache() - cacheCleared = true + val success = clearAllCache() + cacheCleared = success + cacheClearFailed = !success showClearCacheConfirm = false }, colors = ButtonDefaults.buttonColors( @@ -322,12 +330,27 @@ private fun calculateCacheSize(): String { } } -private fun clearAllCache() { - try { - FileUtils.getPatchesDir().listFiles()?.forEach { it.delete() } +private fun clearAllCache(): Boolean { + return try { + var failedCount = 0 + FileUtils.getPatchesDir().listFiles()?.forEach { file -> + try { + java.nio.file.Files.delete(file.toPath()) + } catch (e: Exception) { + failedCount++ + Logger.error("Failed to delete ${file.name}: ${e.message}") + } + } FileUtils.cleanupAllTempDirs() - Logger.info("Cache cleared successfully") + if (failedCount > 0) { + Logger.error("Cache clear incomplete: $failedCount file(s) could not be deleted (may be locked)") + false + } else { + Logger.info("Cache cleared successfully") + true + } } catch (e: Exception) { Logger.error("Failed to clear cache", e) + false } } diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 535cbb5..16478cc 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -42,23 +42,32 @@ class PatchService { } Logger.info("Loading patches from: $patchesFilePath") - val patches = loadPatchesFromJar(setOf(patchFile)) - // Convert library patches to GUI model - val guiPatches = patches.map { it.toGuiPatch() } + // Copy to temp file so URLClassLoader locks the copy, not the cached original. + // On Windows, the classloader holds the file locked and prevents deletion. + val tempCopy = File.createTempFile("morphe-patches-", ".mpp") + try { + patchFile.copyTo(tempCopy, overwrite = true) + val patches = loadPatchesFromJar(setOf(tempCopy)) + + // Convert library patches to GUI model + val guiPatches = patches.map { it.toGuiPatch() } - // Filter by package name if specified - val filtered = if (packageName != null) { - guiPatches.filter { patch -> - patch.compatiblePackages.isEmpty() || // Universal patches - patch.compatiblePackages.any { it.name == packageName } + // Filter by package name if specified + val filtered = if (packageName != null) { + guiPatches.filter { patch -> + patch.compatiblePackages.isEmpty() || // Universal patches + patch.compatiblePackages.any { it.name == packageName } + } + } else { + guiPatches } - } else { - guiPatches - } - Logger.info("Loaded ${filtered.size} patches" + (packageName?.let { " for $it" } ?: "")) - Result.success(filtered) + Logger.info("Loaded ${filtered.size} patches" + (packageName?.let { " for $it" } ?: "")) + Result.success(filtered) + } finally { + tempCopy.deleteOnExit() + } } catch (e: Exception) { Logger.error("Failed to load patches", e) Result.failure(e) @@ -95,7 +104,10 @@ class PatchService { } onProgress("Loading patches...") - val patches = loadPatchesFromJar(setOf(patchFile)) + // Copy to temp file so URLClassLoader locks the copy, not the cached original. + val patchTempCopy = File(tempDir, patchFile.name) + patchFile.copyTo(patchTempCopy, overwrite = true) + val patches = loadPatchesFromJar(setOf(patchTempCopy)) // Handle APKM format (split APK bundle) var mergedApkToCleanup: File? = null From 512f6f5859e676f7475e7fc7e870b5f9dba48072 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:57:30 +0530 Subject: [PATCH 19/23] Patching Engine Fix No more code duplication. The patching logic is present in a central engine that both the cli and the gui can call to make it run however they want. --- build.gradle.kts | 4 - gradle.properties | 1 - .../app/morphe/cli/command/PatchCommand.kt | 339 ++++++------------ .../util => engine}/ApkLibraryStripper.kt | 2 +- .../kotlin/app/morphe/engine/PatchEngine.kt | 322 +++++++++++++++++ .../kotlin/app/morphe/gui/data/model/Patch.kt | 2 +- .../patches/PatchSelectionViewModel.kt | 16 +- .../ui/screens/patching/PatchingViewModel.kt | 2 +- .../app/morphe/gui/util/PatchService.kt | 206 ++--------- 9 files changed, 468 insertions(+), 426 deletions(-) rename src/main/kotlin/app/morphe/{gui/util => engine}/ApkLibraryStripper.kt (99%) create mode 100644 src/main/kotlin/app/morphe/engine/PatchEngine.kt diff --git a/build.gradle.kts b/build.gradle.kts index 19c4ce3..fc79f08 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -159,10 +159,6 @@ tasks { "/prebuilt/windows/aapt.exe", "/prebuilt/*/aapt_*", ) - exclude("/prebuilt/linux/aapt") - exclude("/prebuilt/windows/aapt.exe") - exclude("/prebuilt/*/aapt_*") - minimize { exclude(dependency("org.bouncycastle:.*")) exclude(dependency("app.morphe:morphe-patcher")) diff --git a/gradle.properties b/gradle.properties index 9b4b907..fd63a01 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,3 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official version = 1.4.0-dev.2 -compose.resources.generated.internal = never diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 43be69e..7e1a424 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -1,21 +1,14 @@ package app.morphe.cli.command -import app.morphe.cli.command.model.FailedPatch import app.morphe.cli.command.model.PatchingResult import app.morphe.cli.command.model.PatchingStep +import app.morphe.cli.command.model.PatchingStepResult import app.morphe.cli.command.model.addStepResult import app.morphe.cli.command.model.toSerializablePatch -import app.morphe.gui.util.ApkLibraryStripper +import app.morphe.engine.PatchEngine import app.morphe.library.ApkUtils -import app.morphe.library.ApkUtils.applyTo import app.morphe.library.installation.installer.* -import app.morphe.library.setOptions -import app.morphe.patcher.Patcher -import app.morphe.patcher.PatcherConfig -import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar -import com.reandroid.apkeditor.merge.Merger -import com.reandroid.apkeditor.merge.MergerOptions import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -26,9 +19,8 @@ import picocli.CommandLine.Help.Visibility.ALWAYS import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Spec import java.io.File -import java.io.PrintWriter -import java.io.StringWriter import java.util.logging.Logger +import app.morphe.cli.command.model.FailedPatch as CliFailedPatch @OptIn(ExperimentalSerializationApi::class) @CommandLine.Command( @@ -265,8 +257,6 @@ internal object PatchCommand : Runnable { private var striplibs: List = emptyList() override fun run() { - // region Setup - val outputFilePath = outputFilePath ?: File("").absoluteFile.resolve( "${apk.nameWithoutExtension}-patched.apk", @@ -281,6 +271,7 @@ internal object PatchCommand : Runnable { keyStoreFilePath ?: outputFilePath.parentFile .resolve("${outputFilePath.nameWithoutExtension}.keystore") + // Set up ADB installer (CLI-only) val installer = if (deviceSerial != null) { val deviceSerial = deviceSerial!!.ifEmpty { null } @@ -309,164 +300,113 @@ internal object PatchCommand : Runnable { null } - // endregion - - // region Load patches - - logger.info("Loading patches") - - val patches = loadPatchesFromJar(patchesFiles) - - // endregion - - val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher") - - // Checking if the file is in apkm format (like reddit) - var mergedApkToCleanup: File? = null - val inputApk = if (apk.extension.equals("apkm", ignoreCase = true)) { - logger.info("Merging APKM bundle") + // Resolve --ei/--di indices to patch names by pre-loading patches + val patchesList = loadPatchesFromJar(patchesFiles).toList() - // Save merged APK to output directory (will be cleaned up after patching) - val outputApk = outputFilePath.parentFile.resolve("${apk.nameWithoutExtension}-merged.apk") - - // Use APKEditor's Merger directly (handles extraction and merging) - val mergerOptions = MergerOptions().apply { - inputFile = apk // Original APKM file - outputFile = outputApk - cleanMeta = true + val enabledPatchNames = selection.mapNotNull { sel -> + sel.enabled?.let { en -> + en.selector.name ?: patchesList.getOrNull(en.selector.index!!)?.name } - Merger(mergerOptions).run() + }.toSet() - mergedApkToCleanup = outputApk - outputApk - } else { - apk - } + val disabledPatchNames = selection.mapNotNull { sel -> + sel.disable?.let { dis -> + dis.selector.name ?: patchesList.getOrNull(dis.selector.index!!)?.name + } + }.filterNotNull().toSet() + + // Build options map: Map> + val patchOptions = selection.filter { it.enabled != null } + .associate { sel -> + val en = sel.enabled!! + val name = en.selector.name ?: patchesList[en.selector.index!!].name!! + name to en.options.toMap() + } + .filter { it.value.isNotEmpty() } + + val config = PatchEngine.Config( + inputApk = apk, + patches = patchesList.toSet(), + outputApk = outputFilePath, + enabledPatches = enabledPatchNames, + disabledPatches = disabledPatchNames, + exclusiveMode = exclusive, + forceCompatibility = force, + patchOptions = patchOptions, + unsigned = mount || unsigned, + signerName = signer, + keystoreDetails = ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + keyStoreEntryAlias, + keyStoreEntryPassword, + ), + architecturesToKeep = striplibs, + aaptBinaryPath = aaptBinaryPath, + tempDir = temporaryFilesPath, + ) val patchingResult = PatchingResult() try { - val (packageName, patcherResult) = Patcher( - PatcherConfig( - inputApk, - patcherTemporaryFilesPath, - aaptBinaryPath?.path, - patcherTemporaryFilesPath.absolutePath, - ), - ).use { patcher -> - val packageName = patcher.context.packageMetadata.packageName - val packageVersion = patcher.context.packageMetadata.packageVersion - - patchingResult.packageName = packageName - patchingResult.packageVersion = packageVersion - - val filteredPatches = patches.filterPatchSelection(packageName, packageVersion) - - logger.info("Setting patch options") - - val patchesList = patches.toList() - selection.filter { it.enabled != null }.associate { - val enabledSelection = it.enabled!! - - (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to - enabledSelection.options - }.let(filteredPatches::setOptions) - - patcher += filteredPatches - - // Execute patches. - patchingResult.addStepResult( - PatchingStep.PATCHING, - { - runBlocking { - patcher().collect { patchResult -> - patchResult.exception?.let { exception -> - StringWriter().use { writer -> - exception.printStackTrace(PrintWriter(writer)) - - logger.severe("\"${patchResult.patch}\" failed:\n$writer") - - patchingResult.failedPatches.add( - FailedPatch( - patchResult.patch.toSerializablePatch(), - writer.toString() - ) - ) - patchingResult.success = false - } - } ?: patchResult.patch.let { - patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) - logger.info("\"${patchResult.patch}\" succeeded") - } - } - } - } - ) - - patcher.context.packageMetadata.packageName to patcher.get() + val engineResult = runBlocking { + PatchEngine.patch(config) { msg -> logger.info(msg) } } - // region Save. + patchingResult.packageName = engineResult.packageName + patchingResult.packageVersion = engineResult.packageVersion + patchingResult.success = engineResult.success + + // Map engine step results to CLI model for --result-file + engineResult.stepResults.forEach { step -> + val cliStep = when (step.step) { + PatchEngine.PatchStep.PATCHING -> PatchingStep.PATCHING + PatchEngine.PatchStep.REBUILDING -> PatchingStep.REBUILDING + PatchEngine.PatchStep.STRIPPING_LIBS -> PatchingStep.STRIPPING_LIBS + PatchEngine.PatchStep.SIGNING -> PatchingStep.SIGNING + } + patchingResult.patchingSteps.add(PatchingStepResult(cliStep, step.success, step.error)) + } - inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { - patchingResult.addStepResult( - PatchingStep.REBUILDING, - { - patcherResult.applyTo(this) - } - ) - }.let { patchedApkFile -> - if (!mount && !unsigned) { - patchingResult.addStepResult( - PatchingStep.SIGNING, - { - ApkUtils.signApk( - patchedApkFile, - outputFilePath, - signer, - ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - keyStoreEntryAlias, - keyStoreEntryPassword, - ), - ) - } - ) - } else { - patchedApkFile.copyTo(outputFilePath, overwrite = true) + engineResult.appliedPatches.forEach { name -> + patchesList.find { it.name == name }?.let { + patchingResult.appliedPatches.add(it.toSerializablePatch()) + } + } + engineResult.failedPatches.forEach { failed -> + patchesList.find { it.name == failed.name }?.let { + patchingResult.failedPatches.add(CliFailedPatch(it.toSerializablePatch(), failed.error)) } } logger.info("Saved to $outputFilePath") - // endregion - - // region Install. - - deviceSerial?.let { - patchingResult.addStepResult( - PatchingStep.INSTALLING, - { - runBlocking { - val result = installer!!.install(Installer.Apk(outputFilePath, packageName)) - when (result) { - RootInstallerResult.FAILURE -> { - logger.severe("Failed to mount the patched APK file") - throw IllegalStateException("Failed to mount the patched APK file") - } - is AdbInstallerResult.Failure -> { - logger.severe(result.exception.toString()) - throw result.exception + // ADB install (CLI-only) + if (engineResult.success) { + deviceSerial?.let { + patchingResult.addStepResult( + PatchingStep.INSTALLING, + { + runBlocking { + val result = installer!!.install( + Installer.Apk(outputFilePath, engineResult.packageName), + ) + when (result) { + RootInstallerResult.FAILURE -> { + logger.severe("Failed to mount the patched APK file") + throw IllegalStateException("Failed to mount the patched APK file") + } + is AdbInstallerResult.Failure -> { + logger.severe(result.exception.toString()) + throw result.exception + } + else -> logger.info("Installed the patched APK file") } - else -> logger.info("Installed the patched APK file") } - } - } - ) + }, + ) + } } - - // endregion } finally { patchingResultOutputFilePath?.let { outputFile -> outputFile.outputStream().use { outputStream -> @@ -478,92 +418,13 @@ internal object PatchCommand : Runnable { if (purge) { logger.info("Purging temporary files") - purge(temporaryFilesPath) - } - - // Clean up merged APK if we created one from APKM - mergedApkToCleanup?.let { - if (!it.delete()) { - logger.warning("Could not clean up merged APK: ${it.path}") - } - } - } - - /** - * Filter the patches based on the selection. - * - * @param packageName The package name of the APK file to be patched. - * @param packageVersion The version of the APK file to be patched. - * @return The filtered patches. - */ - private fun Set>.filterPatchSelection( - packageName: String, - packageVersion: String, - ): Set> = buildSet { - val enabledPatchesByName = - selection.mapNotNull { it.enabled?.selector?.name }.toSet() - val enabledPatchesByIndex = - selection.mapNotNull { it.enabled?.selector?.index }.toSet() - - val disabledPatches = - selection.mapNotNull { it.disable?.selector?.name }.toSet() - val disabledPatchesByIndex = - selection.mapNotNull { it.disable?.selector?.index }.toSet() - - this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) -> - val patchName = patch.name!! - - val isManuallyDisabled = patchName in disabledPatches || i in disabledPatchesByIndex - if (isManuallyDisabled) return@patchLoop logger.info("\"$patchName\" disabled manually") - - // Make sure the patch is compatible with the supplied APK files package name and version. - patch.compatiblePackages?.let { packages -> - packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) -> - if (versions?.isEmpty() == true) { - return@patchLoop logger.warning("\"$patchName\" incompatible with \"$packageName\"") - } - - val matchesVersion = - force || versions?.let { it.any { version -> version == packageVersion } } ?: true - - if (!matchesVersion) { - return@patchLoop logger.warning( - "\"$patchName\" incompatible with $packageName $packageVersion " + - "but compatible with " + - packages.joinToString("; ") { (packageName, versions) -> - packageName + " " + versions!!.joinToString(", ") - }, - ) - } - } ?: return@patchLoop logger.fine( - "\"$patchName\" incompatible with $packageName. " + - "It is only compatible with " + - packages.joinToString(", ") { (name, _) -> name }, - ) - - return@let - } ?: logger.fine("\"$patchName\" has no package constraints") - - val isEnabled = !exclusive && patch.use - val isManuallyEnabled = patchName in enabledPatchesByName || i in enabledPatchesByIndex - - if (!(isEnabled || isManuallyEnabled)) { - return@patchLoop logger.info("\"$patchName\" disabled") - } - - add(patch) - - logger.fine("\"$patchName\" added") + val result = + if (temporaryFilesPath.deleteRecursively()) { + "Purged resource cache directory" + } else { + "Failed to purge resource cache directory" + } + logger.info(result) } } - - private fun purge(resourceCachePath: File) { - val result = - if (resourceCachePath.deleteRecursively()) { - "Purged resource cache directory" - } else { - "Failed to purge resource cache directory" - } - logger.info(result) - } } diff --git a/src/main/kotlin/app/morphe/gui/util/ApkLibraryStripper.kt b/src/main/kotlin/app/morphe/engine/ApkLibraryStripper.kt similarity index 99% rename from src/main/kotlin/app/morphe/gui/util/ApkLibraryStripper.kt rename to src/main/kotlin/app/morphe/engine/ApkLibraryStripper.kt index 53dfbf4..f2a30d1 100644 --- a/src/main/kotlin/app/morphe/gui/util/ApkLibraryStripper.kt +++ b/src/main/kotlin/app/morphe/engine/ApkLibraryStripper.kt @@ -1,4 +1,4 @@ -package app.morphe.gui.util +package app.morphe.engine import java.io.File import java.util.logging.Logger diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt new file mode 100644 index 0000000..5a319a8 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -0,0 +1,322 @@ +package app.morphe.engine + +import app.morphe.library.ApkUtils +import app.morphe.library.ApkUtils.applyTo +import app.morphe.library.setOptions +import app.morphe.patcher.Patcher +import app.morphe.patcher.PatcherConfig +import app.morphe.patcher.patch.Patch +import com.reandroid.apkeditor.merge.Merger +import com.reandroid.apkeditor.merge.MergerOptions +import kotlinx.coroutines.ensureActive +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.nio.file.Files +import kotlin.coroutines.coroutineContext + +/** + * Single patching pipeline shared by CLI and GUI. + */ +object PatchEngine { + + enum class PatchStep { + PATCHING, REBUILDING, STRIPPING_LIBS, SIGNING + } + + data class StepResult(val step: PatchStep, val success: Boolean, val error: String? = null) + + data class Config( + val inputApk: File, + val patches: Set>, + val outputApk: File, + val enabledPatches: Set = emptySet(), + val disabledPatches: Set = emptySet(), + val exclusiveMode: Boolean = false, + val forceCompatibility: Boolean = false, + val patchOptions: Map> = emptyMap(), + val unsigned: Boolean = false, + val signerName: String = "Morphe", + val keystoreDetails: ApkUtils.KeyStoreDetails? = null, + val architecturesToKeep: List = emptyList(), + val aaptBinaryPath: File? = null, + val tempDir: File? = null, + val failOnError: Boolean = true, + ) + + data class Result( + val success: Boolean, + val outputPath: String, + val packageName: String, + val packageVersion: String, + val appliedPatches: List, + val failedPatches: List, + val stepResults: List, + ) + + data class FailedPatch(val name: String, val error: String) + + /** + * The single patching pipeline. + * CLI wraps with runBlocking, GUI calls from coroutine scope. + * + * Always returns a [Result] — does not throw for pipeline step failures. + * Only throws for init errors (e.g. Patcher can't open the APK). + */ + suspend fun patch(config: Config, onProgress: (String) -> Unit = {}): Result { + val tempDir = config.tempDir ?: Files.createTempDirectory("morphe-patching").toFile() + var mergedApkToCleanup: File? = null + val stepResults = mutableListOf() + val appliedPatches = mutableListOf() + val failedPatches = mutableListOf() + + try { + // 1. Handle APKM format (split APK bundle) + val actualInputApk = if (config.inputApk.extension.equals("apkm", ignoreCase = true)) { + onProgress("Converting APKM to APK...") + val mergedApk = File(tempDir, "${config.inputApk.nameWithoutExtension}-merged.apk") + val mergerOptions = MergerOptions().apply { + inputFile = config.inputApk + outputFile = mergedApk + cleanMeta = true + } + Merger(mergerOptions).run() + mergedApkToCleanup = mergedApk + mergedApk + } else { + config.inputApk + } + + coroutineContext.ensureActive() + + // 2. Initialize patcher + val patcherTempDir = File(tempDir, "patcher") + patcherTempDir.mkdirs() + + onProgress("Initializing patcher...") + val patcherConfig = PatcherConfig( + actualInputApk, + patcherTempDir, + config.aaptBinaryPath?.path, + patcherTempDir.absolutePath, + ) + + Patcher(patcherConfig).use { patcher -> + val packageName = patcher.context.packageMetadata.packageName + val packageVersion = patcher.context.packageMetadata.packageVersion + + coroutineContext.ensureActive() + + // 3. Filter patches + onProgress("Filtering patches for $packageName v$packageVersion...") + val filteredPatches = filterPatches( + patches = config.patches, + packageName = packageName, + packageVersion = packageVersion, + enabledPatches = config.enabledPatches, + disabledPatches = config.disabledPatches, + exclusiveMode = config.exclusiveMode, + forceCompatibility = config.forceCompatibility, + onProgress = onProgress, + ) + + coroutineContext.ensureActive() + + // 4. Set options + if (config.patchOptions.isNotEmpty()) { + val relevantOptions = config.patchOptions.filter { it.value.isNotEmpty() } + if (relevantOptions.isNotEmpty()) { + filteredPatches.setOptions(relevantOptions) + } + } + + patcher += filteredPatches + + coroutineContext.ensureActive() + + fun earlyResult() = Result( + success = false, + outputPath = config.outputApk.absolutePath, + packageName = packageName, + packageVersion = packageVersion, + appliedPatches = appliedPatches, + failedPatches = failedPatches, + stepResults = stepResults, + ) + + // 5. Execute patches + onProgress("Applying ${filteredPatches.size} patches...") + try { + patcher().collect { patchResult -> + val patchName = patchResult.patch.name ?: "Unknown" + patchResult.exception?.let { exception -> + val error = StringWriter().use { writer -> + exception.printStackTrace(PrintWriter(writer)) + writer.toString() + } + onProgress("FAILED: $patchName") + failedPatches.add(FailedPatch(patchName, error)) + + if (config.failOnError) { + throw PatchFailedException( + "Patch \"$patchName\" failed: ${exception.message}", + exception, + ) + } + } ?: run { + onProgress("Applied: $patchName") + appliedPatches.add(patchName) + } + } + stepResults.add(StepResult(PatchStep.PATCHING, failedPatches.isEmpty())) + } catch (e: PatchFailedException) { + stepResults.add(StepResult(PatchStep.PATCHING, false, e.message)) + return earlyResult() + } + + coroutineContext.ensureActive() + + // 6. Rebuild APK + onProgress("Rebuilding APK...") + try { + val patcherResult = patcher.get() + val rebuiltApk = File(tempDir, "rebuilt.apk") + actualInputApk.copyTo(rebuiltApk, overwrite = true) + patcherResult.applyTo(rebuiltApk) + stepResults.add(StepResult(PatchStep.REBUILDING, true)) + } catch (e: Exception) { + stepResults.add(StepResult(PatchStep.REBUILDING, false, e.toString())) + return earlyResult() + } + + val rebuiltApk = File(tempDir, "rebuilt.apk") + + coroutineContext.ensureActive() + + // 7. Strip libs (if configured) + if (config.architecturesToKeep.isNotEmpty()) { + onProgress("Stripping native libraries...") + try { + ApkLibraryStripper.stripLibraries(rebuiltApk, config.architecturesToKeep) { + onProgress(it) + } + stepResults.add(StepResult(PatchStep.STRIPPING_LIBS, true)) + } catch (e: Exception) { + stepResults.add(StepResult(PatchStep.STRIPPING_LIBS, false, e.toString())) + return earlyResult() + } + } + + coroutineContext.ensureActive() + + // 8. Sign APK (unless unsigned) + val tempOutput = File(tempDir, config.outputApk.name) + if (!config.unsigned) { + onProgress("Signing APK...") + try { + val keystoreDetails = config.keystoreDetails ?: ApkUtils.KeyStoreDetails( + File(tempDir, "morphe.keystore"), + null, + "Morphe Key", + "", + ) + ApkUtils.signApk( + rebuiltApk, + tempOutput, + config.signerName, + keystoreDetails, + ) + stepResults.add(StepResult(PatchStep.SIGNING, true)) + } catch (e: Exception) { + stepResults.add(StepResult(PatchStep.SIGNING, false, e.toString())) + return earlyResult() + } + } else { + rebuiltApk.copyTo(tempOutput, overwrite = true) + } + + // 9. Copy to final output + config.outputApk.parentFile?.mkdirs() + tempOutput.copyTo(config.outputApk, overwrite = true) + + onProgress("Patching complete!") + + return Result( + success = failedPatches.isEmpty(), + outputPath = config.outputApk.absolutePath, + packageName = packageName, + packageVersion = packageVersion, + appliedPatches = appliedPatches, + failedPatches = failedPatches, + stepResults = stepResults, + ) + } + } finally { + mergedApkToCleanup?.delete() + if (config.tempDir == null) { + try { + tempDir.deleteRecursively() + } catch (_: Exception) { + // Best effort cleanup + } + } + } + } + + /** + * Unified patch filtering logic. + * Filters patches based on compatibility, enabled/disabled lists, and exclusive mode. + */ + private fun filterPatches( + patches: Set>, + packageName: String, + packageVersion: String, + enabledPatches: Set, + disabledPatches: Set, + exclusiveMode: Boolean, + forceCompatibility: Boolean, + onProgress: (String) -> Unit, + ): Set> = buildSet { + patches.forEach patchLoop@{ patch -> + val patchName = patch.name ?: return@patchLoop + + // Check if explicitly disabled + if (patchName in disabledPatches) { + onProgress("Skipping disabled: $patchName") + return@patchLoop + } + + // Check package compatibility + patch.compatiblePackages?.let { packages -> + val matchingPkg = packages.singleOrNull { (name, _) -> name == packageName } + if (matchingPkg == null) { + return@patchLoop + } + + val (_, versions) = matchingPkg + if (versions?.isEmpty() == true) { + return@patchLoop + } + + val matchesVersion = forceCompatibility || + versions?.any { it == packageVersion } ?: true + + if (!matchesVersion) { + onProgress("Skipping \"$patchName\": incompatible with $packageName $packageVersion") + return@patchLoop + } + } + + val isManuallyEnabled = patchName in enabledPatches + val isEnabledByDefault = !exclusiveMode && patch.use + + if (!(isEnabledByDefault || isManuallyEnabled)) { + return@patchLoop + } + + add(patch) + } + } + + private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) +} diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt index e3c11e2..2f940a2 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -80,5 +80,5 @@ data class PatchConfig( val disabledPatches: List = emptyList(), val patchOptions: Map = emptyMap(), val useExclusiveMode: Boolean = false, - val riplibs: List = emptyList() + val striplibs: 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 3a86325..278691b 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 @@ -192,7 +192,7 @@ class PatchSelectionViewModel( .map { it.name } // Only set riplibs if user deselected any architecture (keeps = selected ones) - val riplibs = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { + val striplibs = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { _uiState.value.selectedArchitectures.toList() } else { emptyList() @@ -205,7 +205,7 @@ class PatchSelectionViewModel( enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, useExclusiveMode = true, - riplibs = riplibs + striplibs = striplibs ) } @@ -236,8 +236,8 @@ class PatchSelectionViewModel( .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } .map { it.name } - // riplibs flag: only when user deselected at least one architecture - val riplibsArg = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { + // 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 @@ -250,8 +250,8 @@ class PatchSelectionViewModel( sb.append(" -o ${outputFileName} \\\n") sb.append(" --exclusive \\\n") - if (riplibsArg != null) { - sb.append(" --riplibs $riplibsArg \\\n") + if (striplibsArg != null) { + sb.append(" --striplibs $striplibsArg \\\n") } selectedPatchNames.forEachIndexed { index, patch -> @@ -268,8 +268,8 @@ class PatchSelectionViewModel( } else { // Compact mode - single line that wraps naturally val patches = selectedPatchNames.joinToString(" ") { "-e \"$it\"" } - val riplibsPart = if (riplibsArg != null) " --riplibs $riplibsArg" else "" - "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --exclusive$riplibsPart $patches ${inputFile.name}" + val striplibsPart = if (striplibsArg != null) " --striplibs $striplibsArg" else "" + "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --exclusive$striplibsPart $patches ${inputFile.name}" } } 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 edf093e..e5e8326 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 @@ -56,7 +56,7 @@ class PatchingViewModel( disabledPatches = config.disabledPatches, options = config.patchOptions, exclusiveMode = config.useExclusiveMode, - riplibs = config.riplibs, + striplibs = config.striplibs, onProgress = { message -> parseAndAddLog(message) } diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 16478cc..2902104 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -1,23 +1,14 @@ package app.morphe.gui.util +import app.morphe.engine.PatchEngine import app.morphe.gui.data.model.CompatiblePackage import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchOption import app.morphe.gui.data.model.PatchOptionType -import app.morphe.library.ApkUtils -import app.morphe.library.ApkUtils.applyTo -import app.morphe.library.setOptions -import app.morphe.patcher.Patcher -import app.morphe.patcher.PatcherConfig import app.morphe.patcher.patch.loadPatchesFromJar -import com.reandroid.apkeditor.merge.Merger -import com.reandroid.apkeditor.merge.MergerOptions import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.File -import java.io.PrintWriter -import java.io.StringWriter import kotlin.reflect.KType import app.morphe.patcher.patch.Patch as LibraryPatch @@ -76,6 +67,7 @@ class PatchService { /** * Execute patching operation with progress callbacks. + * Delegates to PatchEngine for the actual pipeline. */ suspend fun patch( patchesFilePath: String, @@ -85,12 +77,9 @@ class PatchService { disabledPatches: List = emptyList(), options: Map = emptyMap(), exclusiveMode: Boolean = false, - riplibs: List = emptyList(), + striplibs: List = emptyList(), onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { - val tempDir = FileUtils.createPatchingTempDir() - val tempOutputPath = File(tempDir, File(outputApkPath).name) - try { val patchFile = File(patchesFilePath) val inputApk = File(inputApkPath) @@ -103,171 +92,46 @@ class PatchService { return@withContext Result.failure(Exception("Input APK not found")) } + // Load patches (copy to temp to avoid Windows file lock) onProgress("Loading patches...") - // Copy to temp file so URLClassLoader locks the copy, not the cached original. - val patchTempCopy = File(tempDir, patchFile.name) - patchFile.copyTo(patchTempCopy, overwrite = true) - val patches = loadPatchesFromJar(setOf(patchTempCopy)) - - // Handle APKM format (split APK bundle) - var mergedApkToCleanup: File? = null - val actualInputApk = if (inputApk.extension.equals("apkm", ignoreCase = true)) { - onProgress("Converting APKM to APK...") - val mergedApk = File(tempDir, "${inputApk.nameWithoutExtension}-merged.apk") - val mergerOptions = MergerOptions().apply { - this.inputFile = inputApk - this.outputFile = mergedApk - cleanMeta = true - } - Merger(mergerOptions).run() - mergedApkToCleanup = mergedApk - mergedApk - } else { - inputApk - } - - val patcherTempDir = File(tempDir, "patcher") - patcherTempDir.mkdirs() - - onProgress("Initializing patcher...") - val patcherConfig = PatcherConfig( - actualInputApk, - patcherTempDir, - null, // aapt binary path - patcherTempDir.absolutePath - ) - - val appliedPatches = mutableListOf() - val failedPatches = mutableListOf>() - - Patcher(patcherConfig).use { patcher -> - val packageName = patcher.context.packageMetadata.packageName - val packageVersion = patcher.context.packageMetadata.packageVersion - - onProgress("Filtering patches for $packageName v$packageVersion...") - - // Filter patches based on compatibility and selection - val filteredPatches = patches.filter { patch -> - val patchName = patch.name ?: return@filter false - - // Check if explicitly disabled - if (patchName in disabledPatches) { - onProgress("Skipping disabled: $patchName") - return@filter false - } - - // Check package compatibility - val isCompatible = patch.compatiblePackages?.let { packages -> - packages.any { (name, versions) -> - name == packageName && (versions?.isEmpty() != false || versions.contains(packageVersion)) - } - } ?: true // Universal patches - - if (!isCompatible) { - return@filter false - } - - // In exclusive mode, only include explicitly enabled patches - if (exclusiveMode) { - patchName in enabledPatches - } else { - // Include if: enabled by default OR explicitly enabled - patch.use || patchName in enabledPatches - } - }.toSet() - - onProgress("Applying ${filteredPatches.size} patches...") - - // Set patch options if any - if (options.isNotEmpty()) { - val optionsMap = enabledPatches.associateWith { patchName -> - options.filterKeys { it.startsWith("$patchName.") } - .mapKeys { it.key.removePrefix("$patchName.") } - .mapValues { it.value as Any? } - .toMutableMap() - }.filter { it.value.isNotEmpty() } - - if (optionsMap.isNotEmpty()) { - filteredPatches.setOptions(optionsMap) - } - } - - patcher += filteredPatches - - // Execute patches - runBlocking { - patcher().collect { patchResult -> - val patchName = patchResult.patch.name ?: "Unknown" - patchResult.exception?.let { exception -> - val error = StringWriter().use { writer -> - exception.printStackTrace(PrintWriter(writer)) - writer.toString() - } - onProgress("FAILED: $patchName") - Logger.error("Patch failed: $patchName\n$error") - failedPatches.add(patchName to error) - } ?: run { - onProgress("Applied: $patchName") - Logger.info("Patch applied: $patchName") - appliedPatches.add(patchName) - } - } - } - - // Get patcher result - val patcherResult = patcher.get() - - onProgress("Rebuilding APK...") - val rebuiltApk = File(tempDir, "rebuilt.apk") - actualInputApk.copyTo(rebuiltApk, overwrite = true) - patcherResult.applyTo(rebuiltApk) - - if (riplibs.isNotEmpty()) { - onProgress("Stripping native libraries...") - ApkLibraryStripper.stripLibraries(rebuiltApk, riplibs) { onProgress(it) } - } - - onProgress("Signing APK...") - val keystorePath = File(tempDir, "morphe.keystore") - ApkUtils.signApk( - rebuiltApk, - tempOutputPath, - "Morphe", - ApkUtils.KeyStoreDetails( - keystorePath, - null, // password - "Morphe Key", - "" // entry password - ) + val patchTempCopy = File.createTempFile("morphe-patches-", ".mpp") + try { + patchFile.copyTo(patchTempCopy, overwrite = true) + val loadedPatches = loadPatchesFromJar(setOf(patchTempCopy)) + + // Convert GUI's flat "patchName.optionKey" -> value map + // to engine's Map> format + val patchOptions = enabledPatches.associateWith { patchName -> + options.filterKeys { it.startsWith("$patchName.") } + .mapKeys { it.key.removePrefix("$patchName.") } + .mapValues { it.value as Any? } + }.filter { it.value.isNotEmpty() } + + val config = PatchEngine.Config( + inputApk = inputApk, + patches = loadedPatches, + outputApk = outputFile, + enabledPatches = enabledPatches.toSet(), + disabledPatches = disabledPatches.toSet(), + exclusiveMode = exclusiveMode, + patchOptions = patchOptions, + architecturesToKeep = striplibs, ) - // Move to final location - outputFile.parentFile?.mkdirs() - tempOutputPath.copyTo(outputFile, overwrite = true) - - onProgress("Patching complete!") - Logger.info("Patched APK saved to: ${outputFile.absolutePath}") + val engineResult = PatchEngine.patch(config, onProgress) - // Cleanup merged APK if created - mergedApkToCleanup?.delete() + Result.success(PatchResult( + success = engineResult.success, + outputPath = engineResult.outputPath, + appliedPatches = engineResult.appliedPatches, + failedPatches = engineResult.failedPatches.map { it.name }, + )) + } finally { + patchTempCopy.delete() } - - Result.success(PatchResult( - success = failedPatches.isEmpty(), - outputPath = outputFile.absolutePath, - appliedPatches = appliedPatches, - failedPatches = failedPatches.map { it.first } - )) } catch (e: Exception) { Logger.error("Patching failed", e) Result.failure(e) - } finally { - // Cleanup temp directory - try { - tempDir.deleteRecursively() - } catch (e: Exception) { - Logger.warn("Failed to cleanup temp directory: ${e.message}") - } } } From bf224f64b1be57a9b7d0c20448ff8be036092de3 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:36:20 +0530 Subject: [PATCH 20/23] Minor Fixes Minor fixes for various stuff --- src/main/kotlin/app/morphe/gui/App.kt | 9 +- .../morphe/gui/data/constants/AppConstants.kt | 118 +--------------- .../gui/data/repository/PatchRepository.kt | 30 ++++- .../kotlin/app/morphe/gui/di/AppModule.kt | 2 +- .../gui/ui/components/SettingsDialog.kt | 25 +++- .../morphe/gui/ui/screens/home/HomeScreen.kt | 127 +----------------- .../gui/ui/screens/home/HomeViewModel.kt | 74 ++-------- .../ui/screens/home/components/ApkInfoCard.kt | 36 +---- .../screens/patches/PatchSelectionScreen.kt | 3 +- .../patches/PatchSelectionViewModel.kt | 60 +++++---- .../gui/ui/screens/quick/QuickPatchScreen.kt | 19 +-- .../ui/screens/quick/QuickPatchViewModel.kt | 38 ++---- 12 files changed, 134 insertions(+), 407 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index 5bd715d..8ff6286 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -111,12 +111,15 @@ private fun AppContent(initialSimplifiedMode: Boolean) { ) { Surface(modifier = Modifier.fillMaxSize()) { if (!isLoading) { + // Create QuickPatchViewModel outside Crossfade so it persists across mode switches. + // Otherwise every expert→simplified switch creates a new VM that re-fetches from GitHub. + val quickViewModel = remember { + QuickPatchViewModel(patchRepository, patchService, configRepository) + } + Crossfade(targetState = isSimplifiedMode) { simplified -> if (simplified) { // Quick/Simplified mode - val quickViewModel = remember { - QuickPatchViewModel(patchRepository, patchService, configRepository) - } QuickPatchContent(quickViewModel) } else { // Full mode diff --git a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt index 3ca5a23..ba11011 100644 --- a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -2,7 +2,7 @@ package app.morphe.gui.data.constants /** * Centralized configuration for supported apps. - * Update version, URL, and checksum here - changes will reflect throughout the app. + * This file is massively outdated. Could be used for other things in the future but kinda useless now. */ object AppConstants { @@ -17,24 +17,12 @@ object AppConstants { object YouTube { const val DISPLAY_NAME = "YouTube" const val PACKAGE_NAME = "com.google.android.youtube" - const val SUGGESTED_VERSION = "20.40.45" - - // SHA-256 checksum from APKMirror (leave null if not verified) - // You can find this on the APKMirror download page under "File SHA-256" - val SHA256_CHECKSUM: String? = "b7659da492a1ebd8bd7cea2909be4ee1f58e00a2586d65a1c91b2e1e5ec6acd1" } // ==================== YOUTUBE MUSIC ==================== object YouTubeMusic { const val DISPLAY_NAME = "YouTube Music" const val PACKAGE_NAME = "com.google.android.apps.youtube.music" - const val SUGGESTED_VERSION = "8.40.54" - val SHA256_CHECKSUMS: Map = mapOf( - "arm64-v8a" to "d5b44919a5cd5648b01e392115fe68b9569b1c7847f3cdf65b1ace1302d005d2", - "armeabi-v7a" to "6f5181e8aaa2595af6c421b86ffffcc1c7a4e97968d7be89d04b46776392eaec", - "x86" to "03b1eb6993d43b1de6a9416828df7864be975ca6dd3a82468c431e3c193f3a80", - "x86_64" to "eab4cd51220b28c7108343cdb95a063251029f9a137d052a519d007a9321c848" - ) } // ==================== REDDIT ==================== @@ -52,106 +40,6 @@ object AppConstants { Reddit.PACKAGE_NAME ) - /** - * Get suggested version for a package name. - */ - fun getSuggestedVersion(packageName: String): String? { - return when (packageName) { - YouTube.PACKAGE_NAME -> YouTube.SUGGESTED_VERSION - YouTubeMusic.PACKAGE_NAME -> YouTubeMusic.SUGGESTED_VERSION - else -> null - } - } - - /** - * Get checksum for a package name, version, and architecture. - * @param packageName The app's package name - * @param version The app version - * @param architectures List of architectures in the APK (from lib/ folder) - * @return The expected checksum, or null if not configured/version mismatch - */ - fun getChecksum(packageName: String, version: String, architectures: List = emptyList()): String? { - return when (packageName) { - YouTube.PACKAGE_NAME -> { - // YouTube has a universal APK with single checksum - if (version == YouTube.SUGGESTED_VERSION) YouTube.SHA256_CHECKSUM else null - } - YouTubeMusic.PACKAGE_NAME -> { - if (version != YouTubeMusic.SUGGESTED_VERSION) return null - if (YouTubeMusic.SHA256_CHECKSUMS.isEmpty()) return null - - // Try to find matching architecture checksum - // Check for universal first, then specific architectures - YouTubeMusic.SHA256_CHECKSUMS["universal"] - ?: architectures.firstNotNullOfOrNull { arch -> - YouTubeMusic.SHA256_CHECKSUMS[arch] - } - } - else -> null - } - } - - /** - * Check if we have any checksum configured for this package/version/architecture combo. - */ - fun hasChecksumConfigured(packageName: String, version: String, architectures: List = emptyList()): Boolean { - return getChecksum(packageName, version, architectures) != null - } - - /** - * Check if this is the recommended version. - */ - fun isRecommendedVersion(packageName: String, version: String): Boolean { - return getSuggestedVersion(packageName) == version - } - - // ==================== PATCH RECOMMENDATIONS ==================== - - /** - * Patches that are commonly disabled by users. - * These patches change default behavior in ways that some users may not want. - * The key is a partial match (case-insensitive) against patch names. - */ - object PatchRecommendations { - /** - * Patches commonly disabled for YouTube. - * Pair of (patch name pattern, reason for commonly disabling) - */ - val YOUTUBE_COMMONLY_DISABLED: List> = listOf( - "Custom Branding" to "Keeps the original name and logo for the app", -// "Hide ads" to "Some users prefer keeping ads to support creators", -// "Premium heading" to "Changes the YouTube logo/heading appearance", -// "Navigation buttons" to "Modifies bottom navigation bar layout", -// "Spoof client" to "May cause playback issues on some devices", -// "Disable auto captions" to "Some users rely on auto-generated captions" - ) - - /** - * Patches commonly disabled for YouTube Music. - */ - val YOUTUBE_MUSIC_COMMONLY_DISABLED: List> = listOf( - "Custom Branding" to "Keeps the original name and logo for the app", -// "Spoof client" to "May cause playback issues on some devices" - ) - - /** - * Patches commonly disabled for Reddit. - */ - val REDDIT_COMMONLY_DISABLED: List> = listOf( - "Change package name" to "Doesn't work for reddit", - "Spoof signature" to "May cause issues on some devices" - ) - - /** - * Get commonly disabled patches for a package. - */ - fun getCommonlyDisabled(packageName: String): List> { - return when (packageName) { - YouTube.PACKAGE_NAME -> YOUTUBE_COMMONLY_DISABLED - YouTubeMusic.PACKAGE_NAME -> YOUTUBE_MUSIC_COMMONLY_DISABLED - Reddit.PACKAGE_NAME -> REDDIT_COMMONLY_DISABLED - else -> emptyList() - } - } - } + // TODO: Checksum verification will be re-enabled when checksums are added to .mpp files + // For now, checksums are not validated. See ChecksumUtils.kt for the verification logic. } diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt index b2209c3..c73baca 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -23,12 +23,25 @@ class PatchRepository( private const val GITHUB_API_BASE = "https://api.github.com" private const val PATCHES_REPO = "MorpheApp/morphe-patches" private const val RELEASES_ENDPOINT = "$GITHUB_API_BASE/repos/$PATCHES_REPO/releases" + private const val CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes } + // In-memory cache so multiple callers (both modes) don't re-fetch from GitHub + private var cachedReleases: List? = null + private var cacheTimestamp: Long = 0L + /** - * Fetch all releases from GitHub. + * Fetch all releases from GitHub. Returns cached result if still fresh. + * @param forceRefresh bypass the in-memory cache */ - suspend fun fetchReleases(): Result> = withContext(Dispatchers.IO) { + suspend fun fetchReleases(forceRefresh: Boolean = false): Result> = withContext(Dispatchers.IO) { + // Return cached releases if still fresh + val cached = cachedReleases + if (!forceRefresh && cached != null && (System.currentTimeMillis() - cacheTimestamp) < CACHE_TTL_MS) { + Logger.info("Using cached releases (${cached.size} releases, age=${(System.currentTimeMillis() - cacheTimestamp) / 1000}s)") + return@withContext Result.success(cached) + } + try { Logger.info("Fetching releases from $RELEASES_ENDPOINT") val response: HttpResponse = httpClient.get(RELEASES_ENDPOINT) { @@ -41,6 +54,8 @@ class PatchRepository( if (response.status.isSuccess()) { val releases: List = response.body() Logger.info("Fetched ${releases.size} releases") + cachedReleases = releases + cacheTimestamp = System.currentTimeMillis() Result.success(releases) } else { val error = "Failed to fetch releases: ${response.status}" @@ -49,7 +64,14 @@ class PatchRepository( } } catch (e: Exception) { Logger.error("Error fetching releases", e) - Result.failure(e) + // If we have stale cached data, return it rather than failing + val stale = cachedReleases + if (stale != null) { + Logger.info("Returning stale cached releases after fetch failure") + Result.success(stale) + } else { + Result.failure(e) + } } } @@ -167,6 +189,8 @@ class PatchRepository( * Delete cached patches. */ fun clearCache(): Boolean { + cachedReleases = null + cacheTimestamp = 0L return try { var failedCount = 0 FileUtils.getPatchesDir().listFiles()?.forEach { file -> diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index b7a31d0..87c7f57 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -58,6 +58,6 @@ val appModule = module { // ViewModels (ScreenModels) factory { HomeViewModel(get(), get(), get()) } factory { params -> PatchesViewModel(params.get(), params.get(), get(), get()) } - factory { params -> PatchSelectionViewModel(params.get(), params.get(), params.get(), params.get(), get(), get()) } + factory { params -> PatchSelectionViewModel(params.get(), params.get(), params.get(), params.get(), params.get(), get(), get()) } factory { params -> PatchingViewModel(params.get(), get(), get()) } } 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 99e6e55..a03ba7a 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -251,7 +251,7 @@ fun SettingsDialog( // Cache info val cacheSize = calculateCacheSize() Text( - text = "Cache: $cacheSize (CLI + Patches)", + text = "Cache: $cacheSize (Patches + Logs)", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -286,7 +286,7 @@ fun SettingsDialog( shape = RoundedCornerShape(16.dp), title = { Text("Clear Cache?") }, text = { - Text("This will delete downloaded CLI and patch files. They will be re-downloaded when needed.") + Text("This will delete downloaded patch files and log files. Patches will be re-downloaded when needed.") }, confirmButton = { Button( @@ -322,17 +322,21 @@ private fun ThemePreference.toDisplayName(): String { private fun calculateCacheSize(): String { val patchesSize = FileUtils.getPatchesDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + val logsSize = FileUtils.getLogsDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + val totalSize = patchesSize + logsSize return when { - patchesSize < 1024 -> "$patchesSize B" - patchesSize < 1024 * 1024 -> "%.1f KB".format(patchesSize / 1024.0) - else -> "%.1f MB".format(patchesSize / (1024.0 * 1024.0)) + totalSize < 1024 -> "$totalSize B" + totalSize < 1024 * 1024 -> "%.1f KB".format(totalSize / 1024.0) + else -> "%.1f MB".format(totalSize / (1024.0 * 1024.0)) } } private fun clearAllCache(): Boolean { return try { var failedCount = 0 + + // Delete patch files FileUtils.getPatchesDir().listFiles()?.forEach { file -> try { java.nio.file.Files.delete(file.toPath()) @@ -341,6 +345,17 @@ private fun clearAllCache(): Boolean { Logger.error("Failed to delete ${file.name}: ${e.message}") } } + + // Delete log files + FileUtils.getLogsDir().listFiles()?.forEach { file -> + try { + java.nio.file.Files.delete(file.toPath()) + } catch (e: Exception) { + failedCount++ + Logger.error("Failed to delete log ${file.name}: ${e.message}") + } + } + FileUtils.cleanupAllTempDirs() if (failedCount > 0) { Logger.error("Cache clear incomplete: $failedCount file(s) could not be deleted (may be locked)") diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 585aa9a..c7e879e 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -113,6 +113,7 @@ fun HomeScreenContent( apkPath = uiState.apkInfo!!.filePath, apkName = uiState.apkInfo!!.appName, patchesFilePath = patchesFile.absolutePath, + packageName = uiState.apkInfo!!.packageName, apkArchitectures = uiState.apkInfo!!.architectures )) } @@ -206,6 +207,7 @@ fun HomeScreenContent( apkPath = info.filePath, apkName = info.appName, patchesFilePath = patchesFile.absolutePath, + packageName = info.packageName, apkArchitectures = info.architectures )) } @@ -643,7 +645,7 @@ private fun SupportedAppsSection( // Important notice about APK handling Text( - text = "Download the exact version from APKMirror and drop it here directly. Do not rename or modify the file.", + text = "Download the exact version from APKMirror and drop it here directly.", fontSize = if (isCompact) 10.sp else 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center, @@ -984,129 +986,6 @@ private fun SupportedAppCardDynamic( } } -@Composable -private fun SupportedAppCard( - appType: AppType, - iconRes: org.jetbrains.compose.resources.DrawableResource, - isCompact: Boolean = false, - modifier: Modifier = Modifier -) { - val cardPadding = if (isCompact) 12.dp else 16.dp - val iconSize = if (isCompact) 48.dp else 56.dp - val iconInnerSize = if (isCompact) 32.dp else 40.dp - - Card( - modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(if (isCompact) 12.dp else 16.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(cardPadding), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // App icon - Box( - modifier = Modifier - .size(iconSize) - .clip(RoundedCornerShape(if (isCompact) 10.dp else 12.dp)) - .background(Color.White), - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(iconRes), - contentDescription = "${appType.displayName} icon", - modifier = Modifier.size(iconInnerSize) - ) - } - - Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) - - // App name - Text( - text = appType.displayName, - fontSize = if (isCompact) 14.sp else 16.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) - - // Suggested version badge - Surface( - color = MorpheColors.Teal.copy(alpha = 0.15f), - shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp) - ) { - Column( - modifier = Modifier.padding( - horizontal = if (isCompact) 10.dp else 12.dp, - vertical = if (isCompact) 6.dp else 8.dp - ), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Recommended", - fontSize = if (isCompact) 9.sp else 10.sp, - color = MorpheColors.Teal.copy(alpha = 0.8f), - letterSpacing = 0.5.sp - ) - Text( - text = "v${appType.suggestedVersion}", - fontSize = if (isCompact) 12.sp else 14.sp, - fontWeight = FontWeight.SemiBold, - color = MorpheColors.Teal - ) - } - } - - Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) - - // Download from APKMirror button - val downloadUrl = SupportedApp.getDownloadUrl(appType.packageName, appType.suggestedVersion) - if (downloadUrl != null) { - OutlinedButton( - onClick = { - try { - java.awt.Desktop.getDesktop().browse(java.net.URI(downloadUrl)) - } catch (e: Exception) { - // Ignore errors - } - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp), - contentPadding = PaddingValues( - horizontal = if (isCompact) 8.dp else 12.dp, - vertical = if (isCompact) 6.dp else 8.dp - ), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MorpheColors.Blue - ) - ) { - Text( - text = if (isCompact) "APKMirror" else "Get from APKMirror", - fontSize = if (isCompact) 11.sp else 12.sp, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) - } - - // Package name - Text( - text = appType.packageName, - fontSize = if (isCompact) 9.sp else 10.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - textAlign = TextAlign.Center, - maxLines = 1 - ) - } - } -} - @Composable private fun DragOverlay() { Box( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 141527a..244844b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -313,16 +313,8 @@ class HomeViewModel( val appName = dynamicSupportedApp?.displayName ?: SupportedApp.getDisplayName(packageName) - // Get recommended version - prefer dynamic, fallback to hardcoded + // Get recommended version from dynamic patches data (no hardcoded fallback) val suggestedVersion = dynamicSupportedApp?.recommendedVersion - ?: app.morphe.gui.data.constants.AppConstants.getSuggestedVersion(packageName) - - // Determine AppType for backward compatibility (still used in some places) - val appType = when (packageName) { - app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME -> AppType.YOUTUBE - app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME -> AppType.YOUTUBE_MUSIC - else -> null - } // Compare versions if we have a suggested version val versionStatus = if (suggestedVersion != null) { @@ -335,8 +327,8 @@ class HomeViewModel( // For .apkm files, scan the original bundle (splits contain the native libs, not base.apk) val architectures = extractArchitectures(if (isApkm) file else apkToParse) - // Verify checksum (still uses AppConstants for now) - val checksumStatus = verifyChecksum(file, packageName, versionName, architectures, suggestedVersion) + // TODO: Re-enable when checksums are provided via .mpp files + val checksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured Logger.info("Parsed APK: $packageName v$versionName (recommended=$suggestedVersion, minSdk=$minSdk, archs=$architectures)") @@ -346,7 +338,6 @@ class HomeViewModel( fileSize = file.length(), formattedSize = formatFileSize(file.length()), appName = appName, - appType = appType, packageName = packageName, versionName = versionName, architectures = architectures, @@ -408,42 +399,11 @@ class HomeViewModel( } } - /** - * Verify the APK checksum against expected values. - */ - private fun verifyChecksum( - file: File, - packageName: String, - version: String, - architectures: List, - recommendedVersion: String? - ): app.morphe.gui.util.ChecksumStatus { - // Check if this is a non-recommended version (use dynamic recommended version) - if (recommendedVersion != null && version != recommendedVersion) { - return app.morphe.gui.util.ChecksumStatus.NonRecommendedVersion - } - - // Get expected checksum (still from AppConstants - checksums are manually maintained) - val expectedChecksum = app.morphe.gui.data.constants.AppConstants.getChecksum(packageName, version, architectures) - if (expectedChecksum == null) { - return app.morphe.gui.util.ChecksumStatus.NotConfigured - } - - // Calculate actual checksum - return try { - val actualChecksum = app.morphe.gui.util.ChecksumUtils.calculateSha256(file) - Logger.info("Checksum verification - Expected: $expectedChecksum, Actual: $actualChecksum") - - if (actualChecksum.equals(expectedChecksum, ignoreCase = true)) { - app.morphe.gui.util.ChecksumStatus.Verified - } else { - app.morphe.gui.util.ChecksumStatus.Mismatch(expectedChecksum, actualChecksum) - } - } catch (e: Exception) { - Logger.error("Checksum calculation failed", e) - app.morphe.gui.util.ChecksumStatus.Error(e.message ?: "Unknown error") - } - } + // TODO: Re-enable checksum verification when checksums are provided via .mpp files + // private fun verifyChecksum( + // file: File, packageName: String, version: String, + // architectures: List, recommendedVersion: String? + // ): app.morphe.gui.util.ChecksumStatus { ... } private fun formatFileSize(bytes: Long): String { return when { @@ -499,30 +459,12 @@ data class HomeUiState( get() = patchesVersion != null && patchesVersion == latestPatchesVersion } -enum class AppType( - val displayName: String, - val packageName: String, - val suggestedVersion: String -) { - YOUTUBE( - displayName = app.morphe.gui.data.constants.AppConstants.YouTube.DISPLAY_NAME, - packageName = app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME, - suggestedVersion = app.morphe.gui.data.constants.AppConstants.YouTube.SUGGESTED_VERSION - ), - YOUTUBE_MUSIC( - displayName = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.DISPLAY_NAME, - packageName = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME, - suggestedVersion = app.morphe.gui.data.constants.AppConstants.YouTubeMusic.SUGGESTED_VERSION - ) -} - data class ApkInfo( val fileName: String, val filePath: String, val fileSize: Long, val formattedSize: String, val appName: String, - val appType: AppType?, // Nullable for dynamically supported apps not in the enum val packageName: String, val versionName: String, val architectures: List = emptyList(), diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt index f4b0365..cdd794c 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt @@ -1,6 +1,5 @@ package app.morphe.gui.ui.screens.home.components -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -18,11 +17,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import app.morphe.morphe_cli.generated.resources.Res -import org.jetbrains.compose.resources.painterResource -import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.ui.screens.home.ApkInfo -import app.morphe.gui.ui.screens.home.AppType import app.morphe.gui.ui.screens.home.VersionStatus import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.util.ChecksumStatus @@ -54,16 +49,6 @@ fun ApkInfoCard( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f) ) { - // App icon - determine from appType or packageName - val iconRes = when { - apkInfo.appType == AppType.YOUTUBE -> null - apkInfo.appType == AppType.YOUTUBE_MUSIC -> null - apkInfo.packageName == AppConstants.YouTube.PACKAGE_NAME -> null - apkInfo.packageName == AppConstants.YouTubeMusic.PACKAGE_NAME -> null - apkInfo.packageName == AppConstants.Reddit.PACKAGE_NAME -> null - else -> null - } - Box( modifier = Modifier .size(64.dp) @@ -71,21 +56,12 @@ fun ApkInfoCard( .background(Color.White), contentAlignment = Alignment.Center ) { - if (iconRes != null) { - Image( - painter = painterResource(iconRes), - contentDescription = "${apkInfo.appName} icon", - modifier = Modifier.size(48.dp) - ) - } else { - // Fallback: show first letter of app name - Text( - text = apkInfo.appName.first().toString(), - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = MorpheColors.Blue - ) - } + Text( + text = apkInfo.appName.first().toString(), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MorpheColors.Blue + ) } Column { 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 9cbfcd3..2b3438b 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 @@ -57,13 +57,14 @@ data class PatchSelectionScreen( val apkPath: String, val apkName: String, val patchesFilePath: String, + val packageName: String, val apkArchitectures: List = emptyList() ) : Screen { @Composable override fun Content() { val viewModel = koinScreenModel { - parametersOf(apkPath, apkName, patchesFilePath, apkArchitectures) + parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures) } PatchSelectionScreenContent(viewModel = viewModel) } 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 278691b..d55bcb9 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 @@ -17,6 +17,7 @@ class PatchSelectionViewModel( private val apkPath: String, private val apkName: String, private val patchesFilePath: String, + private val packageName: String, private val apkArchitectures: List, private val patchService: PatchService, private val patchRepository: PatchRepository @@ -60,10 +61,8 @@ class PatchSelectionViewModel( actualPatchesFilePath = downloadResult.getOrNull()!!.absolutePath } - val packageName = getPackageNameFromApk() - // Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(actualPatchesFilePath, packageName) + val patchesResult = patchService.listPatches(actualPatchesFilePath, packageName.ifEmpty { null }) patchesResult.fold( onSuccess = { patches -> @@ -177,9 +176,11 @@ class PatchSelectionViewModel( val outputDir = File(inputFile.parentFile, appFolderName) outputDir.mkdirs() - // Extract version from APK filename for output name + // Extract version from APK filename and patches version for output name val version = extractVersionFromFilename(inputFile.name) ?: "patched" - val outputFileName = "${appFolderName}-${version}-patched.apk" + val patchesVersion = extractPatchesVersion(File(actualPatchesFilePath).name) + val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" + val outputFileName = "${appFolderName}-Morphe-${version}${patchesSuffix}.apk" val outputPath = File(outputDir, outputFileName).absolutePath // Convert unique IDs back to patch names for CLI @@ -219,6 +220,12 @@ class PatchSelectionViewModel( } } + private fun extractPatchesVersion(patchesFileName: String): String? { + // Extract version from patches filename: morphe-patches-1.13.0-dev.11.mpp -> 1.13.0-dev.11 + val regex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") + return regex.find(patchesFileName)?.groupValues?.get(1) + } + fun getApkName(): String = apkName /** @@ -230,12 +237,21 @@ class PatchSelectionViewModel( val patchesFile = File(actualPatchesFilePath) val appFolderName = apkName.replace(" ", "-") val version = extractVersionFromFilename(inputFile.name) ?: "patched" - val outputFileName = "${appFolderName}-${version}-patched.apk" + val patchesVersion = extractPatchesVersion(patchesFile.name) + val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" + val outputFileName = "${appFolderName}-Morphe-${version}${patchesSuffix}.apk" val selectedPatchNames = _uiState.value.allPatches .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } .map { it.name } + val disabledPatchNames = _uiState.value.allPatches + .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } + .map { it.name } + + // 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(",") @@ -248,15 +264,21 @@ class PatchSelectionViewModel( sb.append("java -jar morphe-cli.jar patch \\\n") sb.append(" -p ${patchesFile.name} \\\n") sb.append(" -o ${outputFileName} \\\n") - sb.append(" --exclusive \\\n") + + if (useExclusive) { + sb.append(" --exclusive \\\n") + } if (striplibsArg != null) { sb.append(" --striplibs $striplibsArg \\\n") } - selectedPatchNames.forEachIndexed { index, patch -> - val isLast = index == selectedPatchNames.lastIndex - sb.append(" -e \"$patch\"") + val flagPatches = if (useExclusive) selectedPatchNames else disabledPatchNames + val flag = if (useExclusive) "-e" else "-d" + + flagPatches.forEachIndexed { index, patch -> + val isLast = index == flagPatches.lastIndex + sb.append(" $flag \"$patch\"") if (!isLast) { sb.append(" \\") } @@ -266,10 +288,12 @@ class PatchSelectionViewModel( sb.append(" ${inputFile.name}") sb.toString() } else { - // Compact mode - single line that wraps naturally - val patches = selectedPatchNames.joinToString(" ") { "-e \"$it\"" } + val flagPatches = if (useExclusive) selectedPatchNames else disabledPatchNames + val flag = if (useExclusive) "-e" else "-d" + val patches = flagPatches.joinToString(" ") { "$flag \"$it\"" } + val exclusivePart = if (useExclusive) " --exclusive" else "" val striplibsPart = if (striplibsArg != null) " --striplibs $striplibsArg" else "" - "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --exclusive$striplibsPart $patches ${inputFile.name}" + "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName$exclusivePart$striplibsPart $patches ${inputFile.name}" } } @@ -315,16 +339,6 @@ class PatchSelectionViewModel( return patchRepository.downloadPatches(targetRelease) } - private fun getPackageNameFromApk(): String { - // Extract package name from APK filename (APKMirror format) - val fileName = File(apkPath).name - return when { - fileName.startsWith("com.google.android.youtube_") -> "com.google.android.youtube" - fileName.startsWith("com.google.android.apps.youtube.music_") -> "com.google.android.apps.youtube.music" - fileName.startsWith("com.reddit.frontpage_") -> "com.reddit.frontpage" - else -> "" - } - } } data class PatchSelectionUiState( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 1845e3d..ff59acc 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -52,8 +52,8 @@ import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects import java.awt.Desktop import java.awt.datatransfer.DataFlavor import java.io.File -import javax.swing.JFileChooser -import javax.swing.filechooser.FileNameExtensionFilter +import java.awt.FileDialog +import java.awt.Frame /** * Quick Patch Mode - Single screen simplified patching. @@ -996,14 +996,17 @@ private fun VerificationStatusBanner( * Open native file picker. */ private fun openFilePicker(): File? { - val chooser = JFileChooser().apply { - dialogTitle = "Select APK" - fileFilter = FileNameExtensionFilter("APK Files (*.apk, *.apkm)", "apk", "apkm") - isAcceptAllFileFilterUsed = false + val fileDialog = FileDialog(null as Frame?, "Select APK", FileDialog.LOAD).apply { + isMultipleMode = false + setFilenameFilter { _, name -> name.lowercase().let { it.endsWith(".apk") || it.endsWith(".apkm") } } + isVisible = true } - return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { - chooser.selectedFile + val directory = fileDialog.directory + val file = fileDialog.file + + return if (directory != null && file != null) { + File(directory, file) } else null } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index 2907fd7..35a879f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import net.dongliu.apk.parser.ApkFile import app.morphe.gui.util.ChecksumStatus -import app.morphe.gui.util.ChecksumUtils import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService @@ -203,7 +202,6 @@ class QuickPatchViewModel( ?: SupportedApp.getDisplayName(packageName) val recommendedVersion = dynamicAppInfo?.recommendedVersion - ?: AppConstants.getSuggestedVersion(packageName) // Version check val isRecommendedVersion = recommendedVersion != null && versionName == recommendedVersion @@ -211,8 +209,8 @@ class QuickPatchViewModel( "Version $versionName may have compatibility issues. Recommended: $recommendedVersion" } else null - // Checksum verification (still uses AppConstants - checksums are manually maintained) - val checksumStatus = verifyChecksum(file, packageName, versionName, recommendedVersion) + // TODO: Re-enable when checksums are provided via .mpp files + val checksumStatus = ChecksumStatus.NotConfigured Logger.info("Quick mode: Analyzed $displayName v$versionName (recommended: $recommendedVersion)") @@ -237,29 +235,10 @@ class QuickPatchViewModel( } } - /** - * Verify checksum against known values. - */ - private fun verifyChecksum(file: File, packageName: String, version: String, recommendedVersion: String?): ChecksumStatus { - // Check if this is a non-recommended version (use dynamic recommended version) - if (recommendedVersion != null && version != recommendedVersion) { - return ChecksumStatus.NonRecommendedVersion - } - - val expectedChecksum = AppConstants.getChecksum(packageName, version, emptyList()) - ?: return ChecksumStatus.NotConfigured - - return try { - val actualChecksum = ChecksumUtils.calculateSha256(file) - if (actualChecksum.equals(expectedChecksum, ignoreCase = true)) { - ChecksumStatus.Verified - } else { - ChecksumStatus.Mismatch(expectedChecksum, actualChecksum) - } - } catch (e: Exception) { - ChecksumStatus.Error(e.message ?: "Unknown error") - } - } + // TODO: Re-enable checksum verification when checksums are provided via .mpp files + // private fun verifyChecksum( + // file: File, packageName: String, version: String, recommendedVersion: String? + // ): ChecksumStatus { ... } /** * Start the patching process with defaults. @@ -321,7 +300,10 @@ class QuickPatchViewModel( // Generate output path val outputDir = apkFile.parentFile ?: File(System.getProperty("user.home")) val baseName = apkInfo.displayName.replace(" ", "-") - val outputFileName = "$baseName-Morphe-${apkInfo.versionName}.apk" + val patchesVersion = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") + .find(patchFile.name)?.groupValues?.get(1) + val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" + val outputFileName = "$baseName-Morphe-${apkInfo.versionName}${patchesSuffix}.apk" val outputPath = File(outputDir, outputFileName).absolutePath // Use PatchService for direct library patching (no CLI subprocess) From 46a45774c0b46f8d8bed22493c7692251ae43af4 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:53:59 +0530 Subject: [PATCH 21/23] Use non zero Java exit code if patching fails --- .../app/morphe/cli/command/MainCommand.kt | 8 +- .../app/morphe/cli/command/PatchCommand.kt | 398 +++++++++++++----- 2 files changed, 296 insertions(+), 110 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt index 40bc787..f6c8a83 100644 --- a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt @@ -5,11 +5,13 @@ import app.morphe.library.logging.Logger import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.IVersionProvider -import java.util.* +import java.util.Properties +import kotlin.system.exitProcess fun cliMain(args: Array) { Logger.setDefault() - CommandLine(MainCommand).execute(*args).let(System::exit) + val exitCode = CommandLine(MainCommand).execute(*args) + exitProcess(exitCode) } private object CLIVersionProvider : IVersionProvider { @@ -37,6 +39,6 @@ private object CLIVersionProvider : IVersionProvider { ListPatchesCommand::class, ListCompatibleVersions::class, UtilityCommand::class, - ], + ] ) internal object MainCommand diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 7e1a424..8791884 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -1,14 +1,21 @@ package app.morphe.cli.command +import app.morphe.cli.command.model.FailedPatch import app.morphe.cli.command.model.PatchingResult import app.morphe.cli.command.model.PatchingStep -import app.morphe.cli.command.model.PatchingStepResult import app.morphe.cli.command.model.addStepResult import app.morphe.cli.command.model.toSerializablePatch -import app.morphe.engine.PatchEngine +import app.morphe.engine.ApkLibraryStripper import app.morphe.library.ApkUtils +import app.morphe.library.ApkUtils.applyTo import app.morphe.library.installation.installer.* +import app.morphe.library.setOptions +import app.morphe.patcher.Patcher +import app.morphe.patcher.PatcherConfig +import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar +import com.reandroid.apkeditor.merge.Merger +import com.reandroid.apkeditor.merge.MergerOptions import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -19,15 +26,21 @@ import picocli.CommandLine.Help.Visibility.ALWAYS import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Spec import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.util.concurrent.Callable import java.util.logging.Logger -import app.morphe.cli.command.model.FailedPatch as CliFailedPatch @OptIn(ExperimentalSerializationApi::class) @CommandLine.Command( name = "patch", description = ["Patch an APK file."], ) -internal object PatchCommand : Runnable { +internal object PatchCommand : Callable { + + private const val EXIT_CODE_SUCCESS = 0 + private const val EXIT_CODE_ERROR = 1 + private val logger = Logger.getLogger(this::class.java.name) @Spec @@ -256,7 +269,16 @@ internal object PatchCommand : Runnable { ) private var striplibs: List = emptyList() - override fun run() { + @CommandLine.Option( + names = ["--continue-on-error"], + description = ["Continue patching even if a patch fails. By default, patching stops on the first error."], + showDefaultValue = ALWAYS, + ) + private var continueOnError: Boolean = false + + override fun call(): Int { + // region Setup + val outputFilePath = outputFilePath ?: File("").absoluteFile.resolve( "${apk.nameWithoutExtension}-patched.apk", @@ -271,7 +293,6 @@ internal object PatchCommand : Runnable { keyStoreFilePath ?: outputFilePath.parentFile .resolve("${outputFilePath.nameWithoutExtension}.keystore") - // Set up ADB installer (CLI-only) val installer = if (deviceSerial != null) { val deviceSerial = deviceSerial!!.ifEmpty { null } @@ -281,7 +302,7 @@ internal object PatchCommand : Runnable { } else { AdbInstaller(deviceSerial) } - } catch (e: DeviceNotFoundException) { + } catch (_: DeviceNotFoundException) { if (deviceSerial?.isNotEmpty() == true) { logger.severe( "Device with serial $deviceSerial not found to install to. " + @@ -294,119 +315,199 @@ internal object PatchCommand : Runnable { ) } - return + return EXIT_CODE_ERROR } } else { null } - // Resolve --ei/--di indices to patch names by pre-loading patches - val patchesList = loadPatchesFromJar(patchesFiles).toList() - - val enabledPatchNames = selection.mapNotNull { sel -> - sel.enabled?.let { en -> - en.selector.name ?: patchesList.getOrNull(en.selector.index!!)?.name - } - }.toSet() - - val disabledPatchNames = selection.mapNotNull { sel -> - sel.disable?.let { dis -> - dis.selector.name ?: patchesList.getOrNull(dis.selector.index!!)?.name - } - }.filterNotNull().toSet() - - // Build options map: Map> - val patchOptions = selection.filter { it.enabled != null } - .associate { sel -> - val en = sel.enabled!! - val name = en.selector.name ?: patchesList[en.selector.index!!].name!! - name to en.options.toMap() - } - .filter { it.value.isNotEmpty() } - - val config = PatchEngine.Config( - inputApk = apk, - patches = patchesList.toSet(), - outputApk = outputFilePath, - enabledPatches = enabledPatchNames, - disabledPatches = disabledPatchNames, - exclusiveMode = exclusive, - forceCompatibility = force, - patchOptions = patchOptions, - unsigned = mount || unsigned, - signerName = signer, - keystoreDetails = ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - keyStoreEntryAlias, - keyStoreEntryPassword, - ), - architecturesToKeep = striplibs, - aaptBinaryPath = aaptBinaryPath, - tempDir = temporaryFilesPath, - ) + // endregion val patchingResult = PatchingResult() + var mergedApkToCleanup: File? = null try { - val engineResult = runBlocking { - PatchEngine.patch(config) { msg -> logger.info(msg) } - } + // region Load patches - patchingResult.packageName = engineResult.packageName - patchingResult.packageVersion = engineResult.packageVersion - patchingResult.success = engineResult.success - - // Map engine step results to CLI model for --result-file - engineResult.stepResults.forEach { step -> - val cliStep = when (step.step) { - PatchEngine.PatchStep.PATCHING -> PatchingStep.PATCHING - PatchEngine.PatchStep.REBUILDING -> PatchingStep.REBUILDING - PatchEngine.PatchStep.STRIPPING_LIBS -> PatchingStep.STRIPPING_LIBS - PatchEngine.PatchStep.SIGNING -> PatchingStep.SIGNING - } - patchingResult.patchingSteps.add(PatchingStepResult(cliStep, step.success, step.error)) - } + logger.info("Loading patches") + + val patches = loadPatchesFromJar(patchesFiles) + + // endregion + + val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher") - engineResult.appliedPatches.forEach { name -> - patchesList.find { it.name == name }?.let { - patchingResult.appliedPatches.add(it.toSerializablePatch()) + // Checking if the file is in apkm format (like reddit) + val inputApk = if (apk.extension.equals("apkm", ignoreCase = true)) { + logger.info("Merging APKM bundle") + + // Save merged APK to output directory (will be cleaned up after patching) + val outputApk = outputFilePath.parentFile.resolve("${apk.nameWithoutExtension}-merged.apk") + + // Use APKEditor's Merger directly (handles extraction and merging) + val mergerOptions = MergerOptions().apply { + inputFile = apk // Original APKM file + outputFile = outputApk + cleanMeta = true } + Merger(mergerOptions).run() + + mergedApkToCleanup = outputApk + outputApk + } else { + apk } - engineResult.failedPatches.forEach { failed -> - patchesList.find { it.name == failed.name }?.let { - patchingResult.failedPatches.add(CliFailedPatch(it.toSerializablePatch(), failed.error)) - } + + val (packageName, patcherResult) = Patcher( + PatcherConfig( + inputApk, + patcherTemporaryFilesPath, + aaptBinaryPath?.path, + patcherTemporaryFilesPath.absolutePath, + ), + ).use { patcher -> + val packageName = patcher.context.packageMetadata.packageName + val packageVersion = patcher.context.packageMetadata.packageVersion + + patchingResult.packageName = packageName + patchingResult.packageVersion = packageVersion + + val filteredPatches = patches.filterPatchSelection(packageName, packageVersion) + + logger.info("Setting patch options") + + val patchesList = patches.toList() + selection.filter { it.enabled != null }.associate { + val enabledSelection = it.enabled!! + + (enabledSelection.selector.name ?: patchesList[enabledSelection.selector.index!!].name!!) to + enabledSelection.options + }.let(filteredPatches::setOptions) + + patcher += filteredPatches + + // Execute patches. + patchingResult.addStepResult( + PatchingStep.PATCHING, + { + runBlocking { + patcher().collect { patchResult -> + patchResult.exception?.let { exception -> + StringWriter().use { writer -> + exception.printStackTrace(PrintWriter(writer)) + + logger.severe("\"${patchResult.patch}\" failed:\n$writer") + + patchingResult.failedPatches.add( + FailedPatch( + patchResult.patch.toSerializablePatch(), + writer.toString() + ) + ) + patchingResult.success = false + + if (!continueOnError) { + throw PatchFailedException( + "\"${patchResult.patch}\" failed", + exception + ) + } + } + } ?: patchResult.patch.let { + patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) + logger.info("\"${patchResult.patch}\" succeeded") + } + } + } + } + ) + + patcher.context.packageMetadata.packageName to patcher.get() } - logger.info("Saved to $outputFilePath") + // region Save. - // ADB install (CLI-only) - if (engineResult.success) { - deviceSerial?.let { + inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { + patchingResult.addStepResult( + PatchingStep.REBUILDING, + { + patcherResult.applyTo(this) + } + ) + }.also { rebuiltApk -> + if (striplibs.isNotEmpty()) { patchingResult.addStepResult( - PatchingStep.INSTALLING, + PatchingStep.STRIPPING_LIBS, { - runBlocking { - val result = installer!!.install( - Installer.Apk(outputFilePath, engineResult.packageName), - ) - when (result) { - RootInstallerResult.FAILURE -> { - logger.severe("Failed to mount the patched APK file") - throw IllegalStateException("Failed to mount the patched APK file") - } - is AdbInstallerResult.Failure -> { - logger.severe(result.exception.toString()) - throw result.exception - } - else -> logger.info("Installed the patched APK file") - } + ApkLibraryStripper.stripLibraries(rebuiltApk, striplibs) { msg -> + logger.info(msg) } - }, + } + ) + } + }.let { patchedApkFile -> + if (!mount && !unsigned) { + patchingResult.addStepResult( + PatchingStep.SIGNING, + { + ApkUtils.signApk( + patchedApkFile, + outputFilePath, + signer, + ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + keyStoreEntryAlias, + keyStoreEntryPassword, + ), + ) + } ) + } else { + patchedApkFile.copyTo(outputFilePath, overwrite = true) } } + + logger.info("Saved to $outputFilePath") + + // endregion + + // region Install. + + deviceSerial?.let { + patchingResult.addStepResult( + PatchingStep.INSTALLING, + { + runBlocking { + val result = installer!!.install(Installer.Apk(outputFilePath, packageName)) + when (result) { + RootInstallerResult.FAILURE -> { + logger.severe("Failed to mount the patched APK file") + throw IllegalStateException("Failed to mount the patched APK file") + } + is AdbInstallerResult.Failure -> { + logger.severe(result.exception.toString()) + throw result.exception + } + else -> logger.info("Installed the patched APK file") + } + } + } + ) + } + + // endregion + } catch (e: PatchFailedException) { + logger.severe("Patching aborted: ${e.message}") + logger.info( + "Use --continue-on-error to skip failed patches and continue patching" + ) + return EXIT_CODE_ERROR + } catch (e: Exception) { + // Should never happen. + logger.severe("An unexpected error occurred: ${e.message}") + e.printStackTrace() + return EXIT_CODE_ERROR } finally { patchingResultOutputFilePath?.let { outputFile -> outputFile.outputStream().use { outputStream -> @@ -414,17 +515,100 @@ internal object PatchCommand : Runnable { } logger.info("Patching result saved to $outputFile") } - } - if (purge) { - logger.info("Purging temporary files") - val result = - if (temporaryFilesPath.deleteRecursively()) { - "Purged resource cache directory" - } else { - "Failed to purge resource cache directory" + if (purge) { + logger.info("Purging temporary files") + purge(temporaryFilesPath) + } + + // Clean up merged APK if we created one from APKM + mergedApkToCleanup?.let { + if (!it.delete()) { + logger.warning("Could not clean up merged APK: ${it.path}") } - logger.info(result) + } } + + return EXIT_CODE_SUCCESS + } + + /** + * Filter the patches based on the selection. + * + * @param packageName The package name of the APK file to be patched. + * @param packageVersion The version of the APK file to be patched. + * @return The filtered patches. + */ + private fun Set>.filterPatchSelection( + packageName: String, + packageVersion: String, + ): Set> = buildSet { + val enabledPatchesByName = + selection.mapNotNull { it.enabled?.selector?.name }.toSet() + val enabledPatchesByIndex = + selection.mapNotNull { it.enabled?.selector?.index }.toSet() + + val disabledPatches = + selection.mapNotNull { it.disable?.selector?.name }.toSet() + val disabledPatchesByIndex = + selection.mapNotNull { it.disable?.selector?.index }.toSet() + + this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) -> + val patchName = patch.name!! + + val isManuallyDisabled = patchName in disabledPatches || i in disabledPatchesByIndex + if (isManuallyDisabled) return@patchLoop logger.info("\"$patchName\" disabled manually") + + // Make sure the patch is compatible with the supplied APK files package name and version. + patch.compatiblePackages?.let { packages -> + packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) -> + if (versions?.isEmpty() == true) { + return@patchLoop logger.warning("\"$patchName\" incompatible with \"$packageName\"") + } + + val matchesVersion = + force || versions?.let { it.any { version -> version == packageVersion } } ?: true + + if (!matchesVersion) { + return@patchLoop logger.warning( + "\"$patchName\" incompatible with $packageName $packageVersion " + + "but compatible with " + + packages.joinToString("; ") { (packageName, versions) -> + packageName + " " + versions!!.joinToString(", ") + }, + ) + } + } ?: return@patchLoop logger.fine( + "\"$patchName\" incompatible with $packageName. " + + "It is only compatible with " + + packages.joinToString(", ") { (name, _) -> name }, + ) + + return@let + } ?: logger.fine("\"$patchName\" has no package constraints") + + val isEnabled = !exclusive && patch.use + val isManuallyEnabled = patchName in enabledPatchesByName || i in enabledPatchesByIndex + + if (!(isEnabled || isManuallyEnabled)) { + return@patchLoop logger.info("\"$patchName\" disabled") + } + + add(patch) + + logger.fine("\"$patchName\" added") + } + } + + private fun purge(resourceCachePath: File) { + val result = + if (resourceCachePath.deleteRecursively()) { + "Purged resource cache directory" + } else { + "Failed to purge resource cache directory" + } + logger.info(result) } } + +private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) From 7640a80c485cffe188db4dedf3acf37afbc2fd6d Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:46:09 +0530 Subject: [PATCH 22/23] Minor Fixes Better patching logging Simplified version. Added --force for unsupported versions. Added a button for --continue-on-error to allow gui users to continue patching even when if a patch throws an error. --- .../kotlin/app/morphe/gui/data/model/Patch.kt | 3 +- .../screens/patches/PatchSelectionScreen.kt | 43 +++++++++++++++++-- .../patches/PatchSelectionViewModel.kt | 15 +++++-- .../ui/screens/patching/PatchingViewModel.kt | 1 + .../ui/screens/quick/QuickPatchViewModel.kt | 8 +--- .../app/morphe/gui/util/PatchService.kt | 3 ++ 6 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt index 2f940a2..ea413a7 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -80,5 +80,6 @@ data class PatchConfig( val disabledPatches: List = emptyList(), val patchOptions: Map = emptyMap(), val useExclusiveMode: Boolean = false, - val striplibs: List = emptyList() + val striplibs: List = emptyList(), + val continueOnError: Boolean = false ) 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 2b3438b..3d6d725 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 @@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.PlaylistRemove import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.* import androidx.compose.runtime.* @@ -107,6 +108,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { // State for command preview var cleanMode by remember { mutableStateOf(false) } var showCommandPreview by remember { mutableStateOf(false) } + var continueOnError by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -149,7 +151,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { Spacer(Modifier.width(12.dp)) - // Command preview toggle + // Command preview toggle & continue-on-error toggle if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { val isActive = showCommandPreview Surface( @@ -170,6 +172,39 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { modifier = Modifier.padding(8.dp).size(20.dp) ) } + + Spacer(Modifier.width(6.dp)) + + // Continue on error toggle + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text("Continue patching even if a patch fails") + } + }, + state = rememberTooltipState() + ) { + Surface( + onClick = { continueOnError = !continueOnError }, + shape = RoundedCornerShape(8.dp), + color = if (continueOnError) MaterialTheme.colorScheme.error.copy(alpha = 0.15f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke( + width = 1.dp, + color = if (continueOnError) MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) { + Icon( + imageVector = Icons.Default.PlaylistRemove, + contentDescription = "Continue on error", + tint = if (continueOnError) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(8.dp).size(20.dp) + ) + } + } } Spacer(Modifier.width(12.dp)) @@ -191,8 +226,8 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { ) { // Command preview - collapsible via top bar button if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { - val commandPreview = remember(uiState.selectedPatches, uiState.selectedArchitectures, cleanMode) { - viewModel.getCommandPreview(cleanMode) + val commandPreview = remember(uiState.selectedPatches, uiState.selectedArchitectures, cleanMode, continueOnError) { + viewModel.getCommandPreview(cleanMode, continueOnError) } AnimatedVisibility( visible = showCommandPreview, @@ -322,7 +357,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { ) { Button( onClick = { - val config = viewModel.createPatchConfig() + val config = viewModel.createPatchConfig(continueOnError) navigator.push(PatchingScreen(config)) }, enabled = uiState.selectedPatches.isNotEmpty(), 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 d55bcb9..74b710b 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 @@ -169,7 +169,7 @@ class PatchSelectionViewModel( return _uiState.value.allPatches.count { !it.isEnabled } } - fun createPatchConfig(): PatchConfig { + fun createPatchConfig(continueOnError: Boolean = false): PatchConfig { // Create app folder in the same location as the input APK val inputFile = File(apkPath) val appFolderName = apkName.replace(" ", "-") @@ -206,7 +206,8 @@ class PatchSelectionViewModel( enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, useExclusiveMode = true, - striplibs = striplibs + striplibs = striplibs, + continueOnError = continueOnError ) } @@ -232,7 +233,7 @@ class PatchSelectionViewModel( * Generate a preview of the CLI command that will be executed. * @param cleanMode If true, formats with newlines for readability. If false, compact single-line format. */ - fun getCommandPreview(cleanMode: Boolean = false): String { + fun getCommandPreview(cleanMode: Boolean = false, continueOnError: Boolean = false): String { val inputFile = File(apkPath) val patchesFile = File(actualPatchesFilePath) val appFolderName = apkName.replace(" ", "-") @@ -264,6 +265,11 @@ class PatchSelectionViewModel( sb.append("java -jar morphe-cli.jar patch \\\n") sb.append(" -p ${patchesFile.name} \\\n") sb.append(" -o ${outputFileName} \\\n") + sb.append(" --force \\\n") + + if (continueOnError) { + sb.append(" --continue-on-error \\\n") + } if (useExclusive) { sb.append(" --exclusive \\\n") @@ -293,7 +299,8 @@ class PatchSelectionViewModel( val patches = flagPatches.joinToString(" ") { "$flag \"$it\"" } val exclusivePart = if (useExclusive) " --exclusive" else "" val striplibsPart = if (striplibsArg != null) " --striplibs $striplibsArg" else "" - "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName$exclusivePart$striplibsPart $patches ${inputFile.name}" + val continueOnErrorPart = if (continueOnError) " --continue-on-error" else "" + "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --force$continueOnErrorPart$exclusivePart$striplibsPart $patches ${inputFile.name}" } } 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 e5e8326..8871a9b 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 @@ -57,6 +57,7 @@ class PatchingViewModel( options = config.patchOptions, exclusiveMode = config.useExclusiveMode, striplibs = config.striplibs, + continueOnError = config.continueOnError, onProgress = { message -> parseAndAddLog(message) } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index 35a879f..4c00950 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -317,13 +317,7 @@ class QuickPatchViewModel( options = emptyMap(), exclusiveMode = false, onProgress = { message -> - // Update status with current operation - if (message.contains("patch", ignoreCase = true) || - message.contains("applying", ignoreCase = true) || - message.contains("Applied", ignoreCase = true)) { - _uiState.value = _uiState.value.copy(statusMessage = message.take(60)) - } - // Parse progress + _uiState.value = _uiState.value.copy(statusMessage = message.take(60)) parseProgress(message) } ) diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 2902104..19d9830 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -78,6 +78,7 @@ class PatchService { options: Map = emptyMap(), exclusiveMode: Boolean = false, striplibs: List = emptyList(), + continueOnError: Boolean = false, onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { try { @@ -114,8 +115,10 @@ class PatchService { enabledPatches = enabledPatches.toSet(), disabledPatches = disabledPatches.toSet(), exclusiveMode = exclusiveMode, + forceCompatibility = true, patchOptions = patchOptions, architecturesToKeep = striplibs, + failOnError = !continueOnError, ) val engineResult = PatchEngine.patch(config, onProgress) From 5fb0e1ab81e0d9eee01357f19dd9e6699b2e5600 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:53:12 +0530 Subject: [PATCH 23/23] Minor Fixes and new theme added fixes from other branches and new mini theme --- .../kotlin/app/morphe/engine/PatchEngine.kt | 14 +++++++------- .../gui/ui/components/SettingsDialog.kt | 1 + .../morphe/gui/ui/screens/home/HomeScreen.kt | 2 +- .../gui/ui/screens/quick/QuickPatchScreen.kt | 2 +- .../kotlin/app/morphe/gui/ui/theme/Theme.kt | 19 +++++++++++++++++++ 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index 5a319a8..19b474e 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -280,13 +280,7 @@ object PatchEngine { patches.forEach patchLoop@{ patch -> val patchName = patch.name ?: return@patchLoop - // Check if explicitly disabled - if (patchName in disabledPatches) { - onProgress("Skipping disabled: $patchName") - return@patchLoop - } - - // Check package compatibility + // Check package compatibility first to avoid duplicate logs for multi-app patches. patch.compatiblePackages?.let { packages -> val matchingPkg = packages.singleOrNull { (name, _) -> name == packageName } if (matchingPkg == null) { @@ -307,6 +301,12 @@ object PatchEngine { } } + // Check if explicitly disabled + if (patchName in disabledPatches) { + onProgress("Skipping disabled: $patchName") + return@patchLoop + } + val isManuallyEnabled = patchName in enabledPatches val isEnabledByDefault = !exclusiveMode && patch.use 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 a03ba7a..6aab373 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -316,6 +316,7 @@ private fun ThemePreference.toDisplayName(): String { return when (this) { ThemePreference.LIGHT -> "Light" ThemePreference.DARK -> "Dark" + ThemePreference.AMOLED -> "AMOLED" ThemePreference.SYSTEM -> "System" } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index c7e879e..8d6fe8b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -518,7 +518,7 @@ private fun VersionWarningDialog( private fun BrandingSection(isCompact: Boolean = false) { val themeState = LocalThemeState.current val isDark = when (themeState.current) { - ThemePreference.DARK -> true + ThemePreference.DARK, ThemePreference.AMOLED -> true ThemePreference.LIGHT -> false ThemePreference.SYSTEM -> isSystemInDarkTheme() } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index ff59acc..45478be 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -142,7 +142,7 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { Spacer(modifier = Modifier.height(8.dp)) val themeState = LocalThemeState.current val isDark = when (themeState.current) { - ThemePreference.DARK -> true + ThemePreference.DARK, ThemePreference.AMOLED -> true ThemePreference.LIGHT -> false ThemePreference.SYSTEM -> isSystemInDarkTheme() } diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt index f980d43..3109a73 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt @@ -36,6 +36,23 @@ private val MorpheDarkColorScheme = darkColorScheme( onError = Color.Black ) +private val MorpheAmoledColorScheme = darkColorScheme( + primary = MorpheColors.Blue, + secondary = MorpheColors.Teal, + tertiary = MorpheColors.Cyan, + background = Color.Black, + surface = Color(0xFF0A0A0A), + surfaceVariant = Color(0xFF1A1A1A), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.Black, + onBackground = MorpheColors.TextLight, + onSurface = MorpheColors.TextLight, + onSurfaceVariant = Color(0xFFB0B0B0), + error = Color(0xFFCF6679), + onError = Color.Black +) + private val MorpheLightColorScheme = lightColorScheme( primary = MorpheColors.Blue, secondary = MorpheColors.Teal, @@ -56,6 +73,7 @@ private val MorpheLightColorScheme = lightColorScheme( enum class ThemePreference { LIGHT, DARK, + AMOLED, SYSTEM } @@ -66,6 +84,7 @@ fun MorpheTheme( ) { val colorScheme = when (themePreference) { ThemePreference.DARK -> MorpheDarkColorScheme + ThemePreference.AMOLED -> MorpheAmoledColorScheme ThemePreference.LIGHT -> MorpheLightColorScheme ThemePreference.SYSTEM -> { if (isSystemInDarkTheme()) MorpheDarkColorScheme else MorpheLightColorScheme