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