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/.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 diff --git a/build.gradle.kts b/build.gradle.kts index 7fb4ed5..fc79f08 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,59 @@ 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 --------------------------------------------------- + // 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) + implementation(compose.materialIconsExtended) + + // -- Async / Serialization --------------------------------------------- + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.kotlinx.serialization.json) -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } -} + // -- 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,9 +144,15 @@ 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", @@ -94,7 +162,25 @@ tasks { 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() + } + + distTar { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } + + distZip { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } publish { @@ -102,6 +188,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/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 ead101f..1ddd4a2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,16 @@ +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" // Include morphe-patcher and morphe-library as composite builds if they exist locally diff --git a/src/main/composeResources/drawable/morphe_dark.svg b/src/main/composeResources/drawable/morphe_dark.svg new file mode 100644 index 0000000..96ce4ec --- /dev/null +++ b/src/main/composeResources/drawable/morphe_dark.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + 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/composeResources/drawable/morphe_logo.png b/src/main/composeResources/drawable/morphe_logo.png new file mode 100755 index 0000000..1c211b7 Binary files /dev/null and b/src/main/composeResources/drawable/morphe_logo.png differ 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 01f481c..f6c8a83 100644 --- a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt @@ -8,7 +8,7 @@ import picocli.CommandLine.IVersionProvider import java.util.Properties import kotlin.system.exitProcess -fun main(args: Array) { +fun cliMain(args: Array) { Logger.setDefault() val exitCode = CommandLine(MainCommand).execute(*args) exitProcess(exitCode) @@ -41,4 +41,4 @@ private object CLIVersionProvider : IVersionProvider { UtilityCommand::class, ] ) -private object MainCommand +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 56526e8..d454435 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -5,7 +5,7 @@ import app.morphe.cli.command.model.PatchingResult import app.morphe.cli.command.model.PatchingStep import app.morphe.cli.command.model.addStepResult import app.morphe.cli.command.model.toSerializablePatch -import app.morphe.gui.util.ApkLibraryStripper +import app.morphe.engine.ApkLibraryStripper import app.morphe.library.ApkUtils import app.morphe.library.ApkUtils.applyTo import app.morphe.library.installation.installer.* 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..19b474e --- /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 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) { + 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 + } + } + + // Check if explicitly disabled + if (patchName in disabledPatches) { + onProgress("Skipping disabled: $patchName") + 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/App.kt b/src/main/kotlin/app/morphe/gui/App.kt new file mode 100644 index 0000000..8ff6286 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -0,0 +1,135 @@ +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.DeviceMonitor +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 + ) + + // Start/stop DeviceMonitor with app lifecycle + DisposableEffect(Unit) { + DeviceMonitor.startMonitoring() + onDispose { + DeviceMonitor.stopMonitoring() + } + } + + MorpheTheme(themePreference = themePreference) { + CompositionLocalProvider( + LocalThemeState provides themeState, + LocalModeState provides modeState + ) { + 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 + 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..1891611 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -0,0 +1,116 @@ +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() } + + // 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", + 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..ba11011 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -0,0 +1,45 @@ +package app.morphe.gui.data.constants + +/** + * Centralized configuration for supported apps. + * This file is massively outdated. Could be used for other things in the future but kinda useless now. + */ +object AppConstants { + + // ==================== APP INFO ==================== + 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" + } + + // ==================== YOUTUBE MUSIC ==================== + object YouTubeMusic { + const val DISPLAY_NAME = "YouTube Music" + const val PACKAGE_NAME = "com.google.android.apps.youtube.music" + } + + // ==================== REDDIT ==================== + object Reddit { + const val DISPLAY_NAME = "Reddit" + const val PACKAGE_NAME = "com.reddit.frontpage" + } + + /** + * List of all supported package names for quick lookup. + */ + val SUPPORTED_PACKAGES = listOf( + YouTube.PACKAGE_NAME, + YouTubeMusic.PACKAGE_NAME, + Reddit.PACKAGE_NAME + ) + + // 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/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..ea413a7 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -0,0 +1,85 @@ +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, + val striplibs: List = emptyList(), + val continueOnError: 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..e4a3b48 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt @@ -0,0 +1,69 @@ +package app.morphe.gui.data.model + +import app.morphe.gui.util.DownloadUrlResolver + +/** + * 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 apkDownloadUrl: 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 a web download URL for a package name and version. + */ + fun getDownloadUrl(packageName: String, version: String?): String? { + if (version == null) return null + return DownloadUrlResolver.getWebSearchDownloadLink(packageName, version) + } + + /** + * 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..c73baca --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -0,0 +1,216 @@ +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" + 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. Returns cached result if still fresh. + * @param forceRefresh bypass the in-memory cache + */ + 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) { + 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") + cachedReleases = releases + cacheTimestamp = System.currentTimeMillis() + 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) + // 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) + } + } + } + + /** + * 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 { + cachedReleases = null + cacheTimestamp = 0L + 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}") + } + } + 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/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt new file mode 100644 index 0000000..87c7f57 --- /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(), params.get(), params.get(), get(), get()) } + factory { params -> PatchingViewModel(params.get(), get(), get()) } +} 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/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..b5f70bd --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -0,0 +1,103 @@ +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 +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 +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 + } + } + + 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, + modifier = Modifier.padding(8.dp) + ) + } + + if (showSettingsDialog) { + SettingsDialog( + currentTheme = themeState.current, + onThemeChange = { themeState.onChange(it) }, + autoCleanupTempFiles = autoCleanupTempFiles, + onAutoCleanupChange = { enabled -> + autoCleanupTempFiles = enabled + scope.launch { + configRepository.setAutoCleanupTempFiles(enabled) + } + }, + useExpertMode = !modeState.isSimplified, + onExpertModeChange = { enabled -> + modeState.onChange(!enabled) + }, + onDismiss = { showSettingsDialog = false }, + allowCacheClear = allowCacheClear + ) + } +} + +/** + * 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 new file mode 100644 index 0000000..6aab373 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -0,0 +1,372 @@ +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 +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.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 +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, + useExpertMode: Boolean, + onExpertModeChange: (Boolean) -> Unit, + onDismiss: () -> Unit, + allowCacheClear: Boolean = true +) { + var showClearCacheConfirm by remember { mutableStateOf(false) } + var cacheCleared by remember { mutableStateOf(false) } + var cacheClearFailed 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 -> + 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 + ) + } + } + } + + HorizontalDivider() + + // Expert mode setting + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Expert mode", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Full control over patch selection and configuration", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = useExpertMode, + onCheckedChange = onExpertModeChange, + 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 = 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( + 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" + cacheClearFailed -> "Clear Cache Failed (files in use)" + else -> "Clear Cache" + } + ) + } + + // Cache info + val cacheSize = calculateCacheSize() + Text( + text = "Cache: $cacheSize (Patches + Logs)", + 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 = { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(8.dp) + ) { + Text( + "Close", + color = MaterialTheme.colorScheme.error + ) + } + } + ) + + // Clear cache confirmation dialog + if (showClearCacheConfirm) { + AlertDialog( + onDismissRequest = { showClearCacheConfirm = false }, + shape = RoundedCornerShape(16.dp), + title = { Text("Clear Cache?") }, + text = { + Text("This will delete downloaded patch files and log files. Patches will be re-downloaded when needed.") + }, + confirmButton = { + Button( + onClick = { + val success = clearAllCache() + cacheCleared = success + cacheClearFailed = !success + 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.AMOLED -> "AMOLED" + ThemePreference.SYSTEM -> "System" + } +} + +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 { + 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()) + } catch (e: Exception) { + failedCount++ + 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)") + 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/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt new file mode 100644 index 0000000..8d6fe8b --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -0,0 +1,1042 @@ +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 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 +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 +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.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 +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 + +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) }, + enabled = !uiState.isAnalyzing + ) { + 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, + packageName = uiState.apkInfo!!.packageName, + apkArchitectures = uiState.apkInfo!!.architectures + )) + } + }, + onDismiss = { showVersionWarningDialog = false } + ) + } + + val scrollState = rememberScrollState() + + Box(modifier = Modifier.fillMaxSize()) { + // 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 + ) { + // 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)) + + 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, + packageName = info.packageName, + apkArchitectures = info.architectures + )) + } + } + } + ) + } + + // 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)) + } + } + + // Top bar (device indicator + settings) in top-right corner + TopBarRow( + 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 +) { + 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 + ) + } + } +} + +@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) { + val themeState = LocalThemeState.current + val isDark = when (themeState.current) { + ThemePreference.DARK, ThemePreference.AMOLED -> true + ThemePreference.LIGHT -> false + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + Image( + 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) + ) +} + +@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 and .apkm files", + fontSize = if (isCompact) 11.sp else 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } +} + +@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, + 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.", + 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 = 700.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 downloadUrl = supportedApp.apkDownloadUrl + + 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 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 (downloadUrl != null) { + val uriHandler = LocalUriHandler.current + OutlinedButton( + onClick = { + openUrlAndFollowRedirects(downloadUrl) { urlResolved -> + uriHandler.openUri(urlResolved) + } + }, + 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 = "Download original APK", + 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 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().let { it.endsWith(".apk") || it.endsWith(".apkm") } } + 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..244844b --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -0,0 +1,488 @@ +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 = "Please drop a valid .apk or .apkm 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 or .apkm 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? { + // 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(apkToParse).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 from dynamic patches data (no hardcoded fallback) + val suggestedVersion = dynamicSupportedApp?.recommendedVersion + + // 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 + // For .apkm files, scan the original bundle (splits contain the native libs, not base.apk) + val architectures = extractArchitectures(if (isApkm) file else apkToParse) + + // 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)") + + ApkInfo( + fileName = file.name, + filePath = file.absolutePath, + fileSize = file.length(), + formattedSize = formatFileSize(file.length()), + appName = appName, + 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 + } finally { + if (isApkm) apkToParse.delete() + } + } + + /** + * 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 = 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 + } + .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.toList().ifEmpty { + listOf("universal") + } + } + } catch (e: Exception) { + Logger.warn("Could not extract architectures: ${e.message}") + emptyList() + } + } + + // 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 { + 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 +} + +data class ApkInfo( + val fileName: String, + val filePath: String, + val fileSize: Long, + val formattedSize: String, + val appName: String, + 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..cdd794c --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt @@ -0,0 +1,387 @@ +package app.morphe.gui.ui.screens.home.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.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.gui.ui.screens.home.ApkInfo +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) + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + 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..489bc91 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/FullScreenDropZone.kt @@ -0,0 +1,76 @@ +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, + enabled: Boolean = true, + 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) + if (!enabled) return 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..3d6d725 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -0,0 +1,823 @@ +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 +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.PlaylistRemove +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.TopBarRow +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. + */ +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, packageName, apkArchitectures) + } + 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() + } + ) + } + + // State for command preview + var cleanMode by remember { mutableStateOf(false) } + var showCommandPreview by remember { mutableStateOf(false) } + var continueOnError by remember { mutableStateOf(false) } + + 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() + } + }, + 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 & continue-on-error 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(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)) + + TopBarRow(allowCacheClear = false) + + Spacer(Modifier.width(12.dp)) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Command preview - collapsible via top bar button + if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { + val commandPreview = remember(uiState.selectedPatches, uiState.selectedArchitectures, cleanMode, continueOnError) { + viewModel.getCommandPreview(cleanMode, continueOnError) + } + 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 + SearchBar( + query = uiState.searchQuery, + onQueryChange = { viewModel.setSearchQuery(it) }, + showOnlySelected = uiState.showOnlySelected, + onShowOnlySelectedChange = { viewModel.setShowOnlySelected(it) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // Info card about default-disabled patches + val defaultDisabledCount = remember(uiState.allPatches) { + viewModel.getDefaultDisabledCount() + } + var infoDismissed by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = defaultDisabledCount > 0 && !infoDismissed && !uiState.isLoading, + enter = expandVertically(), + exit = shrinkVertically() + ) { + DefaultDisabledInfoCard( + count = defaultDisabledCount, + onDismiss = { infoDismissed = 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) + ) { + // 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 } + ) { 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(continueOnError) + 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() + .clip(RoundedCornerShape(12.dp)) + .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 = null, + 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 if patch has any + if (patch.options.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + 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) + ) + } + } + } + } + } + } + } +} + +@Composable +private fun DefaultDisabledInfoCard( + count: Int, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MorpheColors.Blue.copy(alpha = 0.08f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MorpheColors.Blue, + modifier = Modifier.size(18.dp) + ) + Text( + 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, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + } + } +} + +/** + * Terminal-style command preview showing the CLI command that will be executed. + */ +@Composable +private fun CommandPreview( + command: String, + cleanMode: Boolean, + onToggleMode: () -> Unit, + onCopy: () -> Unit, + modifier: Modifier = Modifier +) { + val terminalBackground = Color(0xFF1E1E1E) + val terminalGreen = Color(0xFF6A9955) + 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 and controls + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Left side - icon and title + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + tint = terminalGreen, + modifier = Modifier.size(14.dp) + ) + Text( + text = "Command Preview", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = terminalGreen + ) + } + + // 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 = 12.sp, + fontWeight = FontWeight.Bold, + color = if (showCopied) terminalGreen else terminalDim + ) + } + } + + // Mode toggle + 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) + ) + } + } + } + + 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 + ) + } + } + } +} + +@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 new file mode 100644 index 0000000..74b710b --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -0,0 +1,364 @@ +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.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 packageName: String, + private val apkArchitectures: List, + 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( + apkArchitectures = apkArchitectures, + selectedArchitectures = apkArchitectures.toSet() + )) + 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 + } + + // Load patches using PatchService (direct library call) + val patchesResult = patchService.listPatches(actualPatchesFilePath, packageName.ifEmpty { null }) + + 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") + + // 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 = defaultSelected + ) + }, + 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) + } + + 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). + */ + fun getDefaultDisabledCount(): Int { + return _uiState.value.allPatches.count { !it.isEnabled } + } + + 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(" ", "-") + val outputDir = File(inputFile.parentFile, appFolderName) + outputDir.mkdirs() + + // Extract version from APK filename and patches version for output name + val version = extractVersionFromFilename(inputFile.name) ?: "patched" + 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 + 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 } + + // Only set riplibs if user deselected any architecture (keeps = selected ones) + val striplibs = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { + _uiState.value.selectedArchitectures.toList() + } else { + emptyList() + } + + return PatchConfig( + inputApkPath = apkPath, + outputApkPath = outputPath, + patchesFilePath = actualPatchesFilePath, + enabledPatches = selectedPatchNames, + disabledPatches = disabledPatchNames, + useExclusiveMode = true, + striplibs = striplibs, + continueOnError = continueOnError + ) + } + + 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 + } + } + + 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 + + /** + * 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, continueOnError: Boolean = false): String { + val inputFile = File(apkPath) + val patchesFile = File(actualPatchesFilePath) + val appFolderName = apkName.replace(" ", "-") + val version = extractVersionFromFilename(inputFile.name) ?: "patched" + 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(",") + } else { + null + } + + 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(" --force \\\n") + + if (continueOnError) { + sb.append(" --continue-on-error \\\n") + } + + if (useExclusive) { + sb.append(" --exclusive \\\n") + } + + if (striplibsArg != null) { + sb.append(" --striplibs $striplibsArg \\\n") + } + + 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(" \\") + } + sb.append("\n") + } + + sb.append(" ${inputFile.name}") + sb.toString() + } else { + 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 "" + 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}" + } + } + + /** + * 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) + } + +} + +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 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/patches/PatchesScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt new file mode 100644 index 0000000..3289d04 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt @@ -0,0 +1,631 @@ +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.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.* +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.DeviceIndicator +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 patch version to apply. + * This is the screen that selects the patches.mpp file + */ +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 = { + DeviceIndicator() + 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) + } + + 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) + ) { + 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 = "Published: ${formatDate(release.publishedAt)}", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + + if (hasNotes) { + Spacer(modifier = Modifier.height(4.dp)) + Surface( + color = MorpheColors.Blue.copy(alpha = 0.1f), + shape = RoundedCornerShape(6.dp), + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable { isExpanded = !isExpanded } + ) { + 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) + ) + } + } + } + } + + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Selected", + tint = MorpheColors.Blue, + modifier = Modifier.size(24.dp) + ) + } + } + + // 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) + ) + } + } + } +} + +/** + * 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, + 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 { + // 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] + 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 + } + } 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..be0d351 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt @@ -0,0 +1,460 @@ +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.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 +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") + } + } + TopBarRow(allowCacheClear = false) + Spacer(Modifier.width(12.dp)) + }, + 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..8871a9b --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -0,0 +1,238 @@ +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, + striplibs = config.striplibs, + continueOnError = config.continueOnError, + 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..45478be --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -0,0 +1,1020 @@ +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 androidx.compose.foundation.isSystemInDarkTheme +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 +import app.morphe.gui.ui.theme.LocalThemeState +import app.morphe.gui.ui.theme.ThemePreference +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.TopBarRow +import app.morphe.gui.ui.theme.MorpheColors +import androidx.compose.runtime.rememberCoroutineScope +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 +import java.awt.Desktop +import java.awt.datatransfer.DataFlavor +import java.io.File +import java.awt.FileDialog +import java.awt.Frame + +/** + * 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) || it.name.endsWith(".apkm", 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 + ) + ) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Branding + Spacer(modifier = Modifier.height(8.dp)) + val themeState = LocalThemeState.current + val isDark = when (themeState.current) { + ThemePreference.DARK, ThemePreference.AMOLED -> true + ThemePreference.LIGHT -> false + ThemePreference.SYSTEM -> isSystemInDarkTheme() + } + Image( + painter = painterResource(if (isDark) Res.drawable.morphe_dark else Res.drawable.morphe_light), + contentDescription = "Morphe Logo", + modifier = Modifier.height(48.dp) + ) + Text( + text = "Quick Patch", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.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, + 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, + loadError = uiState.patchLoadError, + patchesVersion = uiState.patchesVersion, + onOpenUrl = { url -> + openUrlAndFollowRedirects(url) { urlResolved -> + uriHandler.openUri(urlResolved) + } + }, + onRetry = { viewModel.retryLoadPatches() } + ) + } + } + + // Top bar (device indicator + settings) in top-right corner + TopBarRow( + modifier = Modifier + .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) + } + } + } + } +} + +@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: first letter of display name + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Text( + text = apkInfo.displayName.first().toString(), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = MorpheColors.Blue + ) + } + + 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, + statusMessage: String, + onCancel: () -> Unit +) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + strokeWidth = 4.dp, + color = MorpheColors.Teal + ) + + 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 scope = rememberCoroutineScope() + val adbManager = remember { AdbManager() } + val monitorState by DeviceMonitor.state.collectAsState() + var isInstalling by remember { mutableStateOf(false) } + var installError by remember { mutableStateOf(null) } + var installSuccess by remember { mutableStateOf(false) } + + 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 (monitorState.isAdbAvailable == true) { + Spacer(modifier = Modifier.height(16.dp)) + + val readyDevices = monitorState.devices.filter { it.isReady } + val selectedDevice = monitorState.selectedDevice + + 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 + ) + } + + installError?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun SupportedAppsRow( + supportedApps: List, + isLoading: Boolean, + loadError: String? = null, + patchesVersion: String?, + onOpenUrl: (String) -> Unit, + onRetry: () -> Unit = {} +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Download original APK", + 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 (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( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + supportedApps.forEach { app -> + val url = app.apkDownloadUrl + 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 + ) { + 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 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 + } + + val directory = fileDialog.directory + val file = fileDialog.file + + return if (directory != null && file != null) { + File(directory, file) + } 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..4c00950 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -0,0 +1,466 @@ +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.FileUtils +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, patchLoadError = null) + + try { + // 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, patchLoadError = "Could not fetch releases. Check your internet connection.") + return@launch + } + + // 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, patchLoadError = "No suitable release found") + 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, patchLoadError = "Could not download patches") + 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, patchLoadError = "Could not load patches") + 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, + patchLoadError = null + ) + } catch (e: Exception) { + Logger.error("Quick mode: Failed to load patches", e) + _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. + */ + 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) || 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(apkToParse).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 + + // Version check + val isRecommendedVersion = recommendedVersion != null && versionName == recommendedVersion + val versionWarning = if (!isRecommendedVersion && recommendedVersion != null) { + "Version $versionName may have compatibility issues. Recommended: $recommendedVersion" + } else null + + // TODO: Re-enable when checksums are provided via .mpp files + val checksumStatus = ChecksumStatus.NotConfigured + + 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 + } finally { + if (isApkm) apkToParse.delete() + } + } + + // 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. + */ + 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 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) + // 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(), + disabledPatches = emptyList(), + options = emptyMap(), + exclusiveMode = false, + onProgress = { message -> + _uiState.value = _uiState.value.copy(statusMessage = message.take(60)) + 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( + // Preserve already-loaded patches data + isLoadingPatches = false, + supportedApps = cachedSupportedApps, + patchesVersion = _uiState.value.patchesVersion + ) + } + + /** + * 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, + val patchLoadError: 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..0409c25 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -0,0 +1,717 @@ +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.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 +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 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) } + 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") + } + } + + // Install function + fun installViaAdb() { + val device = monitorState.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 (monitorState.isAdbAvailable == true) { + AdbInstallSection( + devices = monitorState.devices, + selectedDevice = monitorState.selectedDevice, + isLoadingDevices = false, + isInstalling = isInstalling, + installProgress = installProgress, + installError = installError, + installSuccess = installSuccess, + onDeviceSelected = { DeviceMonitor.selectDevice(it) }, + onRefreshDevices = { }, + 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 (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 (monitorState.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)) + } + } + + // Top bar (device indicator + settings) in top-right corner + TopBarRow( + 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..3109a73 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt @@ -0,0 +1,98 @@ +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 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, + 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, + AMOLED, + SYSTEM +} + +@Composable +fun MorpheTheme( + themePreference: ThemePreference = ThemePreference.SYSTEM, + content: @Composable () -> Unit +) { + val colorScheme = when (themePreference) { + ThemePreference.DARK -> MorpheDarkColorScheme + ThemePreference.AMOLED -> MorpheAmoledColorScheme + 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..998f4c0 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -0,0 +1,381 @@ +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 and architecture + val deviceName = if (status == DeviceStatus.DEVICE) { + model ?: product ?: getDeviceName(adbPath, id) + } else { + model ?: product + } + + val architecture = if (status == DeviceStatus.DEVICE) { + getDeviceArchitecture(adbPath, id) + } else null + + AdbDevice(id, status, deviceName, architecture) + } 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 + } + } + + /** + * 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 { + 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, + val architecture: 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 + val arch = architecture?.let { " ($it)" } ?: "" + return when (status) { + DeviceStatus.DEVICE -> "$name$arch (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/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 + ) + } + ) + } +} 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..c0a6922 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/DownloadUrlResolver.kt @@ -0,0 +1,74 @@ +package app.morphe.gui.util + +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 { + + 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) + } + + handleResolvedUrl(result) + } + } + + fun resolveRedirects(url: String, maxRedirectsToFollow : Int = 5): String { + if (maxRedirectsToFollow <= 0) return url + + 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 + + val responseCode = connection.responseCode + if (responseCode in 300..399) { + val location = connection.getHeaderField("Location") + + if (location.isNullOrBlank()) { + Logger.info("Location tag is blank: ${connection.responseMessage}") + return url + } + + 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" + } + + if (!resolved.startsWith(MORPHE_API_URL)) { + return resolved + } + + return resolveRedirects(resolved, maxRedirectsToFollow - 1) + } + + //Log.d("Unexpected response code: $responseCode") + } catch (ex: SocketTimeoutException) { + Logger.info("Timeout while resolving search redirect: $ex") + } catch (ex: Exception) { + Logger.info("Exception while resolving search redirect: $ex") + } + + return url + } + +} 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..245b589 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -0,0 +1,173 @@ +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. + * 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 or APKM. + */ + fun isApkFile(file: File): Boolean { + 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 + } + } +} 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..19d9830 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -0,0 +1,192 @@ +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.patcher.patch.loadPatchesFromJar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +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") + + // 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 } + } + } else { + guiPatches + } + + 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) + } + } + + /** + * Execute patching operation with progress callbacks. + * Delegates to PatchEngine for the actual pipeline. + */ + suspend fun patch( + patchesFilePath: String, + inputApkPath: String, + outputApkPath: String, + enabledPatches: List = emptyList(), + disabledPatches: List = emptyList(), + options: Map = emptyMap(), + exclusiveMode: Boolean = false, + striplibs: List = emptyList(), + continueOnError: Boolean = false, + onProgress: (String) -> Unit = {} + ): Result = withContext(Dispatchers.IO) { + 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")) + } + + // Load patches (copy to temp to avoid Windows file lock) + onProgress("Loading patches...") + 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, + forceCompatibility = true, + patchOptions = patchOptions, + architecturesToKeep = striplibs, + failOnError = !continueOnError, + ) + + val engineResult = PatchEngine.patch(config, onProgress) + + Result.success(PatchResult( + success = engineResult.success, + outputPath = engineResult.outputPath, + appliedPatches = engineResult.appliedPatches, + failedPatches = engineResult.failedPatches.map { it.name }, + )) + } finally { + patchTempCopy.delete() + } + } catch (e: Exception) { + Logger.error("Patching failed", e) + Result.failure(e) + } + } + + /** + * 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..dc713a4 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt @@ -0,0 +1,69 @@ +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() + val recommendedVersion = SupportedApp.getRecommendedVersion(versionList) + SupportedApp( + packageName = packageName, + displayName = SupportedApp.getDisplayName(packageName), + supportedVersions = versionList, + recommendedVersion = recommendedVersion, + apkDownloadUrl = SupportedApp.getDownloadUrl(packageName, recommendedVersion) + ) + }.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 0000000..9cbef39 Binary files /dev/null and b/src/main/resources/morphe_logo.icns differ diff --git a/src/main/resources/morphe_logo.ico b/src/main/resources/morphe_logo.ico new file mode 100644 index 0000000..47e22f7 Binary files /dev/null and b/src/main/resources/morphe_logo.ico differ diff --git a/src/main/resources/morphe_logo.png b/src/main/resources/morphe_logo.png new file mode 100755 index 0000000..1c211b7 Binary files /dev/null and b/src/main/resources/morphe_logo.png differ