From 6163e9c29070e5013274792437d05f8ed2c20853 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:30:10 +0530 Subject: [PATCH 01/39] fix: CLI --purge command does not cleanup temp APK (#169) Moved our temp apk from tmp folder in our morphe-data folder to the temp folder used when patching. `--purge` should now properly clean up this file. --- src/main/kotlin/app/morphe/cli/command/PatchCommand.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 4134eb3..35d6d94 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -823,7 +823,7 @@ internal object PatchCommand : Callable { // region Save. - inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { + inputApk.copyTo(patcherTemporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { patchingResult.addStepResult( PatchingStep.REBUILDING, { From 712b330a062e43183487a8265ecd5d2130067f35 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 15 Jun 2026 08:02:01 +0000 Subject: [PATCH 02/39] chore: Release v1.9.2-dev.1 [skip ci] ## [1.9.2-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.1...v1.9.2-dev.1) (2026-06-15) ### Bug Fixes * CLI --purge command does not cleanup temp APK ([#169](https://github.com/MorpheApp/morphe-cli/issues/169)) ([6163e9c](https://github.com/MorpheApp/morphe-cli/commit/6163e9c29070e5013274792437d05f8ed2c20853)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0165733..db56899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [1.9.2-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.1...v1.9.2-dev.1) (2026-06-15) + + +### Bug Fixes + +* CLI --purge command does not cleanup temp APK ([#169](https://github.com/MorpheApp/morphe-cli/issues/169)) ([6163e9c](https://github.com/MorpheApp/morphe-cli/commit/6163e9c29070e5013274792437d05f8ed2c20853)) + ## [1.9.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0...v1.9.1) (2026-06-08) diff --git a/gradle.properties b/gradle.properties index 2893a41..4cba744 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.9.1 +version = 1.9.2-dev.1 From d865abdbbe2258cf24476add02a361b05f3fdf7e Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:50:05 +0530 Subject: [PATCH 03/39] feat: "your apps" section + settings and tools split + re-orderable patch sources + block play store updates for patched apps (#176) - Settings is now split into Settings and Tools for better UX. - New "Your Apps" section: every GUI patch is recorded (app, version, patches + sources/versions, date, integrity) in the shared engine store. This allows for easy updating of apps and also getting notified about app updates on startup. - Copied over the keystore converter from the manager. Now users can drop any keystore and we should be able to convert it to BKS and use it properly. - Patch sources can now be reordered either by dragging them or using the arrow keys. (Order only affects display order + the app-label tiebreak. It does not change which patches load first). - ADB installs now set a non-play installer source so the play store won't try to auto-update the patched apps. Auto-picks a popular store that isn't installed on the device. Always on (no toggle). !Important: "Your apps" section update requires the user to reinstall the app since we need to collect a bunch of info when an app gets installed. --------- Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> --- gradle.properties | 7 + gradle/libs.versions.toml | 2 +- .../app/morphe/cli/command/PatchCommand.kt | 37 +- .../kotlin/app/morphe/engine/MorpheData.kt | 10 + .../app/morphe/engine/PatchedAppStore.kt | 154 +++ .../morphe/engine/model/PatchedAppRecord.kt | 80 ++ .../app/morphe/engine/util/FileChecksum.kt | 43 + .../morphe/engine/util/JksKeyStoreParser.kt | 158 +++ .../engine/util/KeystoreConversionUtils.kt | 151 +++ .../morphe/engine/util/KeystoreImporter.kt | 136 +++ .../morphe/engine/util/SignatureIdentity.kt | 74 ++ src/main/kotlin/app/morphe/gui/GuiMain.kt | 5 +- .../app/morphe/gui/data/model/AppConfig.kt | 3 + .../kotlin/app/morphe/gui/data/model/Patch.kt | 14 +- .../gui/data/repository/ConfigRepository.kt | 24 + .../gui/data/repository/PatchSourceManager.kt | 10 + .../kotlin/app/morphe/gui/di/AppModule.kt | 7 +- .../morphe/gui/ui/components/ActionButton.kt | 84 ++ .../gui/ui/components/LicensesDialog.kt | 773 ++++++++++++ .../gui/ui/components/SettingsButton.kt | 9 +- .../gui/ui/components/SettingsDialog.kt | 1020 +--------------- .../ui/components/SourceManagementSheet.kt | 216 +++- .../morphe/gui/ui/components/ToolsButton.kt | 81 ++ .../morphe/gui/ui/components/ToolsDialog.kt | 286 +++++ .../morphe/gui/ui/screens/home/HomeScreen.kt | 912 ++++++-------- .../gui/ui/screens/home/HomeViewModel.kt | 518 ++++++++ .../home/components/SupportedAppListRow.kt | 82 +- .../screens/home/components/YourAppsPane.kt | 1047 +++++++++++++++++ .../screens/patches/PatchSelectionScreen.kt | 14 +- .../patches/PatchSelectionViewModel.kt | 90 +- .../gui/ui/screens/patches/PatchesScreen.kt | 5 +- .../ui/screens/patching/PatchingViewModel.kt | 52 +- .../gui/ui/screens/quick/QuickPatchScreen.kt | 1 + .../ui/screens/quick/QuickPatchViewModel.kt | 58 + .../gui/ui/screens/result/ResultScreen.kt | 362 +++--- .../kotlin/app/morphe/gui/util/AdbManager.kt | 163 ++- .../app/morphe/gui/util/ChecksumUtils.kt | 17 +- .../morphe/gui/util/EnabledSourcesLoader.kt | 9 + .../app/morphe/gui/util/PatchService.kt | 6 + .../app/morphe/engine/PatchedAppStoreTest.kt | 97 ++ 40 files changed, 5046 insertions(+), 1771 deletions(-) create mode 100644 src/main/kotlin/app/morphe/engine/PatchedAppStore.kt create mode 100644 src/main/kotlin/app/morphe/engine/model/PatchedAppRecord.kt create mode 100644 src/main/kotlin/app/morphe/engine/util/FileChecksum.kt create mode 100644 src/main/kotlin/app/morphe/engine/util/JksKeyStoreParser.kt create mode 100644 src/main/kotlin/app/morphe/engine/util/KeystoreConversionUtils.kt create mode 100644 src/main/kotlin/app/morphe/engine/util/KeystoreImporter.kt create mode 100644 src/main/kotlin/app/morphe/engine/util/SignatureIdentity.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/components/ActionButton.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/components/ToolsButton.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt create mode 100644 src/test/kotlin/app/morphe/engine/PatchedAppStoreTest.kt diff --git a/gradle.properties b/gradle.properties index 4cba744..bda22d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,10 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official version = 1.9.2-dev.1 + +# Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than +# the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit +# exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each +# (~4g total) fits comfortably on a 16GB machine. +org.gradle.jvmargs = -Xmx2g +kotlin.daemon.jvmargs = -Xmx2g diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e99be64..480721a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlin = "2.3.21" # CLI picocli = "4.7.7" arsclib = "a28c6fb2a7" -morphe-patcher = "1.5.2-dev.2" # TODO: Change to stable release +morphe-patcher = "1.5.2" morphe-library = "1.3.0" # Compose Desktop diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 35d6d94..4fa7747 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -212,13 +212,13 @@ internal object PatchCommand : Callable { description = ["Alias of the private key and certificate pair keystore entry."], showDefaultValue = ALWAYS, ) - private var keyStoreEntryAlias = PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS + private var keyStoreEntryAlias = DEFAULT_KEYSTORE_ALIAS @CommandLine.Option( names = ["--keystore-entry-password"], description = ["Password of the keystore entry."], ) - private var keyStoreEntryPassword = PatchEngine.Config.DEFAULT_KEYSTORE_PASSWORD + private var keyStoreEntryPassword = DEFAULT_KEYSTORE_PASSWORD @CommandLine.Option( names = ["--signer"], @@ -432,7 +432,38 @@ internal object PatchCommand : Callable { val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - val keystoreFilePath = keyStoreFilePath ?: MorpheData.defaultKeystoreFile + // Resolve and possibly convert the keystore. The patcher only loads BKS, + // but users frequently pass PKCS12 (Android Studio default) or JKS (older + // projects, URV exports). KeystoreImporter sniffs by magic bytes (not + // extension — URV ships the same bytes under multiple suffixes), short- + // circuits when already BKS so existing setups are zero-risk, and writes + // converted bytes to a separate file so the user's source is never mutated. + val keystoreFilePath = run { + val source = keyStoreFilePath ?: return@run MorpheData.defaultKeystoreFile + if (!source.exists()) return@run source // patcher will produce a clearer error + val importResult = app.morphe.engine.util.KeystoreImporter.ensureBks( + source = source, + convertedOutput = MorpheData.importedKeystoreFile, + alias = keyStoreEntryAlias, + password = keyStoreEntryPassword, + ) + when (importResult) { + is app.morphe.engine.util.KeystoreImporter.Result.AlreadyBks -> importResult.file + is app.morphe.engine.util.KeystoreImporter.Result.Converted -> { + logger.info( + "Converted ${importResult.sourceFormat.displayName} keystore → BKS: ${importResult.file.absolutePath}" + ) + importResult.file + } + is app.morphe.engine.util.KeystoreImporter.Result.Failed -> { + logger.severe("Keystore conversion failed: ${importResult.reason}") + importResult.cause?.let { logger.severe(it.stackTraceToString()) } + throw IllegalArgumentException( + "Could not use keystore '${source.absolutePath}': ${importResult.reason}" + ) + } + } + } val installer = if (deviceSerial != null) { val deviceSerial = deviceSerial!!.ifEmpty { null } diff --git a/src/main/kotlin/app/morphe/engine/MorpheData.kt b/src/main/kotlin/app/morphe/engine/MorpheData.kt index 4e6ea42..18fae74 100644 --- a/src/main/kotlin/app/morphe/engine/MorpheData.kt +++ b/src/main/kotlin/app/morphe/engine/MorpheData.kt @@ -84,6 +84,16 @@ object MorpheData { */ val defaultKeystoreFile: File get() = File(root, "morphe.keystore") + /** + * Destination for keystores the user imports in non-BKS formats + * (PKCS12 / JKS). The original source file is left untouched; we write + * the BKS-converted bytes here and point the user's keystore config + * at this path. Distinct from [defaultKeystoreFile] so the import flow + * doesn't clobber the auto-generated default — clearing the configured + * path reverts patching to that default. + */ + val importedKeystoreFile: File get() = File(root, "imported.keystore") + /** * Reason the primary (JAR-adjacent) location was rejected. Drives the * fallback log message so a user reporting "where's my cache?" can diff --git a/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt b/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt new file mode 100644 index 0000000..d83cd55 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.engine.model.PatchedAppRecord +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.logging.Logger + +/** + * Persistent store for [PatchedAppRecord]s — the patched-app history shared by + * the CLI and GUI (see `patched-app-recall-plan.md`). + * + * Lives in the engine layer (not GUI) so **both** pipelines can record into one + * file: `morphe-data/patched-apps.json`. Reads are cached in memory; writes go + * through a [Mutex] (in-process safety) and are atomic (temp file + move) so a + * crash mid-write can't corrupt the history. + * + * The [file] is injectable for testing; production code uses the default under + * [MorpheData.root]. + */ +class PatchedAppStore( + private val file: File = File(MorpheData.root, FILE_NAME), +) { + private val logger = Logger.getLogger(PatchedAppStore::class.java.name) + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val mutex = Mutex() + private var cache: List? = null + + private val _changes = MutableSharedFlow(extraBufferCapacity = 1) + + /** + * Emits once whenever the history changes (upsert/delete). Observe this to + * refresh UI the moment a patch completes or a record is forgotten, instead + * of waiting for a screen to be re-entered. + */ + val changes: SharedFlow = _changes.asSharedFlow() + + /** All records, most-recently-patched first. */ + suspend fun getAll(): List = withContext(Dispatchers.IO) { + mutex.withLock { load() } + } + + /** The record for [packageName], or null if the app was never patched. */ + suspend fun get(packageName: String): PatchedAppRecord? = withContext(Dispatchers.IO) { + mutex.withLock { load().firstOrNull { it.packageName == packageName } } + } + + /** Insert [record], replacing any existing record for the same package. */ + suspend fun upsert(record: PatchedAppRecord): Unit = withContext(Dispatchers.IO) { + mutex.withLock { + val others = load().filterNot { it.packageName == record.packageName } + persist(listOf(record) + others) + } + _changes.tryEmit(Unit) + } + + /** Remove the record for [packageName] if present. */ + suspend fun delete(packageName: String): Unit = withContext(Dispatchers.IO) { + val changed = mutex.withLock { + val remaining = load().filterNot { it.packageName == packageName } + if (remaining.size != cache?.size) { + persist(remaining) + true + } else { + false + } + } + if (changed) _changes.tryEmit(Unit) + } + + // --- internals (call only while holding [mutex]) --- + + private fun load(): List { + cache?.let { return it } + val records = if (file.exists()) { + try { + json.decodeFromString(file.readText()).records + } catch (e: Exception) { + // Corrupt/incompatible file: don't lose the user's ability to keep + // patching — start fresh in memory and let the next write heal it. + logger.warning("Could not read patched-app history (${e.message}); starting empty") + emptyList() + } + } else { + emptyList() + } + cache = records + return records + } + + private fun persist(records: List) { + try { + file.parentFile?.mkdirs() + val content = json.encodeToString(StoreFile.serializer(), StoreFile(SCHEMA_VERSION, records)) + // Atomic write: write a temp file, then move it over the target so a + // crash never leaves a half-written history behind. + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(content) + try { + Files.move( + tmp.toPath(), file.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE, + ) + } catch (_: Exception) { + // ATOMIC_MOVE isn't supported on every filesystem — fall back. + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + cache = records + } catch (e: Exception) { + logger.warning("Failed to write patched-app history: ${e.message}") + } + } + + @Serializable + private data class StoreFile( + val version: Int = SCHEMA_VERSION, + val records: List = emptyList(), + ) + + companion object { + const val FILE_NAME = "patched-apps.json" + + /** Bump when the on-disk shape changes incompatibly; add a migration in [load]. */ + const val SCHEMA_VERSION = 1 + + /** + * Process-wide shared instance — use this everywhere in production so the + * in-memory cache is coherent (two instances in one process could race + * and drop each other's records). Tests construct their own with a + * custom [file]. + */ + val shared: PatchedAppStore by lazy { PatchedAppStore() } + } +} diff --git a/src/main/kotlin/app/morphe/engine/model/PatchedAppRecord.kt b/src/main/kotlin/app/morphe/engine/model/PatchedAppRecord.kt new file mode 100644 index 0000000..44c6c01 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/model/PatchedAppRecord.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.model + +import kotlinx.serialization.Serializable + +/** + * A record of one app the user has patched — the "recall" data behind the + * patched-app history (see `patched-app-recall-plan.md`). + * + * Written by both the CLI and the GUI on a successful patch (the store lives in + * the shared engine layer so the two pipelines feed one history), and read back + * to surface "you've patched this before / an update is available" UX. + * + * Keyed by [packageName] — re-patching the same app overwrites its record. + * For apps whose package is renamed by a patch, this is the **original** + * (pre-patch) package name, for consistency with the rest of our schema. + */ +@Serializable +data class PatchedAppRecord( + /** Original (pre-patch) package name. Primary key; matches the supported-apps list. */ + val packageName: String, + /** + * Post-patch package as it installs on a device — differs from [packageName] + * when a rename patch was applied (e.g. `com.google.android.youtube` → + * `app.morphe.android.youtube`). Read from the output APK's manifest at patch + * time. Null/blank = no rename (same as [packageName]). Mirrors Manager's + * `currentPackageName`. + */ + val currentPackageName: String? = null, + /** Human-readable app name shown in UI. */ + val displayName: String, + /** APK version at patch time. */ + val apkVersion: String, + + /** Input APK path used. May no longer exist on disk. */ + val inputApkPath: String, + /** Output APK path we wrote. Its existence is the "is it still here" signal. */ + val outputApkPath: String, + + /** + * Baseline integrity fingerprint of the output APK, captured at patch time. + * Lets us later detect when the patched APK was changed outside Morphe + * (hash mismatch) vs untouched. Null only for records written before this + * field existed, or if hashing failed. See `patched-app-recall-plan.md` (Phase 6). + */ + val outputApkSha256: String? = null, + /** Size in bytes of the output APK at patch time. */ + val outputApkSize: Long = 0, + + /** Bundle source id → set of enabled patch unique ids (same shape as the selection state). */ + val patchSelectionByBundle: Map> = emptyMap(), + /** "patchName.optionKey" → raw value string (deserialized by patch type at apply time). */ + val patchOptionValues: Map = emptyMap(), + + /** + * Which sources + versions were enabled at patch time. "Update available" + * detection compares current source versions against this snapshot. + */ + val sourcesSnapshot: List = emptyList(), + + /** Epoch millis of when the patch completed. */ + val patchedAt: Long, + /** The Morphe (CLI/GUI) version that produced the patch — handy for debugging. */ + val patchedWithMorpheVersion: String, +) { + /** Package actually installed on a device (post-rename if applicable). */ + val installedPackageName: String get() = currentPackageName?.takeIf { it.isNotBlank() } ?: packageName + + @Serializable + data class PatchedSourceSnapshot( + val sourceId: String, + val sourceName: String, + /** `.mpp` release version, e.g. `v1.5.0`. */ + val version: String, + ) +} diff --git a/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt b/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt new file mode 100644 index 0000000..999095d --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File +import java.security.MessageDigest + +/** + * Canonical file-hashing used across the engine, CLI, and GUI. Lives in the + * engine layer so non-GUI callers (CLI patch recording, patched-app integrity + * checks) don't have to depend on `gui.util`. The GUI's `ChecksumUtils` + * delegates here so there's one implementation. + */ +object FileChecksum { + + /** Lowercase-hex SHA-256 of [file]. Streams the file — safe for large APKs. */ + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + file.inputStream().use { stream -> + var read: Int + while (stream.read(buffer).also { read = it } != -1) { + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + /** + * Baseline fingerprint `(sha256, sizeBytes)` of the file at [path], or + * `(null, 0)` if it doesn't exist or hashing fails. Blocking IO — call off + * the main thread. Used to record patched-APK integrity at patch time. + */ + fun fingerprintOrNull(path: String): Pair = try { + val file = File(path) + if (file.exists()) sha256(file) to file.length() else null to 0L + } catch (_: Exception) { + null to 0L + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/JksKeyStoreParser.kt b/src/main/kotlin/app/morphe/engine/util/JksKeyStoreParser.kt new file mode 100644 index 0000000..9dc2278 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/JksKeyStoreParser.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + * + * Ported from morphe-manager — preserved verbatim so a key converted by + * Morphe Manager and one converted here produce byte-identical output. + * Manager carries the URV-compat learnings that this parser encodes. + */ + +package app.morphe.engine.util + +import java.io.DataInputStream +import java.io.InputStream +import java.security.MessageDigest + +/** + * Minimal JKS keystore parser that extracts private key + certificate chain entries. + * + * Android's security provider does not include a JKS KeyStoreSpi, so Manager parses + * the format manually. On desktop JVMs `KeyStore.getInstance("JKS")` typically works, + * but we keep this parser as the fallback for URV-style JKS variants that the stock + * SPI has historically choked on. The key protection algorithm is Sun's proprietary + * XOR scheme: + * keystream = SHA1(pwd || salt) || SHA1(pwd || SHA1(pwd || salt)) || ... + * plaintext = ciphertext XOR keystream + * integrity = SHA1(pwd || plaintext) (last 20 bytes of the protected-key blob) + * + * Reference: sun.security.provider.KeyProtector (OpenJDK source) + */ +object JksKeyStoreParser { + + private const val JKS_MAGIC = 0xFEEDFEED.toInt() + private const val TAG_PRIVATE_KEY = 1 + private const val TAG_TRUSTED_CERT = 2 + + data class JksEntry( + val alias: String, + val privateKeyDer: List, + val certificatesDer: List> + ) + + /** + * Parses a JKS [inputStream] and returns all private key entries. + * [password] is used both to decrypt each key entry and to verify keystore integrity. + * + * @throws IllegalArgumentException if the stream is not a valid JKS keystore. + * @throws SecurityException if key decryption fails (wrong password). + */ + fun parse(inputStream: InputStream, password: String): List { + val dis = DataInputStream(inputStream.buffered()) + + val magic = dis.readInt() + require(magic == JKS_MAGIC) { "Not a JKS keystore (magic=0x${magic.toString(16)})" } + + val version = dis.readInt() + require(version == 1 || version == 2) { "Unsupported JKS version: $version" } + + val count = dis.readInt() + val entries = mutableListOf() + + repeat(count) { + when (dis.readInt()) { + TAG_PRIVATE_KEY -> entries.add(readPrivateKeyEntry(dis, password)) + TAG_TRUSTED_CERT -> skipTrustedCertEntry(dis) + else -> error("Unknown JKS entry tag") + } + } + + return entries + } + + private fun readPrivateKeyEntry(dis: DataInputStream, password: String): JksEntry { + val alias = dis.readUTF() + dis.readLong() // timestamp - unused + + val protectedKeyLen = dis.readInt() + val protectedKey = ByteArray(protectedKeyLen).also { dis.readFully(it) } + val privateKeyDer = decryptKey(protectedKey, password).toList() + + val chainCount = dis.readInt() + val certs = (0 until chainCount).map { + dis.readUTF() + val certLen = dis.readInt() + ByteArray(certLen).also { dis.readFully(it) }.toList() + } + + return JksEntry(alias, privateKeyDer, certs) + } + + private fun skipTrustedCertEntry(dis: DataInputStream) { + dis.readUTF() // alias + dis.readLong() // timestamp + dis.readUTF() // cert type + val len = dis.readInt() + dis.skipBytes(len) + } + + /** + * Decrypts a Sun JKS protected key blob. + * + * Blob layout: [20 salt][ciphertext][20 sha1_check] + * Keystream: SHA1(pwd_utf16be || salt) || SHA1(pwd_utf16be || prev_hash) || ... + * Verify: SHA1(pwd_utf16be || plaintext) == sha1_check + */ + private fun decryptKey(protectedKey: ByteArray, password: String): ByteArray { + // The EncryptedPrivateKeyInfo DER wraps the actual protected blob. + // Structure: SEQUENCE { AlgorithmIdentifier(16 bytes from offset 4), OCTET STRING { blob } } + // AlgorithmIdentifier is always 16 bytes for OID 1.3.6.1.4.1.42.2.17.1.1 with NULL params. + val algoIdLen = 16 + val outerHeaderLen = 4 // 30 82 xx xx + var pos = outerHeaderLen + algoIdLen + + // OCTET STRING tag + length (may be 2 or 4 bytes depending on size) + pos++ // skip 0x04 tag + val lenByte = protectedKey[pos++].toInt() and 0xff + if (lenByte and 0x80 != 0) { + val n = lenByte and 0x7f + pos += n // skip multi-byte length + } + + val blob = protectedKey.copyOfRange(pos, protectedKey.size) + val salt = blob.copyOfRange(0, 20) + val ciphertext = blob.copyOfRange(20, blob.size - 20) + val check = blob.copyOfRange(blob.size - 20, blob.size) + + val pwdUtf16 = password.toByteArray(Charsets.UTF_16BE) + + val keystream = generateKeystream(pwdUtf16, salt, ciphertext.size) + val plaintext = ByteArray(ciphertext.size) { i -> (ciphertext[i].toInt() xor keystream[i].toInt()).toByte() } + + val sha1 = MessageDigest.getInstance("SHA-1") + sha1.update(pwdUtf16) + sha1.update(plaintext) + val verify = sha1.digest() + + if (!verify.contentEquals(check)) { + throw SecurityException("JKS key decryption failed - wrong password for key entry") + } + + return plaintext + } + + private fun generateKeystream(pwdUtf16: ByteArray, salt: ByteArray, length: Int): ByteArray { + val sha1 = MessageDigest.getInstance("SHA-1") + val result = ByteArray(length) + var pos = 0 + var digestInput = pwdUtf16 + salt + + while (pos < length) { + val hash = sha1.digest(digestInput) + val copy = minOf(hash.size, length - pos) + hash.copyInto(result, pos, 0, copy) + pos += copy + digestInput = pwdUtf16 + hash + } + return result + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/KeystoreConversionUtils.kt b/src/main/kotlin/app/morphe/engine/util/KeystoreConversionUtils.kt new file mode 100644 index 0000000..937bc81 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/KeystoreConversionUtils.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + * + * Ported from morphe-manager. Manager wrote this because morphe-patcher only + * speaks BKS but users (including URV cross-over users) often bring keystores + * in PKCS12, JKS, or BKS-with-misleading-extensions. Conversion is byte- + * sniffed, not extension-trusted — URV ships the same key bytes under + * different extensions, so trusting the suffix would mis-route. Keep this + * code as close to Manager's as possible so a key converted here and a key + * converted in Manager produce byte-identical BKS output. + */ + +package app.morphe.engine.util + +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.security.KeyFactory +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.spec.PKCS8EncodedKeySpec + +enum class KeystoreInputFormat( + val displayName: String, + val extensions: List, + val jcaType: String +) { + KEYSTORE(".keystore (BKS)", listOf("keystore"), "BKS"), + BKS(".bks (BKS)", listOf("bks"), "BKS"), + PKCS12(".p12 / .pfx (PKCS12)", listOf("p12", "pfx"), "PKCS12"), + JKS(".jks (JKS)", listOf("jks"), "JKS"); + + companion object { + fun fromExtension(ext: String): KeystoreInputFormat? = + entries.firstOrNull { ext.lowercase() in it.extensions } + + /** + * Sniff the keystore format from the first 4 bytes of the file. + * Returns null if the header is not recognized. + * + * PKCS12 - ASN.1 SEQUENCE: 0x30 + any BER length byte (0x80–0x84) + * JKS - magic: 0xFEEDFEED + * BKS - 4-byte big-endian version int, value 1 or 2 + */ + fun detectFromBytes(header: ByteArray): KeystoreInputFormat? { + if (header.size < 4) return null + return when { + header[0] == 0x30.toByte() && header[1] in byteArrayOf( + 0x80.toByte(), 0x81.toByte(), 0x82.toByte(), 0x83.toByte(), 0x84.toByte() + ) -> PKCS12 + header[0] == 0xFE.toByte() && header[1] == 0xED.toByte() && + header[2] == 0xFE.toByte() && header[3] == 0xED.toByte() -> JKS + header[0] == 0x00.toByte() && header[1] == 0x00.toByte() && + header[2] == 0x00.toByte() && + (header[3] == 0x01.toByte() || header[3] == 0x02.toByte()) -> BKS + else -> null + } + } + } +} + +sealed interface KeystoreConversionResult { + /** Conversion succeeded — [data] is a BKS keystore ready for the patcher. */ + data class Success(val data: List) : KeystoreConversionResult + /** Wrong password/alias, corrupt file, or unsupported format variant. */ + data class Error(val cause: Exception) : KeystoreConversionResult +} + +object KeystoreConversionUtils { + + /** + * Loads a keystore of [format] from [inputStream] and re-encodes all entries into a + * new BKS keystore, returning the raw bytes. The stream is read but not closed. + * + * When [alias] is blank all entries are transferred. Otherwise, the matching entry is + * looked up case-insensitively, falling back to transferring everything if not found. + * + * [password] is used for both the keystore and the individual key entries. + */ + fun convert( + inputStream: InputStream, + format: KeystoreInputFormat, + alias: String, + password: String + ): KeystoreConversionResult = runCatching { + val pass = password.toCharArray() + + // JKS requires manual parsing — see JksKeyStoreParser. Manager originally added + // this because Android's BC has no JKSKeyStoreSpi; we keep it as a fallback for + // URV-style JKS variants the stock SPI has historically choked on. + if (format == KeystoreInputFormat.JKS) { + val entries = JksKeyStoreParser.parse(inputStream, password) + check(entries.isNotEmpty()) { "No entries found in JKS keystore" } + + val jksTarget = KeyStore.getInstance("BKS").apply { load(null, pass) } + val cf = CertificateFactory.getInstance("X.509") + val kf = KeyFactory.getInstance("RSA") + + val filtered = if (alias.isBlank()) entries + else entries.filter { it.alias.equals(alias, ignoreCase = true) } + .ifEmpty { entries } + + filtered.forEach { entry -> + val privateKey = kf.generatePrivate(PKCS8EncodedKeySpec(entry.privateKeyDer.toByteArray())) + val certs = entry.certificatesDer.map { + cf.generateCertificate(it.toByteArray().inputStream()) + }.toTypedArray() + jksTarget.setKeyEntry(entry.alias, privateKey, pass, certs) + } + + val out = ByteArrayOutputStream() + jksTarget.store(out, pass) + return@runCatching KeystoreConversionResult.Success(out.toByteArray().toList()) + } + + // PKCS12 / BKS go through standard JCA. BKS source paths shouldn't normally + // reach here — callers should short-circuit on BKS detection — but kept as a + // safety net so calling convert() with BKS still produces a valid result. + val source = KeyStore.getInstance(format.jcaType).apply { load(inputStream, pass) } + + val entriesToMigrate = if (alias.isBlank()) { + source.aliases().toList() + } else { + source.aliases().toList() + .filter { it.equals(alias, ignoreCase = true) } + .ifEmpty { source.aliases().toList() } + } + + check(entriesToMigrate.isNotEmpty()) { "No entries found in keystore" } + + val target = KeyStore.getInstance("BKS").apply { load(null, pass) } + + for (entryAlias in entriesToMigrate) { + val protection = KeyStore.PasswordProtection(pass) + when { + source.isKeyEntry(entryAlias) -> { + val entry = source.getEntry(entryAlias, protection) ?: continue + target.setEntry(entryAlias, entry, protection) + } + source.isCertificateEntry(entryAlias) -> + target.setCertificateEntry(entryAlias, source.getCertificate(entryAlias)) + } + } + + val out = ByteArrayOutputStream() + target.store(out, pass) + KeystoreConversionResult.Success(out.toByteArray().toList()) + }.getOrElse { e -> + KeystoreConversionResult.Error(e as? Exception ?: RuntimeException(e)) + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/KeystoreImporter.kt b/src/main/kotlin/app/morphe/engine/util/KeystoreImporter.kt new file mode 100644 index 0000000..dfd784f --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/KeystoreImporter.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File +import java.security.Security + +/** + * The user-facing entry point for "I have a keystore file, make it work with + * morphe-patcher" — which only loads BKS. Wraps [KeystoreConversionUtils.convert] + * with the safeguards we want everywhere: + * + * 1. **Byte-sniff, not extension-trust.** URV ships the same key bytes under + * different extensions; extensions lie. + * 2. **BKS short-circuit.** If the source is already BKS, return the original + * file untouched — never round-trip a working keystore through convert(), + * no risk of provider differences silently rewriting it. + * 3. **Never mutate the original.** Conversions write to a separate output + * file the caller provides; the user's source file is read-only. + * 4. **Register BouncyCastle on demand.** convert() needs `KeyStore.getInstance("BKS")` + * which requires the BC provider — ensure it's installed before calling. + */ +object KeystoreImporter { + + /** Outcome of [ensureBks]. */ + sealed interface Result { + /** The provided source was already BKS — no conversion needed. Use [file] directly. */ + data class AlreadyBks(val file: File) : Result + /** Source was a different format; converted bytes were written to [file]. */ + data class Converted(val file: File, val sourceFormat: KeystoreInputFormat) : Result + /** Detection or conversion failed. [reason] is safe to surface to users. */ + data class Failed(val reason: String, val cause: Throwable? = null) : Result + } + + /** + * Ensure [source] can be passed to morphe-patcher. If already BKS, returns + * [Result.AlreadyBks] pointing at the original. Otherwise converts to BKS + * and writes the bytes to [convertedOutput], returning [Result.Converted]. + * + * [alias] and [password] are used to read the source. Pass the user's + * configured values; an empty alias means "transfer every entry" (Manager's + * default behavior — usually what you want for unknown third-party keys). + */ + fun ensureBks( + source: File, + convertedOutput: File, + alias: String, + password: String, + ): Result { + if (!source.exists()) { + return Result.Failed("Keystore file not found: ${source.absolutePath}") + } + if (!source.canRead()) { + return Result.Failed("Keystore file is not readable: ${source.absolutePath}") + } + + val header = try { + source.inputStream().use { stream -> + val buf = ByteArray(4) + val read = stream.read(buf) + if (read < 4) return Result.Failed("Keystore file is too small to identify") + buf + } + } catch (e: Exception) { + return Result.Failed("Couldn't read keystore header: ${e.message ?: e.javaClass.simpleName}", e) + } + + val format = KeystoreInputFormat.detectFromBytes(header) + ?: return Result.Failed( + "Unrecognized keystore format. Supported: BKS, PKCS12 (.p12/.pfx), JKS." + ) + + if (format == KeystoreInputFormat.BKS || format == KeystoreInputFormat.KEYSTORE) { + // BKS short-circuit — never re-encode an already-BKS file. + return Result.AlreadyBks(source) + } + + ensureBouncyCastleProvider() + + val converted = source.inputStream().use { + KeystoreConversionUtils.convert(it, format, alias, password) + } + return when (converted) { + is KeystoreConversionResult.Error -> Result.Failed( + friendlyConversionError(converted.cause), + converted.cause, + ) + is KeystoreConversionResult.Success -> { + try { + convertedOutput.parentFile?.mkdirs() + convertedOutput.writeBytes(converted.data.toByteArray()) + Result.Converted(convertedOutput, format) + } catch (e: Exception) { + Result.Failed( + "Couldn't save converted keystore: ${e.message ?: e.javaClass.simpleName}", + e, + ) + } + } + } + } + + /** + * Register BouncyCastle if it isn't already — needed for + * `KeyStore.getInstance("BKS")`. The patcher already pulls BC transitively, + * so the class is always on the classpath; we just have to install it as a + * JCA provider. Idempotent. + */ + private fun ensureBouncyCastleProvider() { + if (Security.getProvider("BC") != null) return + try { + val provider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") + .getDeclaredConstructor() + .newInstance() as java.security.Provider + Security.addProvider(provider) + } catch (_: Exception) { + // If BC isn't on the classpath the patcher itself would fail anyway — + // let conversion try and surface its own error rather than guessing here. + } + } + + private fun friendlyConversionError(cause: Throwable): String { + val msg = cause.message.orEmpty() + return when { + cause is java.security.UnrecoverableKeyException || + msg.contains("password", ignoreCase = true) -> + "Couldn't decrypt keystore — wrong password or alias." + msg.contains("No entries found", ignoreCase = true) -> + "Keystore has no usable key entries." + else -> "Conversion failed: ${msg.ifBlank { cause.javaClass.simpleName }}" + } + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/SignatureIdentity.kt b/src/main/kotlin/app/morphe/engine/util/SignatureIdentity.kt new file mode 100644 index 0000000..499be16 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/SignatureIdentity.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File +import java.security.KeyStore +import java.security.Security +import java.security.cert.Certificate +import java.util.Arrays + +/** + * Identifies a signing certificate by the same value Android reports in + * `dumpsys package `: + * + * ``` + * signatures=PackageSignatures{… signatures:[a7001add], past signatures:[]} + * ``` + * + * That `a7001add` is `Integer.toHexString(Arrays.hashCode(certDER))` of the + * signing cert (Android's `Signature.hashCode()`). Computing the same value from + * Morphe's keystore cert lets us tell, from a connected device, whether an + * installed app was signed by Morphe — without pulling the APK. + * + * The `signatures:[HEX]` format has been stable across Android 7→15. If a device + * emits something we don't recognise, [parseDeviceSignatureId] returns null and + * callers fall back to the version-robust "installed?" check (no false verdicts). + */ +object SignatureIdentity { + + /** Android's signature id for [cert]: `toHexString(Arrays.hashCode(DER))`. */ + fun idForCert(cert: Certificate): String = + Integer.toHexString(Arrays.hashCode(cert.encoded)) + + /** + * Signature id of the cert under [alias] in the BKS [keystoreFile], or null + * if it can't be read. Only the public certificate is needed, so the key + * (entry) password is irrelevant — just the store password. + */ + fun idForKeystore(keystoreFile: File, storePassword: String?, alias: String): String? = try { + if (!keystoreFile.exists()) { + null + } else { + ensureBouncyCastle() + val ks = KeyStore.getInstance("BKS") + keystoreFile.inputStream().use { ks.load(it, storePassword?.toCharArray()) } + val realAlias = ks.aliases().toList().firstOrNull { it.equals(alias, ignoreCase = true) } ?: alias + ks.getCertificate(realAlias)?.let { idForCert(it) } + } + } catch (e: Exception) { + null + } + + /** + * Parse the signing-cert id from `dumpsys package` output. Matches the + * `signatures:[HEX]` form (lowercased). Null if not present/unrecognised. + */ + fun parseDeviceSignatureId(dumpsysOutput: String): String? = + Regex("""signatures:\[([0-9a-fA-F]+)\]""").find(dumpsysOutput)?.groupValues?.get(1)?.lowercase() + + private fun ensureBouncyCastle() { + if (Security.getProvider("BC") != null) return + try { + val provider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") + .getDeclaredConstructor().newInstance() as java.security.Provider + Security.addProvider(provider) + } catch (_: Exception) { + // BC ships transitively with the patcher; if absent, BKS load fails + // and idForKeystore returns null — graceful. + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt index 6e1b77c..9a548bf 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -78,7 +78,10 @@ fun launchGui(args: Array) = application { state = windowState, icon = appIcon ) { - window.minimumSize = java.awt.Dimension(600, 400) + // Min width keeps the single side-by-side Home layout viable — there is no + // narrow/stacked variant to fall back to (intentionally removed; one layout + // to maintain). 900 is the floor at which the two panes still read well. + window.minimumSize = java.awt.Dimension(900, 500) // macOS: hide the OS-drawn title bar so a Compose-rendered colored // band can take its place. Traffic lights stay where the OS draws diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index 4ef8ed1..bdbfa52 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -82,6 +82,9 @@ data class AppConfig( // When ON, DeviceMonitor polls devices; if Morphe was the one that started // the daemon, it's killed on toggle-OFF and on window close. val autoStartAdb: Boolean = false, + // Which home apps tab the user last viewed ("ALL" or "YOURS"), restored on + // next launch. Stored as a string so this data layer stays free of UI enums. + val homeAppListFilter: String = "ALL", ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt index e2769a6..ff81254 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -5,6 +5,7 @@ package app.morphe.gui.data.model +import app.morphe.engine.model.PatchedAppRecord import app.morphe.patcher.resource.CpuArchitecture import kotlinx.serialization.Serializable @@ -91,5 +92,16 @@ data class PatchConfig( val patchOptions: Map = emptyMap(), val useExclusiveMode: Boolean = false, val keepArchitectures: Set = emptySet(), - val continueOnError: Boolean = false + val continueOnError: Boolean = false, + + // ── Recall metadata ── + // Carried from the selection screen down to the patching screen so the + // success path can record a PatchedAppRecord (see PatchedAppStore). All + // default-empty, so callers that don't populate them still work. + val packageName: String = "", + val appDisplayName: String = "", + /** Source name → set of selected patch unique ids. */ + val patchSelectionByBundle: Map> = emptyMap(), + /** Sources + versions enabled at patch time (drives "update available"). */ + val sourcesSnapshot: List = emptyList(), ) diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index a0477ff..6ebdc20 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -296,6 +296,22 @@ class ConfigRepository { saveConfig(current.copy(patchSource = updatedSources)) } + /** + * Persist a new source ordering given the ids in their desired order. Order + * only affects the app display-name tiebreak (first source wins) and UI + * presentation order — not which patches load — so any source, default + * included, may be reordered. Ignores the call unless [orderedIds] is a + * permutation of the existing sources (guards against a stale UI snapshot + * dropping or duplicating a source). + */ + suspend fun reorderPatchSources(orderedIds: List) { + val current = loadConfig() + val byId = current.patchSource.associateBy { it.id } + if (orderedIds.size != current.patchSource.size || orderedIds.toSet() != byId.keys) return + val reordered = orderedIds.map { byId.getValue(it) } + saveConfig(current.copy(patchSource = reordered)) + } + /** * Update whether Morphe auto-starts the ADB daemon at GUI launch. */ @@ -313,6 +329,14 @@ class ConfigRepository { saveConfig(current.copy(multiSourceHintDismissed = true)) } + /** Persist which home apps tab ("ALL"/"YOURS") the user is viewing. */ + suspend fun setHomeAppListFilter(value: String) { + val current = loadConfig() + if (current.homeAppListFilter == value) return + saveConfig(current.copy(homeAppListFilter = value)) + } + + /** * Toggle enablement of a patch source. Safety net: if disabling would leave zero * enabled sources, the default source is force-enabled (mirrors morphe-manager diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt index 496c62a..9de335a 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt @@ -266,6 +266,16 @@ class PatchSourceManager( _sourceVersion.value++ } + /** + * Persist a new source ordering. Order affects only the display-name + * tiebreak and UI presentation, not which patches load. + */ + suspend fun reorderSources(orderedIds: List) { + configRepository.reorderPatchSources(orderedIds) + refreshEnabledSources() + _sourceVersion.value++ + } + /** * Update an existing source (e.g. rename). Refuses non-deletable sources. */ diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index 9c2a68f..004d231 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -9,6 +9,7 @@ import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchPreferencesRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository +import app.morphe.engine.PatchedAppStore import app.morphe.gui.util.PatchService import io.ktor.client.* import io.ktor.client.engine.cio.* @@ -63,11 +64,12 @@ val appModule = module { single { PatchSourceManager(get(), get()) } single { PatchService() } single { UpdateCheckRepository(get()) } + single { PatchedAppStore.shared } // ViewModels (ScreenModels) // ViewModels observe PatchSourceManager.sourceVersion and reload on source changes. factory { - HomeViewModel(get(), get(), get(), get()) + HomeViewModel(get(), get(), get(), get(), get()) } factory { params -> val psm = get() @@ -96,12 +98,15 @@ val appModule = module { psm.getLocalFilePath(), params.get(), params.get(), + params.get(), + params.get(), ) } factory { params -> PatchingViewModel( params.get(), get(), + get(), get() ) } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ActionButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/ActionButton.kt new file mode 100644 index 0000000..3122fa8 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/ActionButton.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.LocalMorpheCorners + +/** + * Outlined, full-width action button. Shared by [SettingsDialog]'s runtime-logs + * section and [ToolsDialog]'s action list — lifted to its own file so both + * surfaces use one definition. + */ +@Composable +internal fun ActionButton( + label: String, + icon: ImageVector, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + enabled: Boolean = true, + onClick: () -> Unit +) { + val corners = LocalMorpheCorners.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth().hoverable(hoverInteraction), + shape = RoundedCornerShape(corners.small), + border = BorderStroke( + 1.dp, + if (isHovered && enabled) contentColor.copy(alpha = 0.3f) + else borderColor + ), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = contentColor, + disabledContentColor = contentColor.copy(alpha = 0.4f) + ) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + label, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp, + modifier = Modifier.weight(1f) + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt new file mode 100644 index 0000000..df0474b --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt @@ -0,0 +1,773 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +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.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +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.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +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 androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.MorpheAccentColors +import app.morphe.gui.ui.theme.MorpheCornerStyle +import app.morphe.gui.util.Logger +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.aboutlibraries.entity.License +import java.awt.Desktop +import java.net.URI + +@Composable +internal fun LicensesDialog(onDismiss: () -> Unit) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) + + val libs = remember { + try { + val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("aboutlibraries.json") + val json = stream?.bufferedReader()?.use { it.readText() } + if (json != null) Libs.Builder().withJson(json).build() else null + } catch (e: Exception) { + Logger.error("Failed to load licenses", e) + null + } + } + + var searchQuery by remember { mutableStateOf("") } + var viewingLicense by remember { mutableStateOf(null) } + val listState = rememberLazyListState() + + val filtered = remember(libs, searchQuery) { + val all = libs?.libraries.orEmpty() + if (searchQuery.isBlank()) all + else { + val q = searchQuery.trim().lowercase() + all.filter { lib -> + lib.name.lowercase().contains(q) || + lib.uniqueId.lowercase().contains(q) || + (lib.description?.lowercase()?.contains(q) == true) || + lib.licenses.any { it.name.lowercase().contains(q) || (it.spdxId?.lowercase()?.contains(q) == true) } + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .widthIn(min = 640.dp, max = 960.dp) + .heightIn(min = 520.dp, max = 780.dp) + .fillMaxWidth(0.88f) + .fillMaxHeight(0.88f), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(corners.medium), + border = BorderStroke(1.dp, borderColor) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Header ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "OPEN SOURCE LICENSES", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + letterSpacing = 1.8.sp, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "[${libs?.libraries?.size ?: 0}]", + fontFamily = mono, + fontSize = 11.sp, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + val closeHover = remember { MutableInteractionSource() } + val isCloseHovered by closeHover.collectIsHoveredAsState() + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .hoverable(closeHover) + .background( + if (isCloseHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) + else Color.Transparent + ) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (isCloseHovered) 0.85f else 0.55f + ), + modifier = Modifier.size(14.dp) + ) + } + } + + HorizontalDivider(color = dividerColor) + + // ── Search bar ── + Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp)) { + LicenseSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) + } + + HorizontalDivider(color = dividerColor) + + // ── List ── + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + when { + libs == null -> EmptyHint(text = "// failed to load licenses", mono = mono, isError = true) + filtered.isEmpty() -> EmptyHint(text = "// no matches", mono = mono, isError = false) + else -> { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 22.dp, vertical = 8.dp) + ) { + items(items = filtered, key = { it.uniqueId }) { library -> + LibraryRow( + library = library, + mono = mono, + accents = accents, + corners = corners, + borderColor = borderColor, + dividerColor = dividerColor, + onLicenseClick = { viewingLicense = it } + ) + } + } + + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 6.dp), + adapter = rememberScrollbarAdapter(listState), + style = morpheScrollbarStyle() + ) + } + } + } + + HorizontalDivider(color = dividerColor) + + // ── Footer ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (searchQuery.isBlank()) "${filtered.size} libraries" + else "${filtered.size} / ${libs?.libraries?.size ?: 0} matched", + fontFamily = mono, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 0.8.sp + ) + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) + ) { + Text( + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + viewingLicense?.let { license -> + LicenseTextDialog(license = license, onDismiss = { viewingLicense = null }) + } +} + +@Composable +private fun LicenseSearchBar(query: String, onQueryChange: (String) -> Unit) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val searchFocused = remember { mutableStateOf(false) } + val searchBorderColor by animateColorAsState( + if (searchFocused.value) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + animationSpec = tween(150) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, searchBorderColor, RoundedCornerShape(corners.small)) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(16.dp) + ) + + Box(modifier = Modifier.weight(1f)) { + if (query.isEmpty()) { + Text( + text = "Search libraries, SPDX id, uniqueId…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) + ) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = LocalTextStyle.current.copy( + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(accents.primary), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { searchFocused.value = it.isFocused } + ) + } + + if (query.isNotEmpty()) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(corners.small)) + .clickable { onQueryChange("") }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(14.dp) + ) + } + } + } +} + +@Composable +private fun LibraryRow( + library: Library, + mono: androidx.compose.ui.text.font.FontFamily, + accents: MorpheAccentColors, + corners: MorpheCornerStyle, + borderColor: Color, + dividerColor: Color, + onLicenseClick: (License) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + animationSpec = tween(180) + ) + val bgAlpha by animateFloatAsState( + targetValue = when { + expanded -> 0.05f + isHovered -> 0.03f + else -> 0f + }, + animationSpec = tween(180) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .hoverable(hoverInteraction) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = bgAlpha)) + .clickable { expanded = !expanded } + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = library.name, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + library.artifactVersion?.takeIf { it.isNotBlank() }?.let { v -> + Text( + text = "v$v", + fontSize = 10.sp, + fontFamily = mono, + color = accents.secondary.copy(alpha = 0.9f), + letterSpacing = 0.3.sp + ) + } + } + Spacer(Modifier.height(2.dp)) + Text( + text = library.uniqueId, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (library.licenses.isEmpty()) { + LicenseChip( + label = "UNKNOWN", + mono = mono, + corners = corners, + accentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), + onClick = null + ) + } else { + library.licenses.forEach { license -> + LicenseChip( + label = licenseDisplayLabel(license), + mono = mono, + corners = corners, + accentColor = accents.primary, + onClick = { onLicenseClick(license) } + ) + } + } + } + + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.7f else 0.4f), + modifier = Modifier + .size(16.dp) + .graphicsLayer { rotationZ = rotation } + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(expandFrom = Alignment.Top, animationSpec = tween(200)) + + fadeIn(animationSpec = tween(200)), + exit = shrinkVertically(shrinkTowards = Alignment.Top, animationSpec = tween(180)) + + fadeOut(animationSpec = tween(140)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 14.dp, top = 2.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + library.description?.trim()?.takeIf { it.isNotEmpty() }?.let { desc -> + Text( + text = desc, + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), + lineHeight = 17.sp + ) + } + + val devs = library.developers.mapNotNull { it.name?.takeIf { n -> n.isNotBlank() } } + val org = library.organization?.name?.takeIf { it.isNotBlank() } + if (devs.isNotEmpty() || org != null) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (devs.isNotEmpty()) { + MetaLine(label = "AUTHORS", value = devs.joinToString(", "), mono = mono) + } + org?.let { MetaLine(label = "ORG", value = it, mono = mono) } + } + } + + val website = library.website?.takeIf { it.isNotBlank() } + val source = library.scm?.url?.takeIf { it.isNotBlank() } + if (website != null || source != null) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + website?.let { + LinkPill(label = "WEBSITE", url = it, mono = mono, corners = corners, borderColor = borderColor) + } + source?.let { + LinkPill(label = "SOURCE", url = it, mono = mono, corners = corners, borderColor = borderColor) + } + } + } + } + } + + HorizontalDivider(color = dividerColor) + } +} + +@Composable +private fun MetaLine( + label: String, + value: String, + mono: androidx.compose.ui.text.font.FontFamily, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + letterSpacing = 1.sp, + modifier = Modifier.width(56.dp) + ) + Text( + text = value, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun LicenseChip( + label: String, + mono: androidx.compose.ui.text.font.FontFamily, + corners: MorpheCornerStyle, + accentColor: Color, + onClick: (() -> Unit)?, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val bg by animateColorAsState( + if (isHovered && onClick != null) accentColor.copy(alpha = 0.18f) + else accentColor.copy(alpha = 0.08f), + animationSpec = tween(140) + ) + Box( + modifier = Modifier + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .background(bg, RoundedCornerShape(corners.small)) + .border(1.dp, accentColor.copy(alpha = 0.35f), RoundedCornerShape(corners.small)) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 7.dp, vertical = 3.dp) + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accentColor, + letterSpacing = 0.8.sp, + maxLines = 1 + ) + } +} + +@Composable +private fun LinkPill( + label: String, + url: String, + mono: androidx.compose.ui.text.font.FontFamily, + corners: MorpheCornerStyle, + borderColor: Color, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Row( + modifier = Modifier + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (isHovered) borderColor.copy(alpha = 0.4f) else borderColor, + RoundedCornerShape(corners.small) + ) + .clickable { openUrl(url) } + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.9f else 0.6f), + letterSpacing = 1.sp + ) + @Suppress("DEPRECATION") + Icon( + imageVector = Icons.Default.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.75f else 0.45f), + modifier = Modifier.size(10.dp) + ) + } +} + +@Composable +private fun EmptyHint(text: String, mono: androidx.compose.ui.text.font.FontFamily, isError: Boolean) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = text, + fontFamily = mono, + fontSize = 12.sp, + color = if (isError) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + letterSpacing = 0.8.sp + ) + } +} + +@Composable +private fun LicenseTextDialog(license: License, onDismiss: () -> Unit) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + val content = license.licenseContent?.takeIf { it.isNotBlank() } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .widthIn(min = 540.dp, max = 820.dp) + .heightIn(min = 380.dp, max = 680.dp) + .fillMaxWidth(0.78f) + .fillMaxHeight(0.82f), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(corners.medium), + border = BorderStroke(1.dp, borderColor) + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + val chipLabel = licenseDisplayLabel(license) + Text( + text = chipLabel.uppercase(), + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.5.sp, + color = accents.primary + ) + if (license.name.isNotBlank() && !license.name.equals(chipLabel, ignoreCase = true)) { + Text( + text = license.name, + fontFamily = mono, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + } + } + + HorizontalDivider(color = borderColor) + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (content != null) { + val scrollState = rememberScrollState() + Text( + text = content, + fontSize = 11.sp, + fontFamily = mono, + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 22.dp, vertical = 16.dp) + ) + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 6.dp), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle() + ) + } else { + Column( + modifier = Modifier.fillMaxSize().padding(22.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "// full license text not bundled", + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + letterSpacing = 0.5.sp + ) + license.url?.takeIf { it.isNotBlank() }?.let { url -> + Text( + text = "Open the canonical license text:", + fontFamily = mono, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + LinkPill( + label = "OPEN LICENSE", + url = url, + mono = mono, + corners = corners, + borderColor = borderColor + ) + } + } + } + } + + HorizontalDivider(color = borderColor) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.End + ) { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) + ) { + Text( + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +private val MD5_HASH_REGEX = Regex("^[0-9a-f]{32}$") + +private fun licenseDisplayLabel(license: License): String { + license.spdxId?.takeIf { it.isNotBlank() }?.let { return it } + val hash = license.hash + if (hash.isNotBlank() && !MD5_HASH_REGEX.matches(hash)) return hash + return license.name.ifBlank { "—" } +} + +private fun openUrl(url: String) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI.create(url)) + } + } catch (e: Exception) { + Logger.error("Failed to open url: $url", e) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index daa15b8..79b273e 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -36,7 +36,6 @@ import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.UpdateChannelPreference import app.morphe.gui.data.repository.ConfigRepository -import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -48,7 +47,6 @@ import app.morphe.gui.ui.theme.LocalThemeState @Composable fun SettingsButton( modifier: Modifier = Modifier, - allowCacheClear: Boolean = true, isPatching: Boolean = false, onDismiss: () -> Unit = {}, /** @@ -63,7 +61,6 @@ fun SettingsButton( val modeState = LocalModeState.current val adbPreference = LocalAdbPreference.current val configRepository: ConfigRepository = koinInject() - val patchSourceManager: PatchSourceManager = koinInject() val updateCheckRepository: UpdateCheckRepository = koinInject() val scope = rememberCoroutineScope() @@ -150,11 +147,7 @@ fun SettingsButton( showSettingsDialog = false onDismiss() }, - allowCacheClear = allowCacheClear, isPatching = isPatching, - onCacheCleared = { - patchSourceManager.notifyCacheCleared() - }, keystorePath = keystorePath, keystorePassword = keystorePassword, keystoreAlias = keystoreAlias, @@ -222,8 +215,8 @@ fun TopBarRow( verticalAlignment = Alignment.CenterVertically ) { DeviceIndicator() + ToolsButton(allowCacheClear = allowCacheClear) SettingsButton( - allowCacheClear = allowCacheClear, isPatching = isPatching, onUpdateChannelChanged = onUpdateChannelChanged, ) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index db4e5f1..35df0b4 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.unit.sp import app.morphe.engine.MorpheData import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD -import app.morphe.gui.data.constants.AppConstants +import app.morphe.engine.util.KeystoreImporter import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.ui.theme.LocalMorpheAccents @@ -58,9 +58,6 @@ import java.security.MessageDigest import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.UUID -import com.mikepenz.aboutlibraries.Libs -import com.mikepenz.aboutlibraries.entity.Library -import com.mikepenz.aboutlibraries.entity.License import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState @@ -69,19 +66,6 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.VerticalScrollbar -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import app.morphe.gui.ui.theme.MorpheAccentColors -import app.morphe.gui.ui.theme.MorpheCornerStyle -import java.net.URI @Composable fun SettingsDialog( @@ -94,9 +78,7 @@ fun SettingsDialog( useExpertMode: Boolean, onExpertModeChange: (Boolean) -> Unit, onDismiss: () -> Unit, - allowCacheClear: Boolean = true, isPatching: Boolean = false, - onCacheCleared: () -> Unit = {}, keystorePath: String? = null, keystorePassword: String? = null, keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, @@ -117,10 +99,6 @@ fun SettingsDialog( val accents = LocalMorpheAccents.current val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) - var showClearCacheConfirm by remember { mutableStateOf(false) } - var showLicensesDialog by remember { mutableStateOf(false) } - var cacheCleared by remember { mutableStateOf(false) } - var cacheClearFailed by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, @@ -303,101 +281,6 @@ fun SettingsDialog( onExpandedChange = { onCollapsibleSectionToggle("RUNTIME LOGS", it) } ) - SettingsDivider(borderColor) - - // ── Actions ── - SectionLabel("ACTIONS", mono) - Spacer(Modifier.height(8.dp)) - - ActionButton( - label = "OPEN LOGS", - icon = Icons.Default.BugReport, - mono = mono, - borderColor = borderColor, - onClick = { - try { - val logsDir = FileUtils.getLogsDir() - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(logsDir) - } - } catch (e: Exception) { - Logger.error("Failed to open logs folder", e) - } - } - ) - - Spacer(Modifier.height(6.dp)) - - ActionButton( - label = "OPEN APP DATA", - icon = Icons.Default.FolderOpen, - mono = mono, - borderColor = borderColor, - 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) - } - } - ) - - Spacer(Modifier.height(6.dp)) - - ActionButton( - label = "VIEW LICENSES", - icon = Icons.Default.Description, - mono = mono, - borderColor = borderColor, - onClick = { showLicensesDialog = true } - ) - - Spacer(Modifier.height(6.dp)) - - // Clear cache - val cacheColor = when { - cacheCleared -> MorpheColors.Teal - cacheClearFailed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.error - } - ActionButton( - label = when { - !allowCacheClear -> "CLEAR CACHE (DISABLED)" - cacheCleared -> "CACHE CLEARED" - cacheClearFailed -> "CLEAR FAILED" - else -> "CLEAR CACHE" - }, - icon = Icons.Default.Delete, - mono = mono, - borderColor = if (cacheCleared) MorpheColors.Teal.copy(alpha = 0.3f) - else MaterialTheme.colorScheme.error.copy(alpha = 0.2f), - contentColor = cacheColor, - enabled = allowCacheClear && !cacheCleared, - onClick = { showClearCacheConfirm = true } - ) - - Spacer(Modifier.height(4.dp)) - - val cacheSize = calculateCacheSize() - Text( - text = "Cache: $cacheSize (patches + logs)", - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - ) - - SettingsDivider(borderColor) - - // ── About ── - Text( - text = "${AppConstants.APP_NAME} ${AppConstants.APP_VERSION}", - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - ) } }, confirmButton = { @@ -417,784 +300,6 @@ fun SettingsDialog( } } ) - - // Clear cache confirmation - if (showClearCacheConfirm) { - AlertDialog( - onDismissRequest = { showClearCacheConfirm = false }, - shape = RoundedCornerShape(corners.medium), - containerColor = MaterialTheme.colorScheme.surface, - title = { - Text( - "CLEAR CACHE?", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.sp - ) - }, - text = { - Text( - "This will delete downloaded patches and log files. Patches will be re-downloaded when needed.", - fontFamily = mono, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 18.sp - ) - }, - confirmButton = { - Button( - onClick = { - val success = clearAllCache() - cacheCleared = success - cacheClearFailed = !success - showClearCacheConfirm = false - if (success) onCacheCleared() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ), - shape = RoundedCornerShape(corners.small) - ) { - Text( - "CLEAR", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - }, - dismissButton = { - TextButton(onClick = { showClearCacheConfirm = false }) { - Text( - "CANCEL", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - } - ) - } - - if (showLicensesDialog) { - LicensesDialog(onDismiss = { showLicensesDialog = false }) - } -} - -@Composable -private fun LicensesDialog(onDismiss: () -> Unit) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) - val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) - - val libs = remember { - try { - val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("aboutlibraries.json") - val json = stream?.bufferedReader()?.use { it.readText() } - if (json != null) Libs.Builder().withJson(json).build() else null - } catch (e: Exception) { - Logger.error("Failed to load licenses", e) - null - } - } - - var searchQuery by remember { mutableStateOf("") } - var viewingLicense by remember { mutableStateOf(null) } - val listState = rememberLazyListState() - - val filtered = remember(libs, searchQuery) { - val all = libs?.libraries.orEmpty() - if (searchQuery.isBlank()) all - else { - val q = searchQuery.trim().lowercase() - all.filter { lib -> - lib.name.lowercase().contains(q) || - lib.uniqueId.lowercase().contains(q) || - (lib.description?.lowercase()?.contains(q) == true) || - lib.licenses.any { it.name.lowercase().contains(q) || (it.spdxId?.lowercase()?.contains(q) == true) } - } - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .widthIn(min = 640.dp, max = 960.dp) - .heightIn(min = 520.dp, max = 780.dp) - .fillMaxWidth(0.88f) - .fillMaxHeight(0.88f), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(corners.medium), - border = BorderStroke(1.dp, borderColor) - ) { - Column(modifier = Modifier.fillMaxSize()) { - // ── Header ── - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = "OPEN SOURCE LICENSES", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - letterSpacing = 1.8.sp, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "[${libs?.libraries?.size ?: 0}]", - fontFamily = mono, - fontSize = 11.sp, - color = accents.primary, - letterSpacing = 0.5.sp - ) - } - val closeHover = remember { MutableInteractionSource() } - val isCloseHovered by closeHover.collectIsHoveredAsState() - Box( - modifier = Modifier - .size(28.dp) - .clip(RoundedCornerShape(corners.small)) - .hoverable(closeHover) - .background( - if (isCloseHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) - else Color.Transparent - ) - .clickable(onClick = onDismiss), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = if (isCloseHovered) 0.85f else 0.55f - ), - modifier = Modifier.size(14.dp) - ) - } - } - - HorizontalDivider(color = dividerColor) - - // ── Search bar ── - Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp)) { - LicenseSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) - } - - HorizontalDivider(color = dividerColor) - - // ── List ── - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { - when { - libs == null -> EmptyHint(text = "// failed to load licenses", mono = mono, isError = true) - filtered.isEmpty() -> EmptyHint(text = "// no matches", mono = mono, isError = false) - else -> { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 22.dp, vertical = 8.dp) - ) { - items(items = filtered, key = { it.uniqueId }) { library -> - LibraryRow( - library = library, - mono = mono, - accents = accents, - corners = corners, - borderColor = borderColor, - dividerColor = dividerColor, - onLicenseClick = { viewingLicense = it } - ) - } - } - - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight() - .padding(vertical = 6.dp), - adapter = rememberScrollbarAdapter(listState), - style = morpheScrollbarStyle() - ) - } - } - } - - HorizontalDivider(color = dividerColor) - - // ── Footer ── - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (searchQuery.isBlank()) "${filtered.size} libraries" - else "${filtered.size} / ${libs?.libraries?.size ?: 0} matched", - fontFamily = mono, - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - letterSpacing = 0.8.sp - ) - OutlinedButton( - onClick = onDismiss, - shape = RoundedCornerShape(corners.small), - border = BorderStroke(1.dp, borderColor) - ) { - Text( - "CLOSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - - viewingLicense?.let { license -> - LicenseTextDialog(license = license, onDismiss = { viewingLicense = null }) - } -} - -@Composable -private fun LicenseSearchBar(query: String, onQueryChange: (String) -> Unit) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val searchFocused = remember { mutableStateOf(false) } - val searchBorderColor by animateColorAsState( - if (searchFocused.value) accents.primary.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), - animationSpec = tween(150) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .height(38.dp) - .clip(RoundedCornerShape(corners.small)) - .border(1.dp, searchBorderColor, RoundedCornerShape(corners.small)) - .padding(horizontal = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.size(16.dp) - ) - - Box(modifier = Modifier.weight(1f)) { - if (query.isEmpty()) { - Text( - text = "Search libraries, SPDX id, uniqueId…", - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) - ) - } - BasicTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - textStyle = LocalTextStyle.current.copy( - fontSize = 12.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurface - ), - cursorBrush = SolidColor(accents.primary), - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { searchFocused.value = it.isFocused } - ) - } - - if (query.isNotEmpty()) { - Box( - modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(corners.small)) - .clickable { onQueryChange("") }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.size(14.dp) - ) - } - } - } -} - -@Composable -private fun LibraryRow( - library: Library, - mono: androidx.compose.ui.text.font.FontFamily, - accents: MorpheAccentColors, - corners: MorpheCornerStyle, - borderColor: Color, - dividerColor: Color, - onLicenseClick: (License) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - val hoverInteraction = remember { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - val rotation by animateFloatAsState( - targetValue = if (expanded) 180f else 0f, - animationSpec = tween(180) - ) - val bgAlpha by animateFloatAsState( - targetValue = when { - expanded -> 0.05f - isHovered -> 0.03f - else -> 0f - }, - animationSpec = tween(180) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(corners.small)) - .hoverable(hoverInteraction) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = bgAlpha)) - .clickable { expanded = !expanded } - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = library.name, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - library.artifactVersion?.takeIf { it.isNotBlank() }?.let { v -> - Text( - text = "v$v", - fontSize = 10.sp, - fontFamily = mono, - color = accents.secondary.copy(alpha = 0.9f), - letterSpacing = 0.3.sp - ) - } - } - Spacer(Modifier.height(2.dp)) - Text( - text = library.uniqueId, - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (library.licenses.isEmpty()) { - LicenseChip( - label = "UNKNOWN", - mono = mono, - corners = corners, - accentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), - onClick = null - ) - } else { - library.licenses.forEach { license -> - LicenseChip( - label = licenseDisplayLabel(license), - mono = mono, - corners = corners, - accentColor = accents.primary, - onClick = { onLicenseClick(license) } - ) - } - } - } - - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = if (expanded) "Collapse" else "Expand", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.7f else 0.4f), - modifier = Modifier - .size(16.dp) - .graphicsLayer { rotationZ = rotation } - ) - } - - AnimatedVisibility( - visible = expanded, - enter = expandVertically(expandFrom = Alignment.Top, animationSpec = tween(200)) + - fadeIn(animationSpec = tween(200)), - exit = shrinkVertically(shrinkTowards = Alignment.Top, animationSpec = tween(180)) + - fadeOut(animationSpec = tween(140)) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, bottom = 14.dp, top = 2.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - library.description?.trim()?.takeIf { it.isNotEmpty() }?.let { desc -> - Text( - text = desc, - fontSize = 12.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), - lineHeight = 17.sp - ) - } - - val devs = library.developers.mapNotNull { it.name?.takeIf { n -> n.isNotBlank() } } - val org = library.organization?.name?.takeIf { it.isNotBlank() } - if (devs.isNotEmpty() || org != null) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (devs.isNotEmpty()) { - MetaLine(label = "AUTHORS", value = devs.joinToString(", "), mono = mono) - } - org?.let { MetaLine(label = "ORG", value = it, mono = mono) } - } - } - - val website = library.website?.takeIf { it.isNotBlank() } - val source = library.scm?.url?.takeIf { it.isNotBlank() } - if (website != null || source != null) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - website?.let { - LinkPill(label = "WEBSITE", url = it, mono = mono, corners = corners, borderColor = borderColor) - } - source?.let { - LinkPill(label = "SOURCE", url = it, mono = mono, corners = corners, borderColor = borderColor) - } - } - } - } - } - - HorizontalDivider(color = dividerColor) - } -} - -@Composable -private fun MetaLine( - label: String, - value: String, - mono: androidx.compose.ui.text.font.FontFamily, -) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = label, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), - letterSpacing = 1.sp, - modifier = Modifier.width(56.dp) - ) - Text( - text = value, - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -private fun LicenseChip( - label: String, - mono: androidx.compose.ui.text.font.FontFamily, - corners: MorpheCornerStyle, - accentColor: Color, - onClick: (() -> Unit)?, -) { - val hover = remember { MutableInteractionSource() } - val isHovered by hover.collectIsHoveredAsState() - val bg by animateColorAsState( - if (isHovered && onClick != null) accentColor.copy(alpha = 0.18f) - else accentColor.copy(alpha = 0.08f), - animationSpec = tween(140) - ) - Box( - modifier = Modifier - .hoverable(hover) - .clip(RoundedCornerShape(corners.small)) - .background(bg, RoundedCornerShape(corners.small)) - .border(1.dp, accentColor.copy(alpha = 0.35f), RoundedCornerShape(corners.small)) - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(horizontal = 7.dp, vertical = 3.dp) - ) { - Text( - text = label, - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accentColor, - letterSpacing = 0.8.sp, - maxLines = 1 - ) - } -} - -@Composable -private fun LinkPill( - label: String, - url: String, - mono: androidx.compose.ui.text.font.FontFamily, - corners: MorpheCornerStyle, - borderColor: Color, -) { - val hover = remember { MutableInteractionSource() } - val isHovered by hover.collectIsHoveredAsState() - Row( - modifier = Modifier - .hoverable(hover) - .clip(RoundedCornerShape(corners.small)) - .border( - 1.dp, - if (isHovered) borderColor.copy(alpha = 0.4f) else borderColor, - RoundedCornerShape(corners.small) - ) - .clickable { openUrl(url) } - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.9f else 0.6f), - letterSpacing = 1.sp - ) - @Suppress("DEPRECATION") - Icon( - imageVector = Icons.Default.OpenInNew, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.75f else 0.45f), - modifier = Modifier.size(10.dp) - ) - } -} - -@Composable -private fun EmptyHint(text: String, mono: androidx.compose.ui.text.font.FontFamily, isError: Boolean) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = text, - fontFamily = mono, - fontSize = 12.sp, - color = if (isError) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), - letterSpacing = 0.8.sp - ) - } -} - -@Composable -private fun LicenseTextDialog(license: License, onDismiss: () -> Unit) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) - val content = license.licenseContent?.takeIf { it.isNotBlank() } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .widthIn(min = 540.dp, max = 820.dp) - .heightIn(min = 380.dp, max = 680.dp) - .fillMaxWidth(0.78f) - .fillMaxHeight(0.82f), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(corners.medium), - border = BorderStroke(1.dp, borderColor) - ) { - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - val chipLabel = licenseDisplayLabel(license) - Text( - text = chipLabel.uppercase(), - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.5.sp, - color = accents.primary - ) - if (license.name.isNotBlank() && !license.name.equals(chipLabel, ignoreCase = true)) { - Text( - text = license.name, - fontFamily = mono, - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } - Box( - modifier = Modifier - .size(28.dp) - .clip(RoundedCornerShape(corners.small)) - .clickable(onClick = onDismiss), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp) - ) - } - } - - HorizontalDivider(color = borderColor) - - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - if (content != null) { - val scrollState = rememberScrollState() - Text( - text = content, - fontSize = 11.sp, - fontFamily = mono, - lineHeight = 16.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(horizontal = 22.dp, vertical = 16.dp) - ) - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight() - .padding(vertical = 6.dp), - adapter = rememberScrollbarAdapter(scrollState), - style = morpheScrollbarStyle() - ) - } else { - Column( - modifier = Modifier.fillMaxSize().padding(22.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = "// full license text not bundled", - fontFamily = mono, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - letterSpacing = 0.5.sp - ) - license.url?.takeIf { it.isNotBlank() }?.let { url -> - Text( - text = "Open the canonical license text:", - fontFamily = mono, - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - LinkPill( - label = "OPEN LICENSE", - url = url, - mono = mono, - corners = corners, - borderColor = borderColor - ) - } - } - } - } - - HorizontalDivider(color = borderColor) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.End - ) { - OutlinedButton( - onClick = onDismiss, - shape = RoundedCornerShape(corners.small), - border = BorderStroke(1.dp, borderColor) - ) { - Text( - "CLOSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -private val MD5_HASH_REGEX = Regex("^[0-9a-f]{32}$") - -private fun licenseDisplayLabel(license: License): String { - license.spdxId?.takeIf { it.isNotBlank() }?.let { return it } - val hash = license.hash - if (hash.isNotBlank() && !MD5_HASH_REGEX.matches(hash)) return hash - return license.name.ifBlank { "—" } -} - -private fun openUrl(url: String) { - try { - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { - Desktop.getDesktop().browse(URI.create(url)) - } - } catch (e: Exception) { - Logger.error("Failed to open url: $url", e) - } } // ── Shared building blocks ── @@ -1602,53 +707,6 @@ private fun OutputFolderSection( } } -@Composable -private fun ActionButton( - label: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, - mono: androidx.compose.ui.text.font.FontFamily, - borderColor: Color, - contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - enabled: Boolean = true, - onClick: () -> Unit -) { - val corners = LocalMorpheCorners.current - val hoverInteraction = remember { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - - OutlinedButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth().hoverable(hoverInteraction), - shape = RoundedCornerShape(corners.small), - border = BorderStroke( - 1.dp, - if (isHovered && enabled) contentColor.copy(alpha = 0.3f) - else borderColor - ), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = contentColor, - disabledContentColor = contentColor.copy(alpha = 0.4f) - ) - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(14.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - label, - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp, - modifier = Modifier.weight(1f) - ) - } -} - // ── Strip Libs Section ── @@ -1802,8 +860,41 @@ private fun SigningSection( val selected = File(dialog.directory, dialog.file) val validExtensions = listOf(".keystore", ".jks", ".bks", ".p12", ".pfx") if (validExtensions.any { selected.name.lowercase().endsWith(it) }) { - keystoreError = null - onKeystorePathChange(selected.absolutePath) + // Route the picked file through KeystoreImporter: + // BKS files pass through unchanged; PKCS12/JKS get + // converted to BKS and saved as MorpheData.importedKeystoreFile + // (original user file is never mutated). The config + // then points at whichever file is BKS — patcher + // only speaks BKS, so this is the only safe input. + val result = KeystoreImporter.ensureBks( + source = selected, + convertedOutput = MorpheData.importedKeystoreFile, + alias = keystoreAlias, + password = keystoreEntryPassword, + ) + when (result) { + is KeystoreImporter.Result.AlreadyBks -> { + keystoreError = null + onKeystorePathChange(result.file.absolutePath) + } + is KeystoreImporter.Result.Converted -> { + keystoreError = null + Logger.info( + "Converted ${result.sourceFormat.displayName} → BKS for ${selected.name}" + ) + onKeystorePathChange(result.file.absolutePath) + } + is KeystoreImporter.Result.Failed -> { + // Most common failure: wrong password. The + // user's configured entry password didn't + // match the source file. Surface inline so + // they can update it and re-import. + keystoreError = result.reason + result.cause?.let { + Logger.error("Keystore import failed for ${selected.name}", it) + } + } + } } else { keystoreError = "Invalid file type. Expected: ${validExtensions.joinToString(", ")}" } @@ -2555,45 +1646,6 @@ private fun ThemePreference.accentColor(): Color { } } -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 - FileUtils.getPatchesDir().listFiles()?.forEach { file -> - try { if (!file.deleteRecursively()) throw Exception("Could not delete") } - catch (e: Exception) { failedCount++; Logger.error("Failed to delete ${file.name}: ${e.message}") } - } - FileUtils.getLogsDir().listFiles()?.forEach { file -> - try { if (!file.deleteRecursively()) throw Exception("Could not delete") } - 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 - } -} - - // ── Patched App Runtime Logs Section ── private sealed interface RuntimeLogsStatus { diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt index 759275d..f53a2c2 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -21,7 +22,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragIndicator import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -31,9 +35,14 @@ import androidx.compose.animation.core.tween import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.zIndex import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -43,6 +52,7 @@ import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import java.io.File +import kotlin.math.roundToInt /** * Multi-source management sheet, summoned from the home header `+` button. @@ -61,6 +71,10 @@ import java.io.File */ enum class SourceSheetMode { MULTI_TOGGLE, SINGLE_SELECT } +/** 4-way move cursor shown over a source's drag handle so the grab affordance + * reads as "draggable", distinct from the plain hand used elsewhere. */ +private val DragMoveCursor = PointerIcon(java.awt.Cursor(java.awt.Cursor.MOVE_CURSOR)) + @Composable fun SourceManagementSheet( sources: List, @@ -70,6 +84,9 @@ fun SourceManagementSheet( onRemove: (id: String) -> Unit, onOpenPatches: (sourceId: String) -> Unit, onDismiss: () -> Unit, + /** Persist a new source ordering (ids in desired order). Order affects only + * the display-name tiebreak + UI presentation, not which patches load. */ + onReorder: (orderedIds: List) -> Unit = {}, enabled: Boolean = true, /** sourceId → resolved version label (e.g. "v1.27.0-dev.2"). Empty when not loaded. */ sourceVersions: Map = emptyMap(), @@ -94,6 +111,42 @@ fun SourceManagementSheet( var showAddDialog by remember { mutableStateOf(false) } var editingSource by remember { mutableStateOf(null) } + // ── Drag-to-reorder state ────────────────────────────────────────────── + // workingOrder is the live ordering the UI renders from; it reseeds only + // when the actual id sequence from config changes (List equals is + // structural), so a drag we just persisted doesn't get clobbered mid-flight. + val density = LocalDensity.current + val rowSpacingPx = with(density) { 8.dp.toPx() } + val sourcesById = remember(sources) { sources.associateBy { it.id } } + // Stable identity (no remember key) so the drag gesture's captured reference + // never goes stale. We instead adopt external order/membership changes via the + // effect below — but only while idle, so an in-flight drag is never clobbered. + var workingOrder by remember { mutableStateOf(sources.map { it.id }) } + val rowHeights = remember { mutableStateMapOf() } + var draggingId by remember { mutableStateOf(null) } + // Raw total cursor displacement since grab — never mutated mid-drag, so no + // drift accumulates. Visual offset is derived by subtracting the layout + // shift already applied via reordering (see dragOffsetY below). + var dragDeltaY by remember { mutableStateOf(0f) } + var dragStartIndex by remember { mutableStateOf(0) } + // Pull in source add/remove/rename/external-reorder — but never mid-drag, and + // only when the id sequence actually changed (keyed on the id list), so the + // order we just persisted from a drag doesn't trigger a snap-back. + LaunchedEffect(sources.map { it.id }) { + if (draggingId == null) workingOrder = sources.map { it.id } + } + val canReorder = enabled && sources.size > 1 + val orderedSources = workingOrder.mapNotNull { sourcesById[it] } + + fun commitMove(id: String, up: Boolean) { + val i = workingOrder.indexOf(id) + val target = if (up) i - 1 else i + 1 + if (i < 0 || target !in workingOrder.indices) return + val next = workingOrder.toMutableList().apply { add(target, removeAt(i)) } + workingOrder = next + onReorder(next) + } + AlertDialog( onDismissRequest = onDismiss, shape = RoundedCornerShape(corners.medium), @@ -138,7 +191,45 @@ fun SourceManagementSheet( Spacer(Modifier.height(4.dp)) - sources.forEach { source -> + orderedSources.forEachIndexed { index, source -> + // Key by source id (not list position) so a mid-drag reorder + // moves the same composable instead of rebinding slots — which + // would otherwise cancel the in-flight drag gesture. + key(source.id) { + val isDragging = source.id == draggingId + // One slot's pitch (row height + inter-row spacing). Rows vary + // slightly; the dragged row's own height is a fine unit and, + // crucially, the same value drives both the target-index pick + // and the visual offset, so they stay in lockstep. + val slotPitch = ((rowHeights[source.id] ?: 0) + rowSpacingPx).coerceAtLeast(1f) + val dragHandleModifier = if (canReorder) { + Modifier.pointerInput(source.id, canReorder) { + detectDragGestures( + onDragStart = { + draggingId = source.id + dragDeltaY = 0f + dragStartIndex = workingOrder.indexOf(source.id) + }, + onDragEnd = { draggingId = null; dragDeltaY = 0f; onReorder(workingOrder) }, + onDragCancel = { draggingId = null; dragDeltaY = 0f }, + onDrag = { change, dragAmount -> + change.consume() + dragDeltaY += dragAmount.y + val curIdx = workingOrder.indexOf(source.id) + val desired = (dragStartIndex + (dragDeltaY / slotPitch).roundToInt()) + .coerceIn(0, workingOrder.lastIndex) + if (desired != curIdx) { + workingOrder = workingOrder.toMutableList() + .apply { add(desired, removeAt(curIdx)) } + } + } + ) + } + } else Modifier + // Cursor displacement minus the layout shift already realised by + // reordering = the residual the row must translate to sit under + // the cursor. No running subtraction, so nothing drifts. + val dragOffsetY = if (isDragging) dragDeltaY - (index - dragStartIndex) * slotPitch else 0f SourceRow( source = source, version = sourceVersions[source.id], @@ -155,7 +246,18 @@ fun SourceManagementSheet( onEdit = { editingSource = source }, onRemove = { onRemove(source.id) }, onOpenPatches = { onOpenPatches(source.id) }, + canReorder = canReorder, + position = index + 1, + canMoveUp = index > 0, + canMoveDown = index < orderedSources.lastIndex, + onMoveUp = { commitMove(source.id, up = true) }, + onMoveDown = { commitMove(source.id, up = false) }, + dragHandleModifier = dragHandleModifier, + isDragging = isDragging, + dragOffsetY = dragOffsetY, + onMeasured = { h -> rowHeights[source.id] = h }, ) + } } Spacer(Modifier.height(2.dp)) @@ -251,6 +353,16 @@ private fun SourceRow( mode: SourceSheetMode, isActiveSelection: Boolean, onSelectSingle: () -> Unit, + canReorder: Boolean, + position: Int, + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + dragHandleModifier: Modifier, + isDragging: Boolean, + dragOffsetY: Float, + onMeasured: (heightPx: Int) -> Unit, ) { val corners = LocalMorpheCorners.current val hoverInteraction = remember(source.id) { MutableInteractionSource() } @@ -284,10 +396,17 @@ private fun SourceRow( Box( modifier = Modifier + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { translationY = dragOffsetY } + .onSizeChanged { onMeasured(it.height) } .fillMaxWidth() .clip(RoundedCornerShape(corners.medium)) - .border(1.dp, animatedBorder, RoundedCornerShape(corners.medium)) - .background(animatedBg) + .border( + 1.dp, + if (isDragging) accentColor.copy(alpha = 0.7f) else animatedBorder, + RoundedCornerShape(corners.medium) + ) + .background(if (isDragging) accentColor.copy(alpha = 0.10f) else animatedBg) .hoverable(hoverInteraction) .then( if (canInteract) Modifier @@ -298,6 +417,33 @@ private fun SourceRow( .padding(horizontal = 12.dp, vertical = 10.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { + if (canReorder) { + // Drag handle — the only grab point; the rest of the row stays + // click-to-open. Position number sits beside it for orientation. + Box( + modifier = Modifier + .pointerHoverIcon(DragMoveCursor) + .then(dragHandleModifier) + .size(18.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.DragIndicator, + contentDescription = "Drag to reorder", + tint = if (isDragging) accentColor + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(15.dp) + ) + } + Text( + text = position.toString(), + fontSize = 10.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.padding(start = 3.dp, end = 7.dp) + ) + } // LED indicator — glows when enabled (MULTI) or selected (SINGLE). LedIndicator(isOn = isHighlighted, isHot = isHovered && canInteract, accentColor = accentColor) Spacer(Modifier.width(10.dp)) @@ -380,6 +526,17 @@ private fun SourceRow( } } + // Precise fallback to dragging — nudge one slot at a time. + if (canReorder) { + ReorderArrows( + canMoveUp = canMoveUp, + canMoveDown = canMoveDown, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + accentColor = accentColor, + ) + Spacer(Modifier.width(2.dp)) + } // Edit + delete are hidden for default; toggle is always shown if (!isDefault && enabled) { IconButton(onClick = onEdit, modifier = Modifier.size(28.dp)) { @@ -426,6 +583,59 @@ private fun SourceRow( } +/** + * Compact vertical up/down nudge control — a keyboard-free, precise fallback to + * drag reordering. Arrows dim out at the list ends. + */ +@Composable +private fun ReorderArrows( + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + accentColor: Color, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ReorderArrow(Icons.Default.KeyboardArrowUp, "Move up", canMoveUp, accentColor, onMoveUp) + ReorderArrow(Icons.Default.KeyboardArrowDown, "Move down", canMoveDown, accentColor, onMoveDown) + } +} + +@Composable +private fun ReorderArrow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + active: Boolean, + accentColor: Color, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val isHovered by interaction.collectIsHoveredAsState() + Box( + modifier = Modifier + .size(width = 18.dp, height = 13.dp) + .then( + if (active) Modifier + .hoverable(interaction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + else Modifier + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = when { + !active -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + isHovered -> accentColor + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + modifier = Modifier.size(15.dp) + ) + } +} + @Composable private fun ChannelBadge( channel: app.morphe.gui.util.EnabledSourcesLoader.Channel?, diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ToolsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/ToolsButton.kt new file mode 100644 index 0000000..0ff179e --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/ToolsButton.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.PatchSourceManager +import app.morphe.gui.ui.theme.LocalMorpheCorners +import org.koin.compose.koinInject + +/** + * Tools button — peer of [SettingsButton]. Opens [ToolsDialog]. Wrench icon, + * same hover/border treatment as Settings. Sits to the LEFT of Settings in the + * top bar (actions left of preferences). + * + * @param allowCacheClear forwarded to [ToolsDialog] to gate the Clear Cache action. + */ +@Composable +fun ToolsButton( + modifier: Modifier = Modifier, + allowCacheClear: Boolean = true, +) { + val corners = LocalMorpheCorners.current + val patchSourceManager: PatchSourceManager = koinInject() + + var showToolsDialog by remember { mutableStateOf(false) } + + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + animationSpec = tween(150) + ) + + Box( + modifier = modifier + .size(34.dp) + .hoverable(hoverInteraction) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .clickable { showToolsDialog = true }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = "Tools", + tint = if (isHovered) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) + ) + } + + if (showToolsDialog) { + ToolsDialog( + onDismiss = { showToolsDialog = false }, + allowCacheClear = allowCacheClear, + onCacheCleared = { patchSourceManager.notifyCacheCleared() }, + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt new file mode 100644 index 0000000..422d8ab --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt @@ -0,0 +1,286 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.FileUtils +import app.morphe.gui.util.Logger +import java.awt.Desktop + +/** + * Tools dialog — peer of [SettingsDialog]. One-off actions (open logs, open app + * data, view licenses, clear cache) and reference info (version). Mirrors the + * [SettingsDialog] AlertDialog aesthetic. + * + * @param allowCacheClear when false the Clear Cache action is disabled (e.g. + * while patches are in use during selection). + * @param onCacheCleared invoked after a successful cache clear so hosts can + * refresh dependent state (e.g. patch source listings). + */ +@Composable +fun ToolsDialog( + onDismiss: () -> Unit, + allowCacheClear: Boolean = true, + onCacheCleared: () -> Unit = {}, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + + var showClearCacheConfirm by remember { mutableStateOf(false) } + var showLicensesDialog by remember { mutableStateOf(false) } + var cacheCleared by remember { mutableStateOf(false) } + var cacheClearFailed by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + text = "TOOLS", + fontWeight = FontWeight.Bold, + fontFamily = mono, + fontSize = 13.sp, + letterSpacing = 2.sp, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Column( + modifier = Modifier.widthIn(min = 340.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + ActionButton( + label = "OPEN LOGS", + icon = Icons.Default.BugReport, + mono = mono, + borderColor = borderColor, + onClick = { + try { + val logsDir = FileUtils.getLogsDir() + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(logsDir) + } + } catch (e: Exception) { + Logger.error("Failed to open logs folder", e) + } + } + ) + + Spacer(Modifier.height(6.dp)) + + ActionButton( + label = "OPEN APP DATA", + icon = Icons.Default.FolderOpen, + mono = mono, + borderColor = borderColor, + 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) + } + } + ) + + Spacer(Modifier.height(6.dp)) + + ActionButton( + label = "VIEW LICENSES", + icon = Icons.Default.Description, + mono = mono, + borderColor = borderColor, + onClick = { showLicensesDialog = true } + ) + + Spacer(Modifier.height(6.dp)) + + // Clear cache + val cacheColor = when { + cacheCleared -> MorpheColors.Teal + cacheClearFailed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.error + } + ActionButton( + label = when { + !allowCacheClear -> "CLEAR CACHE (DISABLED)" + cacheCleared -> "CACHE CLEARED" + cacheClearFailed -> "CLEAR FAILED" + else -> "CLEAR CACHE" + }, + icon = Icons.Default.Delete, + mono = mono, + borderColor = if (cacheCleared) MorpheColors.Teal.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.error.copy(alpha = 0.2f), + contentColor = cacheColor, + enabled = allowCacheClear && !cacheCleared, + onClick = { showClearCacheConfirm = true } + ) + + Spacer(Modifier.height(4.dp)) + + val cacheSize = calculateCacheSize() + Text( + text = "Cache: $cacheSize (patches + logs)", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + + Spacer(Modifier.height(14.dp)) + + // ── About ── + Text( + text = "${AppConstants.APP_NAME} ${AppConstants.APP_VERSION}", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + }, + confirmButton = { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) + ) { + Text( + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + + // Clear cache confirmation + if (showClearCacheConfirm) { + AlertDialog( + onDismissRequest = { showClearCacheConfirm = false }, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "CLEAR CACHE?", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Text( + "This will delete downloaded patches and log files. Patches will be re-downloaded when needed.", + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + }, + confirmButton = { + Button( + onClick = { + val success = clearAllCache() + cacheCleared = success + cacheClearFailed = !success + showClearCacheConfirm = false + if (success) onCacheCleared() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ), + shape = RoundedCornerShape(corners.small) + ) { + Text( + "CLEAR", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + }, + dismissButton = { + TextButton(onClick = { showClearCacheConfirm = false }) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) + } + + if (showLicensesDialog) { + LicensesDialog(onDismiss = { showLicensesDialog = false }) + } +} + +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 + FileUtils.getPatchesDir().listFiles()?.forEach { file -> + try { if (!file.deleteRecursively()) throw Exception("Could not delete") } + catch (e: Exception) { failedCount++; Logger.error("Failed to delete ${file.name}: ${e.message}") } + } + FileUtils.getLogsDir().listFiles()?.forEach { file -> + try { if (!file.deleteRecursively()) throw Exception("Could not delete") } + 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 index 6a56799..11c85bc 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -85,11 +85,19 @@ import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.morpheScrollbarStyle import kotlinx.coroutines.launch import org.koin.compose.koinInject +import app.morphe.engine.model.PatchedAppRecord import app.morphe.gui.ui.screens.home.components.ApkInfoCard +import app.morphe.gui.ui.screens.home.components.AppListFilter +import app.morphe.gui.ui.screens.home.components.AppListFilterChips import app.morphe.gui.ui.screens.home.components.FullScreenDropZone +import app.morphe.gui.ui.screens.home.components.MorpheDialogButton +import app.morphe.gui.ui.screens.home.components.MorpheDialogCard +import app.morphe.gui.ui.screens.home.components.MorpheDialogText +import app.morphe.gui.ui.screens.home.components.PatchedAppDetailDialog +import app.morphe.gui.ui.screens.home.components.PatchedUpdatesBanner import app.morphe.gui.ui.screens.home.components.SupportedAppListRow +import app.morphe.gui.ui.screens.home.components.YourAppRow import app.morphe.gui.ui.components.MorpheErrorBar -import app.morphe.gui.ui.components.OfflineBanner import app.morphe.gui.ui.components.UpdateBanner import app.morphe.gui.ui.screens.patches.PatchesScreen import app.morphe.gui.ui.screens.patches.PatchSelectionScreen @@ -118,6 +126,217 @@ fun HomeScreenContent( val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() + // Device install-state is polled (adb), not streamed — so re-query each time + // Home (re)appears. Without this, an app installed on another screen (or while + // away) shows stale "NOT ON THIS DEVICE" until the next full reload. + LaunchedEffect(Unit) { viewModel.refreshDeviceInfo() } + + // One-click repatch: a patched-app row's "Re-patch" action. Jump straight to + // patch selection with the input APK + the record's saved selection, using + // the CURRENT resolved sources (so it repatches against current bundle versions). + var repatchMissingRecord by remember { mutableStateOf(null) } + // Launch patch selection for a record with explicit patch files (re-patch uses + // the current resolved set; Update passes freshly-resolved latest files). + fun launchPatch( + record: app.morphe.engine.model.PatchedAppRecord, + apkPath: String, + patchFilePaths: List, + sourceNames: List, + ) { + if (patchFilePaths.isEmpty()) return // patches not loaded yet + navigator.push( + PatchSelectionScreen( + apkPath = apkPath, + apkName = record.displayName, + patchesFilePath = patchFilePaths.first(), + packageName = record.packageName, + patchesFilePaths = patchFilePaths, + patchSourceNames = sourceNames, + initialSelectionByBundle = record.patchSelectionByBundle, + initialPatchOptions = record.patchOptionValues, + ) + ) + } + + fun repatchWithApk(record: app.morphe.engine.model.PatchedAppRecord, apkPath: String) { + launchPatch( + record, apkPath, + viewModel.getAllResolvedPatchFiles().map { it.absolutePath }, + viewModel.getAllResolvedPatchSourceNames(), + ) + } + val onRepatch: (String) -> Unit = onRepatch@{ pkg -> + val record = viewModel.getPatchedRecord(pkg) ?: return@onRepatch + if (java.io.File(record.inputApkPath).exists()) { + repatchWithApk(record, record.inputApkPath) + } else { + repatchMissingRecord = record + } + } + + // Explicit "Forget" recovery action — removes a record from the history. + var forgetConfirm by remember { mutableStateOf(null) } + val onForget: (String) -> Unit = { pkg -> forgetConfirm = viewModel.getPatchedRecord(pkg) } + forgetConfirm?.let { record -> + MorpheDialogCard(onDismiss = { forgetConfirm = null }, title = "Forget ${record.displayName}?") { + MorpheDialogText( + "This removes ${record.displayName} from your patched-app history. " + + "It doesn't touch any files — re-patching the app adds it back." + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + forgetConfirm = null + } + MorpheDialogButton("FORGET", Color(0xFFE0504D), filled = true) { + viewModel.forgetPatchedApp(record.packageName) + forgetConfirm = null + } + } + } + } + + repatchMissingRecord?.let { record -> + MorpheDialogCard(onDismiss = { repatchMissingRecord = null }, title = "Original APK not found") { + MorpheDialogText( + "The input APK for ${record.displayName} is no longer at:\n" + + "${record.inputApkPath}\n\nSelect the APK again to re-patch with your saved settings." + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + repatchMissingRecord = null + } + MorpheDialogButton("SELECT APK…", LocalMorpheAccents.current.primary, filled = true) { + val fd = FileDialog(null as Frame?, "Select APK to re-patch", FileDialog.LOAD) + fd.isVisible = true + val picked = fd.file?.let { File(fd.directory, it) } + repatchMissingRecord = null + if (picked != null && picked.exists()) repatchWithApk(record, picked.absolutePath) + } + } + } + } + + // Phase 7 — tap a "Your apps" row to see the full recall breakdown. + var detailRecord by remember { mutableStateOf(null) } + val onShowDetail: (PatchedAppRecord) -> Unit = { detailRecord = it } + val onUpdate: (String) -> Unit = { pkg -> + viewModel.getPatchedRecord(pkg)?.let { viewModel.prepareUpdate(it) } + } + detailRecord?.let { record -> + val updateInfo = remember(record) { viewModel.recallUpdateInfo(record) } + PatchedAppDetailDialog( + record = record, + state = uiState.patchedStates[record.packageName] ?: PatchedAppState.PATCHED, + deviceInfo = uiState.deviceAppInfo[record.packageName], + updateInfo = updateInfo, + onDismiss = { detailRecord = null }, + onRepatch = { onRepatch(record.packageName) }, + onUpdate = { viewModel.prepareUpdate(record) }, + onForget = { onForget(record.packageName) }, + onOpenFolder = { + runCatching { + val parent = java.io.File(record.outputApkPath).parentFile + if (parent != null && parent.exists()) java.awt.Desktop.getDesktop().open(parent) + } + }, + onInstall = { viewModel.installPatchedApp(record.packageName) }, + installing = uiState.installingPackage == record.packageName, + ) + } + + // ── Update flow (Phase 7, issue 2c): resolve latest → maybe pick a newer APK ── + val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current + when (val prep = uiState.updatePrep) { + is UpdatePrep.Preparing -> MorpheDialogCard( + onDismiss = { viewModel.clearUpdatePrep() }, + title = "Preparing update…", + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = LocalMorpheAccents.current.primary, + ) + Spacer(Modifier.width(12.dp)) + MorpheDialogText("Resolving the latest patches…") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + viewModel.clearUpdatePrep() + } + } + } + is UpdatePrep.Failed -> MorpheDialogCard( + onDismiss = { viewModel.clearUpdatePrep() }, + title = "Update failed", + ) { + MorpheDialogText(prep.message) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("OK", LocalMorpheAccents.current.primary, filled = true) { + viewModel.clearUpdatePrep() + } + } + } + is UpdatePrep.Ready -> { + val record = viewModel.getPatchedRecord(prep.packageName) + if (record == null) { + viewModel.clearUpdatePrep() + } else { + // Patch with the latest files using either an existing or a picked APK. + fun launchWith(apkPath: String) { + viewModel.clearUpdatePrep() + if (File(apkPath).exists()) { + launchPatch(record, apkPath, prep.patchFilePaths, prep.sourceNames) + } else { + val fd = FileDialog(null as Frame?, "Select APK to patch", FileDialog.LOAD) + fd.isVisible = true + fd.file?.let { File(fd.directory, it) }?.takeIf { it.exists() } + ?.let { launchPatch(record, it.absolutePath, prep.patchFilePaths, prep.sourceNames) } + } + } + if (!prep.needsNewerApk) { + // APK still satisfies the latest patches → patch straight away. + LaunchedEffect(prep) { launchWith(record.inputApkPath) } + } else { + val targetV = prep.targetVersion?.removePrefix("v") ?: "newer" + MorpheDialogCard( + onDismiss = { viewModel.clearUpdatePrep() }, + title = "Update ${record.displayName}", + ) { + val usedV = record.apkVersion.removePrefix("v") + MorpheDialogText( + if (prep.currentSupported) { + "The latest patches add support for a newer app version (v$targetV). " + + "You can grab it, or keep using your v$usedV — your call." + } else { + "Your v$usedV is no longer supported by the latest patches. " + + "Get v$targetV to keep patching." + } + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("USE MY APK", LocalMorpheAccents.current.secondary, filled = false) { + launchWith(record.inputApkPath) + } + MorpheDialogButton("GET v$targetV", LocalMorpheAccents.current.primary, filled = true) { + val url = prep.downloadUrl + val r = record + val files = prep.patchFilePaths + val names = prep.sourceNames + viewModel.clearUpdatePrep() + if (url != null) uriHandler.openUri(url) + val fd = FileDialog(null as Frame?, "Select the v$targetV APK", FileDialog.LOAD) + fd.isVisible = true + fd.file?.let { File(fd.directory, it) }?.takeIf { it.exists() } + ?.let { launchPatch(r, it.absolutePath, files, names) } + } + } + } + } + } + } + null -> {} + } + val patchSourceManager: PatchSourceManager = koinInject() val allSources by patchSourceManager.allSources.collectAsState() val coroutineScope = rememberCoroutineScope() @@ -176,6 +395,14 @@ fun HomeScreenContent( onRemove = { id -> coroutineScope.launch { patchSourceManager.removeSource(id) } }, + onReorder = { orderedIds -> + coroutineScope.launch { + patchSourceManager.reorderSources(orderedIds) + // Reload so the union app list + display-name tiebreak reflect + // the new source priority. + viewModel.retryLoadPatches() + } + }, onOpenPatches = { sourceId -> // Hide sheet immediately so it doesn't ride the push animation. // Mark it as pending-reopen so it returns smoothly after pop. @@ -205,21 +432,13 @@ fun HomeScreenContent( modifier = Modifier .fillMaxSize() ) { - // Side-by-side layout: drop zone / APK info on the left, vertical - // supported-apps list on the right. Falls back to top/bottom on - // narrower windows. Hysteresis (switch up at 920dp, down at 880dp) - // prevents flicker when the user resizes near the threshold. - var splitLayoutState by remember { mutableStateOf(maxWidth >= 900.dp) } - splitLayoutState = when { - maxWidth >= 920.dp -> true - maxWidth < 880.dp -> false - else -> splitLayoutState - } - val useSplitLayout = splitLayoutState - val isCompact = maxWidth < 500.dp + // Single side-by-side layout: APK drop zone on one side, supported-apps + // list on the other. The window enforces a minimum width wide enough for + // it (see GuiMain), so there's no narrow/stacked variant to maintain. + // isSmall is kept for spacing only (short windows), not a separate layout. + val isCompact = false val isSmall = maxHeight < 600.dp - val padding = if (isCompact) 16.dp else 24.dp - val outerMaxWidth = maxWidth + val padding = 24.dp // Version warning dialog state var showVersionWarningDialog by remember { mutableStateOf(false) } @@ -248,8 +467,6 @@ fun HomeScreenContent( ) } - val useHorizontalHeader = maxWidth >= 600.dp - val pinSupportedAppsToBottom = useHorizontalHeader && maxHeight >= 760.dp val patchesLoaded = !uiState.isLoadingPatches && viewModel.getCachedPatchesFile() != null val onChangePatchesClick: () -> Unit = { navigator.push(PatchesScreen( @@ -314,8 +531,9 @@ fun HomeScreenContent( val sourceStates: List = allSources.map { src -> sourceLedState(src, channelsBySource[src.id]) } - val headerContent: @Composable ColumnScope.() -> Unit = { - if (useHorizontalHeader) { + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Pinned header (not scrollable) ── HeaderBar( uiState = uiState, isSmall = isSmall, @@ -325,145 +543,10 @@ fun HomeScreenContent( onManageSourcesClick = { showSourceManagementSheet = true }, sourceStates = sourceStates, ) - } else { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) - BrandingSection(isCompact = isCompact) - - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = uiState.patchesVersion!!, - latestLabel = uiState.latestPatchesLabel, - onChangePatchesClick = onChangePatchesClick, - patchSourceName = uiState.patchSourceName, - isCompact = isCompact - ) - } else if (uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesLoadingIndicator() - } else if (uiState.patchLoadError != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = "NOT LOADED", - latestLabel = null, - onChangePatchesClick = onChangePatchesClick, - isCompact = isCompact - ) - } - - if (uiState.isOffline && !uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - OfflineBanner( - onRetry = onRetry, - modifier = Modifier - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - ) - } - } - } - - val workspaceContent: @Composable (Modifier) -> Unit = { modifier -> - Box( - modifier = modifier - .fillMaxWidth() - .padding(padding), - contentAlignment = Alignment.Center - ) { - MiddleContent( - uiState = uiState, - isCompact = isCompact, - patchesLoaded = patchesLoaded, - onClearClick = onClearClick, - onChangeClick = onChangeClick, - onContinueClick = onContinueClick, - patchSourceNames = patchSourcesForSelectedApk, - ) - } - } - val supportedAppsContent: @Composable () -> Unit = { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding( - start = padding, - end = padding, - bottom = if (isSmall) 8.dp else 16.dp - ) - ) { - SupportedAppsSection( - isCompact = isCompact, - maxWidth = this@BoxWithConstraints.maxWidth, - isLoading = uiState.isLoadingPatches, - isDefaultSource = uiState.isDefaultSource, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = onRetry, - sourceNamesByPackage = sourceNamesByPackage, - ) - } - } - - Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { - // ── Pinned header (not scrollable) ── - if (useHorizontalHeader) { - HeaderBar( - uiState = uiState, - isSmall = isSmall, - onChangePatchesClick = onChangePatchesClick, - onRetry = onRetry, - onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, - onManageSourcesClick = { showSourceManagementSheet = true }, - sourceStates = sourceStates, - ) - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) - BrandingSection(isCompact = isCompact) - - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = uiState.patchesVersion!!, - latestLabel = uiState.latestPatchesLabel, - onChangePatchesClick = onChangePatchesClick, - isCompact = isCompact - ) - } else if (uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesLoadingIndicator() - } else if (uiState.patchLoadError != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = "NOT LOADED", - latestLabel = null, - onChangePatchesClick = onChangePatchesClick, - isCompact = isCompact - ) - } - - // Offline banner - if (uiState.isOffline && !uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - OfflineBanner( - onRetry = onRetry, - modifier = Modifier - .widthIn(max = 400.dp) - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - ) - } - } - } - - // ── Body ── - if (useSplitLayout) { - // Side-by-side: drop zone / APK info on the left, - // vertical supported-apps list on the right. The list pane - // owns its own scroll; the rest stays static. - Column(modifier = Modifier.weight(1f).fillMaxWidth()) { + // ── Body: drop zone / APK info on one side, supported-apps + // list on the other. The list pane owns its own scroll. ── + Column(modifier = Modifier.weight(1f).fillMaxWidth()) { if (uiState.showUpdateBanner) { UpdateBanner( info = uiState.updateInfo!!, @@ -500,6 +583,18 @@ fun HomeScreenContent( // Left: browse/discover supported apps (wizard step 1). SupportedAppsListPane( supportedApps = uiState.supportedApps, + patchedStates = uiState.patchedStates, + patchedRecords = uiState.patchedRecords, + deviceAppInfo = uiState.deviceAppInfo, + updateInfoByPackage = uiState.updateInfoByPackage, + onRepatch = onRepatch, + onForget = onForget, + onUpdate = onUpdate, + onInstall = { viewModel.installPatchedApp(it) }, + installingPackage = uiState.installingPackage, + onShowDetail = onShowDetail, + filter = uiState.appListFilter, + onFilterChange = { viewModel.setAppListFilter(it) }, sourceNamesByPackage = sourceNamesByPackage, isLoading = uiState.isLoadingPatches, loadError = uiState.patchLoadError, @@ -537,105 +632,9 @@ fun HomeScreenContent( } } } - } - } else { - // ── Scrollable top/bottom body (narrow windows) ── - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) { - val bodyMaxHeight = this.maxHeight - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .heightIn(min = bodyMaxHeight), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = if (pinSupportedAppsToBottom) Arrangement.SpaceBetween else Arrangement.Top, - ) { - if (uiState.showUpdateBanner) { - UpdateBanner( - info = uiState.updateInfo!!, - onDismissForSession = { viewModel.dismissUpdateForSession() }, - onDismissForVersion = { viewModel.dismissUpdateForVersion() }, - modifier = Modifier - .fillMaxWidth() - .padding(start = padding, end = padding, top = 8.dp), - ) - } - if (uiState.showMultiSourceHint) { - MultiSourceHintBanner( - onDismiss = { viewModel.dismissMultiSourceHint() }, - modifier = Modifier - .fillMaxWidth() - .padding(start = padding, end = padding, top = 8.dp), - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(padding), - contentAlignment = Alignment.Center, - ) { - MiddleContent( - uiState = uiState, - isCompact = isCompact, - patchesLoaded = patchesLoaded, - onClearClick = onClearClick, - onChangeClick = onChangeClick, - onContinueClick = onContinueClick, - patchSourceNames = patchSourcesForSelectedApk, - ) - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding( - start = padding, - end = padding, - bottom = if (isSmall) 8.dp else 16.dp, - ), - ) { - SupportedAppsSection( - isCompact = isCompact, - maxWidth = outerMaxWidth, - isLoading = uiState.isLoadingPatches, - isDefaultSource = uiState.isDefaultSource, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = onRetry, - sourceNamesByPackage = sourceNamesByPackage, - ) - } - } - - if (scrollState.maxValue > 0) { - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter(scrollState), - style = morpheScrollbarStyle(), - ) - } - } } } - // Top bar — only floated when not using horizontal header - if (!useHorizontalHeader) { - TopBarRow( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = padding, end = padding), - allowCacheClear = true, - onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, - ) - } - // Error/warning bar — custom Morphe-styled, avoids Material3 // SnackbarHost (whose internal SnackbarKt invocation path the // shadow `minimize` analyzer can't trace, causing runtime @@ -1278,6 +1277,18 @@ private fun AnalyzingSection(isCompact: Boolean = false) { @Composable private fun SupportedAppsListPane( supportedApps: List, + patchedStates: Map = emptyMap(), + patchedRecords: List = emptyList(), + deviceAppInfo: Map = emptyMap(), + updateInfoByPackage: Map = emptyMap(), + onRepatch: (String) -> Unit = {}, + onForget: (String) -> Unit = {}, + onUpdate: (String) -> Unit = {}, + onInstall: (String) -> Unit = {}, + installingPackage: String? = null, + onShowDetail: (PatchedAppRecord) -> Unit = {}, + filter: AppListFilter = AppListFilter.ALL, + onFilterChange: (AppListFilter) -> Unit = {}, sourceNamesByPackage: Map>, isLoading: Boolean, loadError: String?, @@ -1297,6 +1308,12 @@ private fun SupportedAppsListPane( it.displayName.contains(searchQuery, ignoreCase = true) || it.packageName.contains(searchQuery, ignoreCase = true) } + val filteredRecords = if (searchQuery.isBlank()) patchedRecords + else patchedRecords.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + } + val activeCount = if (filter == AppListFilter.YOURS) patchedRecords.size else supportedApps.size // Collapse if the currently expanded app filters out. LaunchedEffect(searchQuery, filtered) { @@ -1313,34 +1330,22 @@ private fun SupportedAppsListPane( .wrapContentHeight() .align(Alignment.Center), ) { - // ── Header row: SUPPORTED APPS · count ── - // end = 12.dp matches the LazyColumn's right padding so "X apps" - // visually aligns with the right edge of the cards. - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(end = 12.dp, bottom = 4.dp), - ) { - Text( - text = "SUPPORTED APPS", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - letterSpacing = 1.5.sp, - color = homeMutedTextColor(0.4f), - ) - Spacer(Modifier.weight(1f)) - if (!isLoading && supportedApps.isNotEmpty()) { - Text( - text = "${supportedApps.size} apps", - fontSize = 9.sp, - fontFamily = mono, - color = homeMutedTextColor(0.4f), - ) - } + // ── On-open update notice: jumps to "Your apps" where each is badged ── + val updateCount = patchedStates.values.count { it == PatchedAppState.PATCHED_WITH_UPDATES } + if (filter == AppListFilter.ALL && updateCount > 0) { + PatchedUpdatesBanner(updateCount) { onFilterChange(AppListFilter.YOURS) } } + // ── Filter: ALL APPS · YOUR APPS ── + AppListFilterChips( + filter = filter, + onSelect = onFilterChange, + allCount = supportedApps.size, + yourCount = patchedRecords.size, + ) + // ── Search field ── - if (supportedApps.size > 4) { + if (activeCount > 4) { // Match the LazyColumn's right padding so the field aligns with cards. // Dp.Unspecified disables the default 340dp cap so the field fills // the pane width like the cards below it. @@ -1357,7 +1362,24 @@ private fun SupportedAppsListPane( Spacer(modifier = Modifier.height(10.dp)) } - when { + if (filter == AppListFilter.YOURS) { + YourAppsListBody( + patchedRecords = patchedRecords, + filteredRecords = filteredRecords, + searchQuery = searchQuery, + patchedStates = patchedStates, + deviceAppInfo = deviceAppInfo, + updateInfoByPackage = updateInfoByPackage, + onShowDetail = onShowDetail, + onRepatch = onRepatch, + onUpdate = onUpdate, + onForget = onForget, + onInstall = onInstall, + installingPackage = installingPackage, + paneMaxHeight = paneMaxHeight, + showSearch = activeCount > 4, + ) + } else when { isLoading -> { Column( modifier = Modifier.fillMaxWidth().padding(end = 12.dp), @@ -1458,6 +1480,8 @@ private fun SupportedAppsListPane( else app.packageName }, patchSourceNames = sourceNamesByPackage[app.packageName] ?: emptyList(), + patchedState = patchedStates[app.packageName] ?: PatchedAppState.NEVER_PATCHED, + deviceInfo = deviceAppInfo[app.packageName], ) } } @@ -1483,186 +1507,103 @@ private fun SupportedAppsListPane( } } +/** + * "Your apps" list body — the patched-app history (Phase 7). Same scroll/scrollbar + * treatment as the supported-apps list, but rows are [YourAppRow]s sourced from the + * records (not the supported-apps list), so apps patched via a since-removed source + * still appear. Tapping a row opens the detail dialog. + */ @Composable -private fun SupportedAppsSection( - isCompact: Boolean = false, - maxWidth: Dp = 800.dp, - isLoading: Boolean = false, - isDefaultSource: Boolean = true, - supportedApps: List = emptyList(), - loadError: String? = null, - onRetry: () -> Unit = {}, - /** packageName → source display names contributing patches. Used to badge - * cards with their source attribution in multi-source mode. */ - sourceNamesByPackage: Map> = emptyMap(), +private fun YourAppsListBody( + patchedRecords: List, + filteredRecords: List, + searchQuery: String, + patchedStates: Map, + deviceAppInfo: Map, + updateInfoByPackage: Map, + onShowDetail: (PatchedAppRecord) -> Unit, + onRepatch: (String) -> Unit, + onUpdate: (String) -> Unit, + onForget: (String) -> Unit, + onInstall: (String) -> Unit, + installingPackage: String?, + paneMaxHeight: Dp, + showSearch: Boolean, ) { - val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val useVerticalLayout = maxWidth < 400.dp + when { + patchedRecords.isEmpty() -> YourAppsEmptyHint( + title = "NO PATCHED APPS YET", + subtitle = "Patch an app and it shows up here.", + mono = mono, + ) + filteredRecords.isEmpty() -> YourAppsEmptyHint( + title = "NO MATCHES", + subtitle = "Nothing matches \"$searchQuery\".", + mono = mono, + ) + else -> { + val listState = rememberLazyListState() + val headerSearchAllowance = if (showSearch) 80.dp else 34.dp + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = (paneMaxHeight - headerSearchAllowance).coerceAtLeast(120.dp)), + ) { + androidx.compose.foundation.lazy.LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(items = filteredRecords, key = { it.packageName }) { record -> + YourAppRow( + record = record, + state = patchedStates[record.packageName] ?: PatchedAppState.PATCHED, + deviceInfo = deviceAppInfo[record.packageName], + updateInfo = updateInfoByPackage[record.packageName], + onClick = { onShowDetail(record) }, + onRepatch = { onRepatch(record.packageName) }, + onUpdate = { onUpdate(record.packageName) }, + onForget = { onForget(record.packageName) }, + onInstall = { onInstall(record.packageName) }, + installing = installingPackage == record.packageName, + ) + } + } + Box(modifier = Modifier.matchParentSize(), contentAlignment = Alignment.CenterEnd) { + VerticalScrollbar( + modifier = Modifier.fillMaxHeight(), + adapter = rememberScrollbarAdapter(listState), + style = morpheScrollbarStyle(), + ) + } + } + } + } +} +@Composable +private fun YourAppsEmptyHint(title: String, subtitle: String, mono: androidx.compose.ui.text.font.FontFamily) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), ) { Text( - text = "SUPPORTED APPS", - fontSize = if (isCompact) 10.sp else 11.sp, + text = title, + fontSize = 11.sp, fontWeight = FontWeight.Bold, fontFamily = mono, - color = homeMutedTextColor(0.7f), - letterSpacing = 3.sp + letterSpacing = 1.sp, + color = homeMutedTextColor(0.55f), ) - - Spacer(modifier = Modifier.height(6.dp)) - + Spacer(Modifier.height(6.dp)) Text( - text = if (isDefaultSource) "Download the exact version from APKMirror and drop it here." - else "Drop the APK for a supported app here.", - fontSize = if (isCompact) 10.sp else 11.sp, + text = subtitle, + fontSize = 11.sp, fontFamily = mono, - fontWeight = FontWeight.Normal, - color = homeMutedTextColor(0.5f), + color = homeMutedTextColor(0.4f), 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 -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(32.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "Loading patches...", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.5f) - ) - } - } - loadError != null -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(16.dp) - ) { - Text( - text = "LOAD FAILED", - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.error, - letterSpacing = 1.sp - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = loadError, - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.6f), - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton( - onClick = onRetry, - shape = RoundedCornerShape(corners.small), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) - ) { - Text( - "RETRY", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 1.sp - ) - } - } - } - supportedApps.isEmpty() -> { - Text( - text = "No supported apps found", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.5f) - ) - } - else -> { - val focusManager = LocalFocusManager.current - var searchQuery by remember { mutableStateOf("") } - val filteredApps = if (searchQuery.isBlank()) supportedApps - else supportedApps.filter { - it.displayName.contains(searchQuery, ignoreCase = true) || - it.packageName.contains(searchQuery, ignoreCase = true) - } - - if (supportedApps.size > 4) { - SlimSearchField( - value = searchQuery, - onValueChange = { searchQuery = it }, - mono = mono, - corners = corners, - accents = accents - ) - Spacer(modifier = Modifier.height(12.dp)) - } - - var selectedApp by remember { mutableStateOf(null) } - // Clear selection if the selected app is filtered out - LaunchedEffect(searchQuery, filteredApps) { - if (selectedApp != null && filteredApps.none { it.packageName == selectedApp?.packageName }) { - selectedApp = null - } - } - - if (filteredApps.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "No matching apps", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.3f) - ) - } - } else { - SupportedAppsMasterDetail( - apps = filteredApps, - selectedApp = selectedApp, - onSelect = { app -> - selectedApp = if (selectedApp?.packageName == app.packageName) null else app - }, - onClose = { selectedApp = null }, - isDefaultSource = isDefaultSource, - useVerticalLayout = useVerticalLayout, - sourceNamesByPackage = sourceNamesByPackage, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { focusManager.clearFocus() } - ) - } - } - } } } @@ -1694,89 +1635,6 @@ private fun homeAccentTextColor(accent: Color): Color { return accent } -@Composable -private fun PatchesVersionCard( - patchesVersion: String, - latestLabel: String?, - onChangePatchesClick: () -> Unit, - patchSourceName: String? = null, - isCompact: Boolean = false, - modifier: Modifier = Modifier -) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val hoverInteraction = remember { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - val borderColor by animateColorAsState( - if (isHovered) accents.primary.copy(alpha = 0.4f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), - animationSpec = tween(200) - ) - - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(corners.medium)) - .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) - .background(MaterialTheme.colorScheme.surface) - .hoverable(hoverInteraction) - .clickable(onClick = onChangePatchesClick) - ) { - // Source name + version + badge — single row - Row( - modifier = Modifier - .padding(vertical = if (isCompact) 8.dp else 10.dp) - .padding(start = 12.dp, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = patchSourceName?.uppercase() ?: "PATCHES", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - letterSpacing = 1.5.sp - ) - Text( - text = " · ", - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) - ) - Text( - text = patchesVersion, - fontSize = if (isCompact) 12.sp else 13.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = mono, - color = accents.primary - ) - if (latestLabel != null) { - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = Modifier - .background(accents.secondary.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) - .border(1.dp, accents.secondary.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text( - text = latestLabel, - fontSize = 8.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accents.secondary, - letterSpacing = 1.sp - ) - } - } - } - } - } -} - @Composable private fun VersionWarningDialog( versionStatus: VersionStatus, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 3ee85b9..896d5c2 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -7,7 +7,11 @@ package app.morphe.gui.ui.screens.home import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import app.morphe.engine.MorpheData +import app.morphe.engine.PatchedAppStore import app.morphe.engine.UpdateInfo +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.util.SignatureIdentity import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository @@ -20,10 +24,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import app.morphe.engine.util.ApkManifestReader +import app.morphe.gui.util.AdbManager +import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger @@ -39,6 +47,8 @@ class HomeViewModel( private val patchService: PatchService, private val configRepository: ConfigRepository, private val updateCheckRepository: UpdateCheckRepository, + private val patchedAppStore: PatchedAppStore, + private val adbManager: AdbManager = AdbManager(), ) : ScreenModel { private var patchRepository: PatchRepository = patchSourceManager.getActiveRepositorySync() @@ -82,9 +92,28 @@ class HomeViewModel( updateInfo = info, dismissedUpdateVersion = dismissed, showMultiSourceHint = multiSourceShouldShow, + appListFilter = runCatching { + app.morphe.gui.ui.screens.home.components.AppListFilter.valueOf(config.homeAppListFilter) + }.getOrDefault(app.morphe.gui.ui.screens.home.components.AppListFilter.ALL), ) } + // React to history changes (a patch just completed, a record forgotten) + // so badges + device state update immediately — no leave-and-return needed. + screenModelScope.launch { + patchedAppStore.changes.collect { refreshPatchedState() } + } + + // Optional device layer: when the selected ADB device changes (connect, + // disconnect, authorize), refresh which patched apps are installed on it. + // distinctUntilChanged on (id, ready) avoids re-querying on noisy emits. + screenModelScope.launch { + DeviceMonitor.state + .map { it.selectedDevice?.id to (it.selectedDevice?.isReady == true) } + .distinctUntilChanged() + .collect { refreshDeviceInfo() } + } + // Load patches whenever EXPERT becomes the active mode. StateFlow // emits its current value on subscribe, so this also covers the // "VM was just created while EXPERT is active" case — replaces the @@ -167,6 +196,97 @@ class HomeViewModel( } } + /** + * Begin an "Update" for [record]: resolve the LATEST patch files (ignoring any + * pinned version — this run only, leaving global config untouched), then work + * out whether the user's patched APK version still satisfies what the latest + * patches target. Result lands in [HomeUiState.updatePrep] for the screen to act on. + */ + fun prepareUpdate(record: PatchedAppRecord) { + _uiState.value = _uiState.value.copy(updatePrep = UpdatePrep.Preparing(record.packageName)) + screenModelScope.launch { + try { + val enabled = patchSourceManager.getEnabledRepositories() + // emptyMap() preferred versions → each source resolves to its latest + // release (the pin override is scoped to this call; config is untouched). + val result = EnabledSourcesLoader.loadAll(enabled, patchService, emptyMap()) + val resolvedOk = result.resolved.filter { it.patchFile != null } + val files = resolvedOk.mapNotNull { it.patchFile?.absolutePath } + if (files.isEmpty()) { + _uiState.value = _uiState.value.copy( + updatePrep = UpdatePrep.Failed(record.packageName, "Couldn't resolve the latest patches (offline?)."), + ) + return@launch + } + val names = resolvedOk.map { it.source.name } + val apps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + // Use the LATEST patch's supported versions to pick the channel-appropriate + // target — so a newer experimental app version a newer patch introduces is + // offered, even though the old version has rolled off the experimental list. + val app = apps.find { it.packageName == record.packageName } + val (target, _) = suggestedAppVersion(app, record.apkVersion) + val needsNewerApk = isNewerVersion(target, record.apkVersion) + val currentSupported = app == null || app.recommendedVersion == null || + app.supportedVersions.any { it.equals(record.apkVersion, ignoreCase = true) } || + app.experimentalVersions.any { it.equals(record.apkVersion, ignoreCase = true) } + val downloadUrl = if (needsNewerApk && target != null && app != null) { + app.let { SupportedApp.getDownloadUrl(it.packageName, target) } + } else null + _uiState.value = _uiState.value.copy( + updatePrep = UpdatePrep.Ready( + packageName = record.packageName, + patchFilePaths = files, + sourceNames = names, + targetVersion = target, + needsNewerApk = needsNewerApk, + currentSupported = currentSupported, + downloadUrl = downloadUrl, + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + updatePrep = UpdatePrep.Failed(record.packageName, e.message ?: "Update preparation failed"), + ) + } + } + } + + fun clearUpdatePrep() { + if (_uiState.value.updatePrep != null) _uiState.value = _uiState.value.copy(updatePrep = null) + } + + /** + * Install the already-patched output APK for [packageName] onto the selected + * device (no re-patch needed). On completion, refresh the device layer so the + * "install pending" badge clears the moment the device reports the new version. + */ + fun installPatchedApp(packageName: String) { + val record = patchedRecordsByPackage[packageName] ?: return + val device = DeviceMonitor.state.value.selectedDevice ?: return + if (!device.isReady || _uiState.value.installingPackage != null) return + _uiState.value = _uiState.value.copy(installingPackage = packageName) + screenModelScope.launch { + // Always record a non-Play installer so the Play Store won't clobber + // the patched app with an official update. + val installer = adbManager.resolveSpoofInstaller(device.id) + val result = adbManager.installApk(record.outputApkPath, device.id, installerPackage = installer) + _uiState.value = _uiState.value.copy( + installingPackage = null, + error = result.exceptionOrNull()?.let { "Install failed: ${it.message}" } ?: _uiState.value.error, + ) + refreshDeviceInfo() + } + } + + /** Switch the home apps tab (ALL/YOURS) and remember it for next launch. */ + fun setAppListFilter(filter: app.morphe.gui.ui.screens.home.components.AppListFilter) { + if (_uiState.value.appListFilter == filter) return + _uiState.value = _uiState.value.copy(appListFilter = filter) + screenModelScope.launch { configRepository.setHomeAppListFilter(filter.name) } + } + /** * Hide the update banner persistently for the current available version. * The banner will reappear automatically when an even newer version becomes @@ -260,17 +380,24 @@ class HomeViewModel( "${result.resolved.count { it.patchFile != null }} sources" } + val patchedStates = computePatchedStates(supportedApps) + latestResolvedApps = null // fresh load — drop any stale eager-resolved apps _uiState.value = _uiState.value.copy( isLoadingPatches = false, isOffline = isOffline, supportedApps = supportedApps, + patchedStates = patchedStates, + patchedRecords = sortedPatchedRecords(), + updateInfoByPackage = buildUpdateInfoMap(supportedApps), patchesVersion = displayVersion, latestPatchesVersion = displayVersion, latestDevPatchesVersion = null, patchSourceName = sourceName, patchLoadError = null ) + refreshDeviceInfo() // records just (re)loaded — refresh the optional device layer reanalyzeSelectedApk() + eagerlyResolveLatestApps() // upgrade update-info to the LATEST patch's app versions } catch (e: CancellationException) { // Cancellation is normal coroutine bookkeeping (a newer load // superseded this one, or the screen left composition). Do NOT @@ -287,6 +414,298 @@ class HomeViewModel( } } + /** + * Cross-reference the patched-app history with the supported-apps list to + * compute a per-package recall state for home-screen badges. v1 distinguishes + * "never patched / patched / patched-but-output-APK-missing"; "update + * available" detection is a later phase. Best-effort — failures yield no badges. + */ + /** Last-loaded patched-app records, keyed by package. Powers one-click repatch. */ + private var patchedRecordsByPackage: Map = emptyMap() + + /** The patched-app record for [packageName], or null if never patched. */ + fun getPatchedRecord(packageName: String): PatchedAppRecord? = + patchedRecordsByPackage[packageName] + + /** + * Compute per-source patch-file freshness + app-version freshness for [record], + * comparing the snapshot it was patched with against the currently resolved + * sources and the supported app's recommended/experimental versions. The app + * suggestion stays in the channel the user patched on (stable vs experimental). + */ + fun recallUpdateInfo(record: PatchedAppRecord): RecallUpdateInfo = + recallUpdateInfo(record, _uiState.value.supportedApps) + + /** All records → their update info; precomputed for the list/cards (avoids + * recomputing per recomposition). [apps] passed explicitly so it can be built + * from a freshly-loaded list before it lands in uiState. */ + private fun buildUpdateInfoMap(apps: List): Map = + patchedRecordsByPackage.values.associate { it.packageName to recallUpdateInfo(it, apps) } + + // supportedApps parsed from the LATEST patches (eagerly resolved when a newer + // patch exists), so the UI shows the real future app version without tapping Update. + private var latestResolvedApps: List? = null + + /** + * When a newer patch than the loaded one exists, resolve+download the latest + * patches in the background, parse their supported app versions, and rebuild + * [HomeUiState.updateInfoByPackage] against them — so the card/dialog can show + * "App vX → vY" up front. Best-effort; failures keep the loaded-patch info. + */ + private fun eagerlyResolveLatestApps() { + val anyBehind = cachedSourcesResult?.resolved?.any { + it.patchFile != null && it.resolvedVersion != null && + isNewerVersion(it.latestAvailableVersion ?: it.resolvedVersion, it.resolvedVersion) + } == true + if (!anyBehind || patchedRecordsByPackage.isEmpty()) return + screenModelScope.launch { + try { + val enabled = patchSourceManager.getEnabledRepositories() + val result = EnabledSourcesLoader.loadAll(enabled, patchService, emptyMap()) + val apps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + latestResolvedApps = apps + _uiState.value = _uiState.value.copy(updateInfoByPackage = buildUpdateInfoMap(apps)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.error("Eager latest-patch resolve failed", e) + } + } + } + + private fun recallUpdateInfo( + record: PatchedAppRecord, + apps: List, + ): RecallUpdateInfo { + val resolvedBySource = resolvedVersionBySource() // what Re-patch will use right now + val latestBySource = latestAvailableBySource() // newest available (may need downloading) + val sources = record.sourcesSnapshot + // Only sources that actually contributed patches. The selection map has an + // (empty) entry per enabled bundle, so an enabled-but-unused source has an + // empty set → drop it. Null = key mismatch/old record → keep (don't hide). + .filter { snap -> + val sel = record.patchSelectionByBundle[snap.sourceName] + sel == null || sel.isNotEmpty() + } + .map { snap -> + val latest = latestBySource[snap.sourceName] + RecallUpdateInfo.SourceUpdate( + name = snap.sourceName, + usedVersion = snap.version, + resolvedVersion = resolvedBySource[snap.sourceName], + latestAvailableVersion = latest, + outdated = isNewerVersion(latest, snap.version), + ) + } + val app = apps.find { it.packageName == record.packageName } + val used = record.apkVersion + val (suggested, channel) = suggestedAppVersion(app, used) + val latestStable = app?.recommendedVersion + // Supported if the patch targets any version (recommendedVersion null), or the + // used version is in its stable/experimental lists. Unknown app → assume yes. + val usedSupported = app == null || app.recommendedVersion == null || + app.supportedVersions.any { it.equals(used, ignoreCase = true) } || + app.experimentalVersions.any { it.equals(used, ignoreCase = true) } + return RecallUpdateInfo( + sources = sources, + appUsedVersion = used, + appChannel = channel, + appSuggestedVersion = suggested, + appOutdated = isNewerVersion(suggested, used), + appUsedSupported = usedSupported, + latestStableVersion = latestStable, + stableUpdateAvailable = isNewerVersion(latestStable, used), + ) + } + + /** + * The app version a re-patch should aim for, staying on the channel the user + * patched on. **Experimental track** = the patched version is in the experimental + * list OR is already newer than the latest stable (i.e. they're ahead of stable). + * Returns (targetVersion, channel). + * + * Crucially this keys off the channel, not exact membership of the OLD version in + * the NEW patch's lists — so when a newer patch introduces a newer experimental + * app version (e.g. patch 1.30 adds YouTube 21.21.80) it's still suggested even + * though the user's 21.20.400 has rolled off the experimental list. + */ + private fun suggestedAppVersion( + app: app.morphe.gui.data.model.SupportedApp?, + used: String, + ): Pair { + if (app == null) return null to RecallUpdateInfo.AppChannel.UNKNOWN + val latestStable = app.recommendedVersion + val latestExperimental = app.experimentalVersions.firstOrNull() + val onExperimental = app.experimentalVersions.any { it.equals(used, ignoreCase = true) } || + (latestStable != null && isNewerVersion(used, latestStable)) + return if (onExperimental) { + (latestExperimental ?: latestStable) to RecallUpdateInfo.AppChannel.EXPERIMENTAL + } else { + (latestStable ?: latestExperimental) to RecallUpdateInfo.AppChannel.STABLE + } + } + + /** + * Explicitly remove [packageName] from the patched-app history and refresh + * the badges. The only way a record leaves the store — we never auto-delete. + * Touches no files; re-patching the app recreates the record. + */ + fun forgetPatchedApp(packageName: String) { + // delete() emits a change → the store observer refreshes badges/device state. + screenModelScope.launch { patchedAppStore.delete(packageName) } + } + + /** + * Recompute badges + device state from the current store contents, reusing the + * already-loaded supported-apps list. Cheap (reads the in-memory store cache) — + * this is the live-refresh path, distinct from a full patches reload. + */ + private fun refreshPatchedState() { + screenModelScope.launch { + val states = computePatchedStates(_uiState.value.supportedApps) + _uiState.value = _uiState.value.copy( + patchedStates = states, + patchedRecords = sortedPatchedRecords(), + // Reuse the eagerly-resolved latest apps if we have them, so a store + // change (patch/forget) doesn't drop the accurate future versions. + updateInfoByPackage = buildUpdateInfoMap(latestResolvedApps ?: _uiState.value.supportedApps), + ) + refreshDeviceInfo() + } + } + + /** The history as a list, most-recently-patched first (for the "Your apps" surface). */ + private fun sortedPatchedRecords(): List = + patchedRecordsByPackage.values.sortedByDescending { it.patchedAt } + + /** source name → version currently resolved/downloaded (what Re-patch uses now). */ + private fun resolvedVersionBySource(): Map = + cachedSourcesResult?.resolved + ?.filter { it.patchFile != null } + ?.associate { it.source.name to it.resolvedVersion } + ?: emptyMap() + + /** source name → newest available version (falls back to resolved when unknown/offline). */ + private fun latestAvailableBySource(): Map = + cachedSourcesResult?.resolved + ?.filter { it.patchFile != null } + ?.associate { it.source.name to (it.latestAvailableVersion ?: it.resolvedVersion) } + ?: emptyMap() + + private suspend fun computePatchedStates( + apps: List, + ): Map = try { + val records = patchedAppStore.getAll().associateBy { it.packageName } + patchedRecordsByPackage = records + // Compare each record's patch-time snapshot against the LATEST AVAILABLE + // source version (not just what's currently downloaded) so "update + // available" surfaces without the user first selecting the newer file. + val latestBySource = latestAvailableBySource() + apps.associate { app -> + val record = records[app.packageName] + val output = record?.let { File(it.outputApkPath) } + // "Update available" = a newer patch-source version (vs the snapshot) OR a + // newer recommended stable app version than what was patched. Either is + // worth re-patching, so both surface the same badge/notification. + val sourceUpdate = record?.hasAvailableUpdate(latestBySource) == true + val appUpdate = record != null && + app.recommendedVersion?.let { isNewerVersion(it, record.apkVersion) } == true + app.packageName to when { + record == null -> PatchedAppState.NEVER_PATCHED + output?.exists() != true -> PatchedAppState.APK_MISSING + // Cheap integrity check: a re-signed/re-built APK changes size. + // (The stored sha256 is kept for certain on-demand + device verify.) + record.outputApkSize > 0 && output.length() != record.outputApkSize -> + PatchedAppState.MODIFIED_EXTERNALLY + sourceUpdate || appUpdate -> PatchedAppState.PATCHED_WITH_UPDATES + else -> PatchedAppState.PATCHED + } + } + } catch (e: Exception) { + Logger.error("Failed to compute patched-app states", e) + emptyMap() + } + + /** + * Refresh the optional device layer: for each patched record, ask the + * connected device whether it's installed and at what version. Reliable + + * version-robust (`pm list packages` / `versionName=`). No device / not + * ready → clears the info (the offline JSON view stands on its own). + */ + fun refreshDeviceInfo() { + screenModelScope.launch { + val device = DeviceMonitor.state.value.selectedDevice + if (device == null || !device.isReady) { + if (_uiState.value.deviceAppInfo.isNotEmpty()) { + _uiState.value = _uiState.value.copy(deviceAppInfo = emptyMap()) + } + return@launch + } + val records = patchedRecordsByPackage.values + if (records.isEmpty()) return@launch + val installed = adbManager.listInstalledPackages(device.id).getOrNull() ?: return@launch + val ourSignatureIds = morpheSignatureIds() + // Keyed by ORIGINAL package (matches the supported-apps row lookup), but + // queried by the INSTALLED package (post-rename) so renamed apps match. + val info = records.associate { record -> + val devicePkg = record.installedPackageName + val outputExists = File(record.outputApkPath).exists() + record.packageName to if (devicePkg !in installed) { + // Not on device — but the patched APK is on disk, so it can be installed. + DeviceAppInfo(installed = false, installedVersion = null, installPending = outputExists) + } else { + val (version, sigId) = adbManager.getInstalledPackageInfo(device.id, devicePkg) ?: (null to null) + val signed = if (sigId == null || ourSignatureIds.isEmpty()) null else sigId in ourSignatureIds + // Device is behind the version we already patched → install pending. + val pending = outputExists && version != null && isNewerVersion(record.apkVersion, version) + DeviceAppInfo(installed = true, installedVersion = version, signedByMorphe = signed, installPending = pending) + } + } + _uiState.value = _uiState.value.copy(deviceAppInfo = info) + } + } + + /** + * Signature ids of Morphe's signing certs — the shared default keystore plus + * the user's configured keystore (if any). An installed app whose device + * signature id is in this set was signed by Morphe. + */ + private suspend fun morpheSignatureIds(): Set = buildSet { + SignatureIdentity.idForKeystore( + MorpheData.defaultKeystoreFile, + storePassword = null, + alias = app.morphe.engine.PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS, + )?.let { add(it) } + val config = configRepository.loadConfig() + config.resolvedKeystorePath()?.let { ks -> + SignatureIdentity.idForKeystore(ks, config.keystorePassword, config.keystoreAlias)?.let { add(it) } + } + } + + /** True if any source the app was patched with now resolves to a newer version. */ + private fun PatchedAppRecord.hasAvailableUpdate(currentVersionBySource: Map): Boolean = + sourcesSnapshot.any { snap -> isNewerVersion(currentVersionBySource[snap.sourceName], snap.version) } + + /** + * Coarse "is [current] newer than [baseline]" — tolerant of `v` prefixes and + * `-dev`/prerelease suffixes (compares the numeric x.y.z core). Update + * detection accepts a few false positives, so exact prerelease ordering + * isn't needed; missing/"unknown" versions never flag an update. + */ + private fun isNewerVersion(current: String?, baseline: String?): Boolean { + if (current.isNullOrBlank() || baseline.isNullOrBlank()) return false + if (current.equals("unknown", true) || baseline.equals("unknown", true)) return false + fun core(v: String) = v.trim().removePrefix("v").removePrefix("V") + .substringBefore('-') + .split('.').map { it.toIntOrNull() ?: 0 } + val c = core(current); val b = core(baseline) + for (i in 0 until maxOf(c.size, b.size)) { + val cv = c.getOrElse(i) { 0 }; val bv = b.getOrElse(i) { 0 } + if (cv != bv) return cv > bv + } + return false + } + /** * Snapshot of the most recent multi-source load. Used by 9d's * PatchSelectionViewModel migration to render badged per-source patches. @@ -665,6 +1084,90 @@ class HomeViewModel( // compareVersions and VersionStatus moved to app.morphe.gui.util.VersionUtils } +/** Home-screen recall state per supported app (drives the row badge). */ +enum class PatchedAppState { + NEVER_PATCHED, + PATCHED, + PATCHED_WITH_UPDATES, + /** Output APK present but no longer matches what Morphe produced (changed outside Morphe). */ + MODIFIED_EXTERNALLY, + APK_MISSING, +} + +/** + * Update guidance for a patched app's detail view: per-source patch-file freshness + * plus app-version freshness within the channel the user patched on (stable vs + * experimental). Drives the "newer version available — re-patch" hints. + */ +data class RecallUpdateInfo( + val sources: List, + val appUsedVersion: String, + val appChannel: AppChannel, + /** Latest version in [appChannel], or null if unknown. */ + val appSuggestedVersion: String?, + val appOutdated: Boolean, + /** Whether the patched app version is still supported by the evaluated patch. */ + val appUsedSupported: Boolean = true, + /** Latest stable app version the evaluated patch supports, if any. */ + val latestStableVersion: String? = null, + /** A later STABLE version exists than what was patched (recommended to take, + * regardless of which channel the user is on). */ + val stableUpdateAvailable: Boolean = false, +) { + data class SourceUpdate( + val name: String, + /** Version this app was patched with (from the record snapshot). */ + val usedVersion: String, + /** Version currently resolved/downloaded — what a plain Re-patch will use. */ + val resolvedVersion: String?, + /** Newest available version (an "Update" would move to this). */ + val latestAvailableVersion: String?, + /** True when [latestAvailableVersion] is newer than [usedVersion]. */ + val outdated: Boolean, + ) + + enum class AppChannel { STABLE, EXPERIMENTAL, UNKNOWN } +} + +/** + * Async state for the "Update" action: resolve the LATEST patch files (ignoring + * any pin, for this run only), then decide whether the user's APK still satisfies + * what the latest patches target. The screen reacts to each state. + */ +sealed interface UpdatePrep { + val packageName: String + + data class Preparing(override val packageName: String) : UpdatePrep + data class Failed(override val packageName: String, val message: String) : UpdatePrep + data class Ready( + override val packageName: String, + /** Latest resolved patch-file paths to patch with. */ + val patchFilePaths: List, + val sourceNames: List, + /** App version the latest patches recommend (channel-aware), if known. */ + val targetVersion: String?, + /** True when [targetVersion] is newer than the version the user patched. */ + val needsNewerApk: Boolean, + /** Whether the user's current APK version is still supported by the latest + * patch (→ "your call" wording vs "no longer supported"). */ + val currentSupported: Boolean, + /** Download link for [targetVersion] (supported-apps style), if applicable. */ + val downloadUrl: String?, + ) : UpdatePrep +} + +/** What the connected device reports about a patched app (optional device layer). */ +data class DeviceAppInfo( + val installed: Boolean, + val installedVersion: String?, + /** true = installed copy is Morphe-signed; false = re-signed/replaced externally; + * null = couldn't determine (unrecognised dumpsys format / no keystore). */ + val signedByMorphe: Boolean? = null, + /** The patched output APK is newer than what's on the device (or not installed at + * all) and exists on disk — so it can be installed without re-patching. */ + val installPending: Boolean = false, +) + data class HomeUiState( val selectedApk: File? = null, val apkInfo: ApkInfo? = null, @@ -677,6 +1180,21 @@ data class HomeUiState( val isOffline: Boolean = false, val isDefaultSource: Boolean = true, val supportedApps: List = emptyList(), + /** Per-package recall state for home-screen badges. */ + val patchedStates: Map = emptyMap(), + /** Patched-app history, most-recent-first — drives the "Your apps" surface. */ + val patchedRecords: List = emptyList(), + /** Per-package update info (patch-file + app freshness) for the list/cards. */ + val updateInfoByPackage: Map = emptyMap(), + /** Which home apps tab is active (ALL/YOURS); restored from config on launch. */ + val appListFilter: app.morphe.gui.ui.screens.home.components.AppListFilter = + app.morphe.gui.ui.screens.home.components.AppListFilter.ALL, + /** In-flight "Update" preparation (resolve latest → decide APK), or null. */ + val updatePrep: UpdatePrep? = null, + /** Package currently being installed to the device from its stored output APK. */ + val installingPackage: String? = null, + /** Per-package device install info (optional layer; empty when no device connected). */ + val deviceAppInfo: Map = emptyMap(), val patchesVersion: String? = null, val latestPatchesVersion: String? = null, val latestDevPatchesVersion: String? = null, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt index e3ee6af..fee6e34 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt @@ -37,7 +37,10 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import app.morphe.gui.ui.screens.home.PatchedAppState +import app.morphe.gui.ui.theme.MorpheColors import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -64,6 +67,11 @@ fun SupportedAppListRow( /** Source display names whose patches target [app.packageName]. Rendered as * the FROM chips inside the expanded body. Empty hides the FROM section. */ patchSourceNames: List = emptyList(), + /** Recall state — renders a badge in the header for PATCHED / APK_MISSING. */ + patchedState: PatchedAppState = PatchedAppState.NEVER_PATCHED, + /** Optional device-layer info (installed? + version). Null = no device / not patched. + * Recall ACTIONS (Re-patch/Forget) live on the "Your apps" card, not here. */ + deviceInfo: app.morphe.gui.ui.screens.home.DeviceAppInfo? = null, modifier: Modifier = Modifier, ) { val corners = LocalMorpheCorners.current @@ -145,6 +153,10 @@ fun SupportedAppListRow( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + if (patchedState != PatchedAppState.NEVER_PATCHED) { + Spacer(Modifier.width(8.dp)) + PatchedStateBadge(patchedState, mono) + } } // ── Row 2: STABLE LATEST + EXPERIMENTAL LATEST chips ── @@ -183,17 +195,73 @@ fun SupportedAppListRow( exit = shrinkVertically(animationSpec = tween(180), shrinkTowards = Alignment.Top) + fadeOut(animationSpec = tween(120)), ) { - ExpandedBody( - app = app, - patchSourceNames = patchSourceNames, - accents = accents, - mono = mono, - cornerSmall = corners.small, - ) + Column { + ExpandedBody( + app = app, + patchSourceNames = patchSourceNames, + accents = accents, + mono = mono, + cornerSmall = corners.small, + ) + deviceInfo?.let { DeviceInfoLine(it, mono) } + } } } } +/** Optional device-layer line: whether the app is installed on the connected device. */ +@Composable +private fun DeviceInfoLine(info: app.morphe.gui.ui.screens.home.DeviceAppInfo, mono: FontFamily) { + val version = info.installedVersion?.let { " · v${it.removePrefix("v")}" } ?: "" + val (text, color) = when { + !info.installed -> "NOT ON THIS DEVICE" to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + // Installed but signed by a different cert → replaced/re-signed outside Morphe. + info.signedByMorphe == false -> "ON DEVICE$version · NOT MORPHE-SIGNED" to Color(0xFFE0504D) // red + else -> "ON DEVICE$version" to MorpheColors.Teal // ours, or signature undetermined + } + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = color, + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 2.dp, bottom = 4.dp), + ) +} + +/** + * Small recall badge shown in the row header. Renders nothing for + * [PatchedAppState.NEVER_PATCHED] (callers gate on that before calling). + */ +@Composable +internal fun PatchedStateBadge(state: PatchedAppState, mono: FontFamily) { + val (label, color) = when (state) { + PatchedAppState.PATCHED -> "PATCHED" to MorpheColors.Teal + PatchedAppState.PATCHED_WITH_UPDATES -> "UPDATE AVAILABLE" to MorpheColors.Blue + PatchedAppState.MODIFIED_EXTERNALLY -> "MODIFIED" to Color(0xFFE0504D) // red + PatchedAppState.APK_MISSING -> "APK MISSING" to Color(0xFFE0A030) // amber + PatchedAppState.NEVER_PATCHED -> return + } + val corners = LocalMorpheCorners.current + Box( + modifier = Modifier + .clip(RoundedCornerShape(corners.small)) + .background(color.copy(alpha = 0.12f)) + .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(corners.small)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = label, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = color, + letterSpacing = 0.5.sp, + ) + } +} + /** * Channel label + version pair. When [downloadUrl] is non-null and [version] is * present, the chip becomes a clickable quick-download (with hand cursor + open- diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt new file mode 100644 index 0000000..474cf80 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt @@ -0,0 +1,1047 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.screens.home.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +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.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.gui.ui.screens.home.DeviceAppInfo +import app.morphe.gui.ui.screens.home.PatchedAppState +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Which list the home pane is showing: all supported apps, or only patched ("yours"). */ +enum class AppListFilter { ALL, YOURS } + +/** + * Segmented filter at the top of the apps pane: ALL APPS · YOUR APPS. Replaces the + * old static "SUPPORTED APPS" header. The "Your apps" tab carries a count badge so + * the history is discoverable even before it's selected. + */ +@Composable +fun AppListFilterChips( + filter: AppListFilter, + onSelect: (AppListFilter) -> Unit, + allCount: Int, + yourCount: Int, +) { + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val corners = LocalMorpheCorners.current + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().padding(end = 12.dp, bottom = 6.dp), + ) { + FilterChip( + label = "ALL APPS", + count = if (allCount > 0) allCount else null, + selected = filter == AppListFilter.ALL, + accent = accents.primary, + mono = mono, + corner = corners.small, + onClick = { onSelect(AppListFilter.ALL) }, + ) + FilterChip( + label = "YOUR APPS", + count = if (yourCount > 0) yourCount else null, + selected = filter == AppListFilter.YOURS, + accent = accents.primary, + mono = mono, + corner = corners.small, + onClick = { onSelect(AppListFilter.YOURS) }, + ) + } +} + +/** + * On-open update notice (Phase 7 QoL, mirrors Manager). Shown above the apps list + * when one or more patched apps have a newer app version or patch-source version + * available. Tapping jumps to the "Your apps" list where each is badged. + */ +@Composable +fun PatchedUpdatesBanner(count: Int, onView: () -> Unit) { + val mono = LocalMorpheFont.current + val corners = LocalMorpheCorners.current + val blue = app.morphe.gui.ui.theme.MorpheColors.Blue + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(end = 12.dp, bottom = 8.dp) + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, blue.copy(alpha = if (isHovered) 0.55f else 0.35f), RoundedCornerShape(corners.medium)) + .background(blue.copy(alpha = if (isHovered) 0.14f else 0.09f)) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onView) + .padding(horizontal = 12.dp, vertical = 9.dp), + ) { + Icon(Icons.Default.Refresh, contentDescription = null, tint = blue, modifier = Modifier.size(15.dp)) + Text( + text = if (count == 1) "1 patched app has an update available" + else "$count patched apps have updates available", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = blue, + modifier = Modifier.weight(1f), + ) + Text("VIEW →", fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = blue, letterSpacing = 1.sp) + } +} + +@Composable +private fun FilterChip( + label: String, + count: Int?, + selected: Boolean, + accent: Color, + mono: FontFamily, + corner: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val border by animateColorAsState( + when { + selected -> accent.copy(alpha = 0.6f) + isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + }, + tween(150), label = "chip", + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier + .clip(RoundedCornerShape(corner)) + .border(1.dp, border, RoundedCornerShape(corner)) + .background(if (selected) accent.copy(alpha = 0.12f) else Color.Transparent) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.sp, + color = if (selected) accent + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + ) + if (count != null) { + Text( + text = count.toString(), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (selected) accent else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + } + } +} + +/** + * Compact summary row for the "Your apps" list — one per [PatchedAppRecord]. + * Tapping opens [PatchedAppDetailDialog] for the full breakdown. + */ +@Composable +fun YourAppRow( + record: PatchedAppRecord, + state: PatchedAppState, + deviceInfo: DeviceAppInfo?, + updateInfo: app.morphe.gui.ui.screens.home.RecallUpdateInfo?, + onClick: () -> Unit, + onRepatch: () -> Unit, + onUpdate: () -> Unit, + onForget: () -> Unit, + onInstall: () -> Unit = {}, + installing: Boolean = false, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hover = remember(record.packageName) { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val border by animateColorAsState( + if (isHovered) accents.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + tween(150), label = "yourRow", + ) + val bg by animateColorAsState( + if (isHovered) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + else MaterialTheme.colorScheme.surface, + tween(150), label = "yourRowBg", + ) + val initial = record.displayName.firstOrNull()?.uppercase() ?: "?" + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, border, RoundedCornerShape(corners.medium)) + .background(bg) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accents.primary.copy(alpha = 0.35f), RoundedCornerShape(corners.small)) + .background(accents.primary.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + Text(initial, fontSize = 12.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = accents.primary) + } + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = record.displayName, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "v${record.apkVersion.removePrefix("v")} · ${relativeOrShortDate(record.patchedAt)}", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (deviceInfo?.installPending == true) { + Spacer(Modifier.width(8.dp)) + MiniBadge("INSTALL READY", app.morphe.gui.ui.theme.MorpheColors.Teal, mono) + } + if (state != PatchedAppState.NEVER_PATCHED) { + Spacer(Modifier.width(8.dp)) + PatchedStateBadge(state, mono) + } + } + deviceInfo?.let { DeviceLine(it, mono) } + // Patch source + version, with "→ vNew" when a newer patch file is available. + updateInfo?.sources?.firstOrNull()?.let { s -> + val more = updateInfo.sources.size - 1 + VersionBumpText( + label = "${s.name} ", + oldVersion = s.usedVersion, + newVersion = if (s.outdated) s.latestAvailableVersion else null, + newColor = app.morphe.gui.ui.theme.MorpheColors.Blue, + mono = mono, + suffix = if (more > 0) " +$more" else null, + ) + } + // App version bump (amber if recommended/unsupported, blue if optional), or + // a heads-up when a newer patch exists but its app version isn't resolved yet. + val cardAdvice = updateInfo?.let { appAdvice(it) } + if (cardAdvice != null && updateInfo.appSuggestedVersion != null) { + VersionBumpText( + label = "App ", + oldVersion = record.apkVersion, + newVersion = updateInfo.appSuggestedVersion, + newColor = if (cardAdvice.second) Color(0xFFE0A030) else app.morphe.gui.ui.theme.MorpheColors.Blue, + mono = mono, + ) + } else if (updateInfo != null && updateInfo.sources.any { it.outdated }) { + Text( + text = "ⓘ Newer patch may bump the app — tap Update to check", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + letterSpacing = 0.3.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Already-patched APK is newer than what's on the device → offer to install + // it directly (no re-patch needed). Streams away once the device catches up. + if (deviceInfo?.installPending == true) { + val teal = app.morphe.gui.ui.theme.MorpheColors.Teal + Text( + text = if (deviceInfo.installed) + "⤓ Patched v${record.apkVersion.removePrefix("v")} ready — device on v${deviceInfo.installedVersion?.removePrefix("v") ?: "?"} (no re-patch needed)" + else + "⤓ Patched v${record.apkVersion.removePrefix("v")} ready to install (no re-patch needed)", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = teal, + letterSpacing = 0.3.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Actions live directly on the card. Clicks are consumed, so they don't + // also open the detail dialog. + val hasUpdate = updateInfo != null && (updateInfo.appOutdated || updateInfo.sources.any { it.outdated }) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 2.dp), + ) { + if (deviceInfo?.installPending == true) { + DetailActionPill( + if (installing) "INSTALLING…" else "INSTALL", + Icons.Default.Download, + app.morphe.gui.ui.theme.MorpheColors.Teal, mono, corners.small, + onClick = if (installing) ({}) else onInstall, + ) + } + if (hasUpdate) { + DetailActionPill( + "UPDATE", Icons.Default.Refresh, + app.morphe.gui.ui.theme.MorpheColors.Blue, mono, corners.small, onClick = onUpdate, + ) + } + DetailActionPill("RE-PATCH", Icons.Default.Refresh, accents.primary, mono, corners.small, onClick = onRepatch) + DetailActionPill( + "FORGET", Icons.Default.Delete, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), mono, corners.small, + onClick = onForget, + ) + } + } +} + +/** + * Full recall breakdown for one patched app. Everything is already on the record + * (date, versions, per-source snapshot, selection, options, integrity); this is a + * read surface plus the Re-patch / Open folder / Forget actions. + */ +@Composable +fun PatchedAppDetailDialog( + record: PatchedAppRecord, + state: PatchedAppState, + deviceInfo: DeviceAppInfo?, + updateInfo: app.morphe.gui.ui.screens.home.RecallUpdateInfo?, + onDismiss: () -> Unit, + onRepatch: () -> Unit, + onUpdate: () -> Unit, + onForget: () -> Unit, + onOpenFolder: () -> Unit, + onInstall: () -> Unit = {}, + installing: Boolean = false, +) { + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val corners = LocalMorpheCorners.current + val patchCount = record.patchSelectionByBundle.values.sumOf { it.size } + val hasUpdate = updateInfo != null && (updateInfo.appOutdated || updateInfo.sources.any { it.outdated }) + val installPending = deviceInfo?.installPending == true + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(corners.large), + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke( + 1.dp, accents.primary.copy(alpha = 0.25f), + ), + modifier = Modifier.widthIn(max = 480.dp), + ) { + Column( + modifier = Modifier + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // ── Header ── + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = record.displayName, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = record.packageName, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + if (record.currentPackageName != null && + record.currentPackageName.isNotBlank() && + record.currentPackageName != record.packageName + ) { + Text( + text = "→ ${record.currentPackageName}", + fontSize = 10.sp, + fontFamily = mono, + color = accents.primary.copy(alpha = 0.8f), + ) + } + } + if (state != PatchedAppState.NEVER_PATCHED) { + PatchedStateBadge(state, mono) + } + } + + deviceInfo?.let { DeviceLine(it, mono) } + + Divider(accents.primary) + + // ── Key facts ── + DetailRow("PATCHED", fullDate(record.patchedAt), mono) + DetailRow("APP VERSION", "v${record.apkVersion.removePrefix("v")}", mono) + val appAdviceMsg = updateInfo?.let { appAdvice(it) } + if (appAdviceMsg != null) { + UpdateHint(appAdviceMsg.first, mono, recommended = appAdviceMsg.second) + } else if (updateInfo != null && updateInfo.sources.any { it.outdated }) { + // Newer patch exists but its app versions aren't resolved yet + // (offline / mid-fetch) — UPDATE fetches them. + InfoNote("A newer patch is available and may support a newer app version. Tap Update to check.", mono) + } + DetailRow("MORPHE", record.patchedWithMorpheVersion, mono) + + // ── Sources + per-source patch-file freshness ── + val sourceRows = updateInfo?.sources + if (!sourceRows.isNullOrEmpty()) { + Divider(accents.primary) + SectionHeader("SOURCES", accents.secondary, mono) + sourceRows.forEach { SourceUpdateRow(it, mono) } + } else if (record.sourcesSnapshot.isNotEmpty()) { + Divider(accents.primary) + SectionHeader("SOURCES", accents.secondary, mono) + record.sourcesSnapshot.forEach { src -> + DetailRow(src.sourceName, "v${src.version.removePrefix("v")}", mono) + } + } + + // ── Patches applied (expandable + searchable) ── + Divider(accents.primary) + var patchesExpanded by remember { mutableStateOf(false) } + var patchSearch by remember { mutableStateOf("") } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .clickable { patchesExpanded = !patchesExpanded } + .padding(vertical = 4.dp, horizontal = 4.dp), + ) { + SectionHeader("PATCHES APPLIED", accents.primary, mono) + Spacer(Modifier.weight(1f)) + Text( + text = "$patchCount ${if (patchesExpanded) "▾" else "▸"}", + fontSize = 11.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + color = accents.primary, + ) + } + if (patchesExpanded) { + if (patchCount > 5) { + PatchSearchField(patchSearch, { patchSearch = it }, mono, corners.small, accents.primary) + } + record.patchSelectionByBundle.forEach { (bundle, patches) -> + val shown = (if (patchSearch.isBlank()) patches + else patches.filter { it.contains(patchSearch, ignoreCase = true) }).sorted() + if (shown.isNotEmpty()) { + Text( + text = bundle, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 6.dp, bottom = 2.dp), + ) + shown.forEach { uid -> + Text( + text = "• $uid", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.padding(start = 8.dp, top = 1.dp), + ) + } + } + } + if (record.patchOptionValues.isNotEmpty() && patchSearch.isBlank()) { + Text( + text = "OPTIONS", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp), + ) + record.patchOptionValues.forEach { (k, v) -> + Text( + text = "• $k = $v", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.padding(start = 8.dp, top = 1.dp), + ) + } + } + } + + // ── Output ── + Divider(accents.primary) + DetailRow("OUTPUT SIZE", humanSize(record.outputApkSize), mono) + record.outputApkSha256?.let { + DetailRow("SHA-256", it.take(16) + "…", mono) + } + Text( + text = record.outputApkPath, + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + ) + + // ── Actions: full-width buttons that state what they'll do ── + Divider(accents.primary) + val repatchSub = updateInfo?.sources?.mapNotNull { it.resolvedVersion }?.firstOrNull() + ?.let { "uses v${it.removePrefix("v")}" } + val updateSub = updateInfo?.let { updateSummary(it) } + // Already-patched APK ready to install (no re-patch) — primary action. + if (installPending) { + val sub = if (deviceInfo?.installed == true) + "v${record.apkVersion.removePrefix("v")} ready · device on v${deviceInfo.installedVersion?.removePrefix("v") ?: "?"}" + else "v${record.apkVersion.removePrefix("v")} ready — no re-patch needed" + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + WideActionButton( + if (installing) "INSTALLING…" else "INSTALL", + sub, Icons.Default.Download, + app.morphe.gui.ui.theme.MorpheColors.Teal, mono, corners.small, + onClick = if (installing) ({}) else ({ onInstall() }), + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + if (hasUpdate) { + WideActionButton( + "UPDATE", updateSub, Icons.Default.Refresh, + app.morphe.gui.ui.theme.MorpheColors.Blue, mono, corners.small, + ) { onDismiss(); onUpdate() } + } + WideActionButton("RE-PATCH", repatchSub, Icons.Default.Refresh, accents.primary, mono, corners.small) { + onDismiss(); onRepatch() + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + WideActionButton("FOLDER", null, Icons.AutoMirrored.Filled.OpenInNew, accents.secondary, mono, corners.small, onClick = onOpenFolder) + WideActionButton( + "FORGET", null, Icons.Default.Delete, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), mono, corners.small, + ) { onDismiss(); onForget() } + } + } + } + } +} + +/** Full-width action button (used in the detail dialog): icon + label, plus an + * optional sub-line stating the version it acts on. Stretches via [RowScope.weight]. */ +@Composable +private fun androidx.compose.foundation.layout.RowScope.WideActionButton( + label: String, + sublabel: String?, + icon: androidx.compose.ui.graphics.vector.ImageVector, + color: Color, + mono: FontFamily, + corner: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(corner)) + .border(1.dp, color.copy(alpha = if (isHovered) 0.6f else 0.35f), RoundedCornerShape(corner)) + .background(color.copy(alpha = if (isHovered) 0.14f else 0.08f)) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Text(label, fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = color, letterSpacing = 0.5.sp) + } + if (sublabel != null) { + Text( + text = sublabel, + fontSize = 8.sp, + fontFamily = mono, + color = color.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** One-line summary of what an UPDATE will move to (patch + app versions). */ +private fun updateSummary(u: app.morphe.gui.ui.screens.home.RecallUpdateInfo): String? { + val parts = mutableListOf() + val outdated = u.sources.filter { it.outdated && it.latestAvailableVersion != null } + outdated.firstOrNull()?.let { s -> + val more = outdated.size - 1 + parts += "→ patches v${s.latestAvailableVersion!!.removePrefix("v")}" + if (more > 0) " +$more" else "" + } + if (u.appOutdated && u.appSuggestedVersion != null) { + parts += "app v${u.appSuggestedVersion.removePrefix("v")}" + } + return parts.joinToString(" · ").ifBlank { null } +} + +/** + * Morphe-styled modal card (Dialog + Surface) — the house replacement for stock + * Material `AlertDialog`s. Sharp corners, accent border, mono title. + */ +@Composable +fun MorpheDialogCard( + onDismiss: () -> Unit, + title: String, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + val accents = LocalMorpheAccents.current + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(corners.large), + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke(1.dp, accents.primary.copy(alpha = 0.25f)), + modifier = Modifier.widthIn(max = 440.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = title, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp, + ) + content() + } + } + } +} + +/** Body paragraph for a [MorpheDialogCard]. */ +@Composable +fun MorpheDialogText(text: String) { + Text( + text = text, + fontSize = 12.sp, + fontFamily = LocalMorpheFont.current, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 17.sp, + ) +} + +/** Full-width dialog action button. [filled] = primary emphasis (solid fill). */ +@Composable +fun androidx.compose.foundation.layout.RowScope.MorpheDialogButton( + label: String, + color: Color, + filled: Boolean, + onClick: () -> Unit, +) { + val mono = LocalMorpheFont.current + val corner = LocalMorpheCorners.current.small + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(corner)) + .then( + if (filled) { + Modifier.background(color.copy(alpha = if (isHovered) 1f else 0.85f)) + } else { + Modifier + .border(1.dp, color.copy(alpha = if (isHovered) 0.6f else 0.35f), RoundedCornerShape(corner)) + .background(color.copy(alpha = if (isHovered) 0.12f else 0.06f)) + } + ) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = if (filled) MaterialTheme.colorScheme.surface else color, + ) + } +} + +/** Slim search field for filtering the applied-patches list. */ +@Composable +private fun PatchSearchField( + value: String, + onValueChange: (String) -> Unit, + mono: FontFamily, + corner: androidx.compose.ui.unit.Dp, + accent: Color, +) { + androidx.compose.foundation.text.BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(accent), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp), + decorationBox = { inner -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + // Fixed height + centered content so the field doesn't grow/shift + // when typing, and the placeholder/cursor sit at the same spot. + .height(32.dp) + .clip(RoundedCornerShape(corner)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(corner)) + .padding(horizontal = 8.dp), + ) { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text( + "Search patches…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + } + inner() + } + } + }, + ) +} + +@Composable +private fun DetailRow(label: String, value: String, mono: FontFamily) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.width(120.dp), + ) + Text( + text = value, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } +} + +/** One source row showing the patched version + an "↑ vX available" hint if outdated. */ +@Composable +private fun SourceUpdateRow(s: app.morphe.gui.ui.screens.home.RecallUpdateInfo.SourceUpdate, mono: FontFamily) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Text( + text = s.name, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.width(120.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "v${s.usedVersion.removePrefix("v")}", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + ) + if (s.outdated && s.latestAvailableVersion != null) { + Text( + text = "↑ v${s.latestAvailableVersion.removePrefix("v")} available", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = app.morphe.gui.ui.theme.MorpheColors.Blue, + ) + } + } + } +} + +/** "↑ …" advice line. recommended = amber (take it), optional = blue (your call). */ +@Composable +private fun UpdateHint(text: String, mono: FontFamily, recommended: Boolean = false) { + Text( + text = "↑ $text", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (recommended) Color(0xFFE0A030) else app.morphe.gui.ui.theme.MorpheColors.Blue, + lineHeight = 14.sp, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + ) +} + +/** + * App-version advice for a patched app, or null if current. Returns (message, + * recommended): recommended=true (amber) when the version is unsupported or a newer + * stable is out; false (blue) for an optional experimental bump. + */ +private fun appAdvice(u: app.morphe.gui.ui.screens.home.RecallUpdateInfo): Pair? { + if (!u.appOutdated || u.appSuggestedVersion == null) return null + val target = u.appSuggestedVersion.removePrefix("v") + val used = u.appUsedVersion.removePrefix("v") + return when { + !u.appUsedSupported -> "v$used is no longer supported. Please update to v$target" to true + u.appChannel == app.morphe.gui.ui.screens.home.RecallUpdateInfo.AppChannel.EXPERIMENTAL -> + "Newer experimental v$target available." to false + else -> "Update recommended. Newer stable v$target available" to true + } +} + +/** + * "label vOld → vNew" with distinct colors: muted label/old/arrow, highlighted new. + * Reads far better than a single flat accent. [newColor] signals tone (blue = optional, + * amber = recommended). When [newVersion] is null, just shows "label vOld". + */ +@Composable +private fun VersionBumpText( + label: String, + oldVersion: String, + newVersion: String?, + newColor: Color, + mono: FontFamily, + suffix: String? = null, +) { + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + val muted = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + val arrow = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + val text = buildAnnotatedString { + withStyle(SpanStyle(color = labelColor, fontWeight = FontWeight.Bold)) { append(label) } + withStyle(SpanStyle(color = muted)) { append("v${oldVersion.removePrefix("v")}") } + if (newVersion != null) { + withStyle(SpanStyle(color = arrow)) { append(" → ") } + withStyle(SpanStyle(color = newColor, fontWeight = FontWeight.Bold)) { append("v${newVersion.removePrefix("v")}") } + } + if (suffix != null) withStyle(SpanStyle(color = muted)) { append(suffix) } + } + Text( + text = text, + fontSize = 9.sp, + fontFamily = mono, + letterSpacing = 0.3.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** Small pill badge (matches PatchedStateBadge styling) for ad-hoc states. */ +@Composable +private fun MiniBadge(label: String, color: Color, mono: FontFamily) { + val corner = LocalMorpheCorners.current.small + Box( + modifier = Modifier + .clip(RoundedCornerShape(corner)) + .background(color.copy(alpha = 0.12f)) + .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(corner)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text(label, fontSize = 8.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = color, letterSpacing = 0.5.sp) + } +} + +/** Muted informational note (ⓘ) — full width, wraps. */ +@Composable +private fun InfoNote(text: String, mono: FontFamily) { + Text( + text = "ⓘ $text", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + lineHeight = 14.sp, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + ) +} + +@Composable +private fun SectionHeader(text: String, color: Color, mono: FontFamily) { + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.2.sp, + color = color.copy(alpha = 0.85f), + ) +} + +@Composable +private fun Divider(color: Color) { + Box( + modifier = Modifier + .fillMaxWidth() + .size(1.dp) + .background(color.copy(alpha = 0.12f)), + ) +} + +@Composable +private fun DetailActionPill( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + color: Color, + mono: FontFamily, + corner: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Row( + modifier = Modifier + .clip(RoundedCornerShape(corner)) + .border(1.dp, color.copy(alpha = if (isHovered) 0.6f else 0.35f), RoundedCornerShape(corner)) + .background(color.copy(alpha = if (isHovered) 0.14f else 0.08f)) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Text(label, fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = color, letterSpacing = 0.5.sp) + } +} + +/** Shared device-install line (mirrors the supported-row variant). */ +@Composable +private fun DeviceLine(info: DeviceAppInfo, mono: FontFamily) { + val version = info.installedVersion?.let { " · v${it.removePrefix("v")}" } ?: "" + val (text, color) = when { + !info.installed -> "NOT ON THIS DEVICE" to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + info.signedByMorphe == false -> "ON DEVICE$version · NOT MORPHE-SIGNED" to Color(0xFFE0504D) + else -> "ON DEVICE$version" to app.morphe.gui.ui.theme.MorpheColors.Teal + } + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = color, + letterSpacing = 0.5.sp, + ) +} + +private fun fullDate(millis: Long): String = + SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.US).format(Date(millis)) + +/** "today / yesterday / 3d ago / MMM d" — compact for the list row. */ +private fun relativeOrShortDate(millis: Long): String { + val now = System.currentTimeMillis() + val days = ((now - millis) / 86_400_000L).toInt() + return when { + days <= 0 -> "today" + days == 1 -> "yesterday" + days < 7 -> "${days}d ago" + else -> SimpleDateFormat("MMM d", Locale.US).format(Date(millis)) + } +} + +private fun humanSize(bytes: Long): String { + if (bytes <= 0) return "—" + val mb = bytes / 1_048_576.0 + return if (mb >= 1) "%.1f MB".format(mb) else "%.0f KB".format(bytes / 1024.0) +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt index 9f7bbc5..1d9a3c2 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -58,6 +58,7 @@ import app.morphe.gui.ui.components.ErrorDialog import app.morphe.gui.ui.components.DeviceIndicator import app.morphe.gui.ui.components.MorpheSwitch import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.ToolsButton import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage @@ -91,13 +92,21 @@ data class PatchSelectionScreen( /** Parallel to [patchesFilePaths] — display name per source. Drives badging * in the patch list. Empty disables badging (legacy single-source). */ val patchSourceNames: List = emptyList(), + /** One-click repatch seed (source/bundle name → patch uniqueIds). Empty = + * normal flow. Set when entering from a "Your apps" / patched-row Repatch. */ + val initialSelectionByBundle: Map> = emptyMap(), + /** One-click repatch option seed ("patchName.optionKey" → value). */ + val initialPatchOptions: Map = emptyMap(), ) : Screen { @Composable override fun Content() { val effectiveList = patchesFilePaths.takeIf { it.isNotEmpty() } ?: listOf(patchesFilePath) val viewModel = koinScreenModel { - parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures, effectiveList, patchSourceNames) + parametersOf( + apkPath, apkName, patchesFilePath, packageName, apkArchitectures, + effectiveList, patchSourceNames, initialSelectionByBundle, initialPatchOptions, + ) } PatchSelectionScreenContent(viewModel = viewModel) } @@ -322,8 +331,9 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { DeviceIndicator() Spacer(modifier = Modifier.width(6.dp)) + ToolsButton(allowCacheClear = false) + Spacer(modifier = Modifier.width(6.dp)) SettingsButton( - allowCacheClear = false, onDismiss = { viewModel.refreshStripLibsStatus() } ) } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt index 5ec69c7..06c8e38 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -25,6 +25,8 @@ import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File /** @@ -63,6 +65,11 @@ class PatchSelectionViewModel( /** Parallel to [patchesFilePaths] — display name of each source. Used as the * per-bundle label AND persistence key. */ private val patchSourceNames: List = emptyList(), + /** One-click repatch seed: source/bundle name → set of patch uniqueIds to + * pre-select. Empty = normal flow (saved prefs / .mpp defaults). */ + private val initialSelectionByBundle: Map> = emptyMap(), + /** One-click repatch seed: "patchName.optionKey" → option value. */ + private val initialPatchOptions: Map = emptyMap(), ) : ScreenModel { // Actual path to use for the primary file — may differ from patchesFilePath @@ -93,8 +100,18 @@ class PatchSelectionViewModel( // Store the resolved absolute path so the lookup at line ~487 can // pass it straight into File(...) without re-resolving. defaultOutputDirectory = config.resolvedDefaultOutputDirectory()?.absolutePath + // Architectures arrive empty from a repatch/update (the caller didn't + // pre-analyze the APK) — derive them from the APK itself so the strip-libs + // option isn't lost. Also correct for an Update's freshly-downloaded APK. + val arches = apkArchitectures.ifEmpty { + withContext(Dispatchers.IO) { + runCatching { app.morphe.gui.util.FileUtils.extractArchitectures(File(apkPath)) } + .getOrDefault(emptyList()) + } + } _uiState.value = _uiState.value.copy( - stripLibsStatus = computeStripLibsStatus(apkArchitectures, config.keepArchitectures) + apkArchitectures = arches, + stripLibsStatus = computeStripLibsStatus(arches, config.keepArchitectures), ) } } @@ -151,24 +168,40 @@ class PatchSelectionViewModel( val savedByBundle = mutableMapOf>() val initialOptions = mutableMapOf() var anyBundleHasSaved = false - for (bundle in bundles) { - val saved = preferencesRepository.get(bundle.bundleName, packageName) - if (saved != null) { - anyBundleHasSaved = true - val byName = bundle.patches.associateBy { it.name } - val selected = saved.patches - .filter { (_, entry) -> entry.enabled } - .keys - .mapNotNull { byName[it]?.uniqueId } - .toSet() - savedByBundle[bundle.bundleId] = selected - // Materialize saved option values ("patchName.optionKey" → string). - // Options are per-patch-name so they're naturally global here; - // identical patches in two bundles share option values, which - // is fine — same option means same thing. - for ((patchName, entry) in saved.patches) { - for ((optKey, jsonValue) in entry.options) { - initialOptions["$patchName.$optKey"] = jsonValue.toString().trim('"') + + if (initialSelectionByBundle.isNotEmpty()) { + // One-click repatch: seed selection + options from the + // PatchedAppRecord (keyed by source/bundle name). Takes + // precedence over saved prefs; keep only ids that still + // exist in the current (possibly newer) bundle. + anyBundleHasSaved = true + for (bundle in bundles) { + val seed = initialSelectionByBundle[bundle.bundleName] ?: continue + val validIds = bundle.patches.mapTo(mutableSetOf()) { it.uniqueId } + savedByBundle[bundle.bundleId] = seed.intersect(validIds) + } + initialOptions.putAll(initialPatchOptions) + Logger.info("Repatch: seeded selection for $packageName from record") + } else { + for (bundle in bundles) { + val saved = preferencesRepository.get(bundle.bundleName, packageName) + if (saved != null) { + anyBundleHasSaved = true + val byName = bundle.patches.associateBy { it.name } + val selected = saved.patches + .filter { (_, entry) -> entry.enabled } + .keys + .mapNotNull { byName[it]?.uniqueId } + .toSet() + savedByBundle[bundle.bundleId] = selected + // Materialize saved option values ("patchName.optionKey" → string). + // Options are per-patch-name so they're naturally global here; + // identical patches in two bundles share option values, which + // is fine — same option means same thing. + for ((patchName, entry) in saved.patches) { + for ((optKey, jsonValue) in entry.options) { + initialOptions["$patchName.$optKey"] = jsonValue.toString().trim('"') + } } } } @@ -501,6 +534,21 @@ class PatchSelectionViewModel( ?.toSet() ?: emptySet() + // Recall metadata: capture per-bundle selection and the source+version + // snapshot so the patching success path can record a PatchedAppRecord. + val state = _uiState.value + val selectionByBundle = state.bundles.associate { bundle -> + bundle.bundleName to state.selectedByBundle[bundle.bundleId].orEmpty() + } + val sourcesSnapshot = actualPatchesFilePaths.mapIndexed { i, path -> + val name = patchSourceNames.getOrNull(i) ?: File(path).nameWithoutExtension + app.morphe.engine.model.PatchedAppRecord.PatchedSourceSnapshot( + sourceId = name, + sourceName = name, + version = extractPatchesVersion(File(path).name) ?: "unknown", + ) + } + return PatchConfig( inputApkPath = apkPath, outputApkPath = outputPath, @@ -511,6 +559,10 @@ class PatchSelectionViewModel( useExclusiveMode = true, keepArchitectures = keepArches, continueOnError = continueOnError, + packageName = packageName, + appDisplayName = apkName, + patchSelectionByBundle = selectionByBundle, + sourcesSnapshot = sourcesSnapshot, ) } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt index 480c477..7d33101 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt @@ -48,6 +48,7 @@ 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.ToolsButton import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage @@ -221,7 +222,9 @@ fun PatchesScreenContent(viewModel: PatchesViewModel) { DeviceIndicator() Spacer(modifier = Modifier.width(6.dp)) - SettingsButton(allowCacheClear = true) + ToolsButton(allowCacheClear = true) + Spacer(modifier = Modifier.width(6.dp)) + SettingsButton() } // ── Content area ── diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt index d044351..94ca687 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -15,14 +15,22 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import app.morphe.engine.MorpheData +import app.morphe.engine.PatchedAppStore +import app.morphe.engine.UpdateChecker +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.util.ApkManifestReader +import app.morphe.engine.util.FileChecksum import app.morphe.gui.util.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import app.morphe.gui.util.PatchService import java.io.File class PatchingViewModel( private val config: PatchConfig, private val patchService: PatchService, - private val configRepository: ConfigRepository + private val configRepository: ConfigRepository, + private val patchedAppStore: PatchedAppStore, ) : ScreenModel { private val _uiState = MutableStateFlow(PatchingUiState()) @@ -119,6 +127,7 @@ class PatchingViewModel( progress = 1f ) Logger.info("Patching completed: ${config.outputApkPath}") + recordPatchedApp(patchResult) } else { val reason = patchResult.failureReason ?: if (patchResult.failedPatches.isNotEmpty()) @@ -154,6 +163,47 @@ class PatchingViewModel( Logger.info("Patching cancelled by user") } + /** + * Record this patch in the shared patched-app history (see [PatchedAppStore]). + * Best-effort: a history-write failure must never disrupt the success UX. + */ + private suspend fun recordPatchedApp(patchResult: app.morphe.gui.util.PatchResult) { + try { + val pkg = config.packageName.ifEmpty { patchResult.packageName } + if (pkg.isEmpty()) return // nothing useful to key on + val (sha, size) = withContext(Dispatchers.IO) { + FileChecksum.fingerprintOrNull(config.outputApkPath) + } + // Read the output APK's manifest once: post-rename package (for device + // matching) + versionName (fallback so the APK version number always shows). + val manifest = withContext(Dispatchers.IO) { + runCatching { ApkManifestReader.read(File(config.outputApkPath)) }.getOrNull() + } + patchedAppStore.upsert( + PatchedAppRecord( + packageName = pkg, + currentPackageName = manifest?.packageName, + displayName = config.appDisplayName.ifEmpty { pkg }, + // Prefer the manifest's versionName (e.g. "21.20.400") — the patch + // result's packageVersion can be the numeric versionCode, which breaks + // version comparisons for update detection. + apkVersion = manifest?.versionName?.takeIf { it.isNotBlank() } ?: patchResult.packageVersion, + inputApkPath = config.inputApkPath, + outputApkPath = config.outputApkPath, + outputApkSha256 = sha, + outputApkSize = size, + patchSelectionByBundle = config.patchSelectionByBundle, + patchOptionValues = config.patchOptions, + sourcesSnapshot = config.sourcesSnapshot, + patchedAt = System.currentTimeMillis(), + patchedWithMorpheVersion = UpdateChecker.currentVersion() ?: "unknown", + ) + ) + } catch (e: Exception) { + Logger.error("Failed to record patched app", e) + } + } + private fun addLog(message: String, level: LogLevel) { val entry = LogEntry(message, level) _uiState.value = _uiState.value.copy( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 0868476..1c92b6c 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -128,6 +128,7 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { onAdd = { src -> pickerScope.launch { patchSourceManager.addSource(src) } }, onEdit = { src -> pickerScope.launch { patchSourceManager.updateSource(src) } }, onRemove = { id -> pickerScope.launch { patchSourceManager.removeSource(id) } }, + onReorder = { orderedIds -> pickerScope.launch { patchSourceManager.reorderSources(orderedIds) } }, onOpenPatches = { /* unused in SINGLE_SELECT mode */ }, onDismiss = { showSourcePicker = false }, enabled = uiState.phase != QuickPatchPhase.DOWNLOADING && diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index cb07697..bbdf8ea 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -12,13 +12,20 @@ import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchConfig import app.morphe.gui.data.model.SupportedApp import app.morphe.engine.MorpheData +import app.morphe.engine.PatchedAppStore +import app.morphe.engine.UpdateChecker import app.morphe.engine.UpdateInfo +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.util.ApkOutputNaming +import app.morphe.engine.util.FileChecksum import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -44,6 +51,7 @@ class QuickPatchViewModel( private val patchService: PatchService, private val configRepository: ConfigRepository, private val updateCheckRepository: UpdateCheckRepository, + private val patchedAppStore: PatchedAppStore = PatchedAppStore.shared, ) : ScreenModel { private var patchRepository: PatchRepository = patchSourceManager.getActiveRepositorySync() @@ -526,6 +534,7 @@ class QuickPatchViewModel( statusMessage = "Patching complete! Applied ${result.appliedPatches.size} patches." ) Logger.info("Quick mode: Patching completed - $outputPath (${result.appliedPatches.size} patches)") + recordPatchedApp(result, apkFile.absolutePath, outputPath, apkInfo.displayName) } else { val errorMsg = if (result.failedPatches.isNotEmpty()) { "Patching had failures: ${result.failedPatches.joinToString(", ")}" @@ -551,6 +560,55 @@ class QuickPatchViewModel( /** * Parse progress from CLI output. */ + /** + * Record this quick-mode patch in the shared patched-app history. + * Best-effort: a write failure must never disrupt the success UX. Quick mode + * uses the default patch set, so no per-bundle selection is captured. + */ + private suspend fun recordPatchedApp( + result: app.morphe.gui.util.PatchResult, + inputApkPath: String, + outputApkPath: String, + displayName: String, + ) { + try { + val pkg = result.packageName + if (pkg.isEmpty()) return + val (sha, size) = withContext(Dispatchers.IO) { + FileChecksum.fingerprintOrNull(outputApkPath) + } + val manifest = withContext(Dispatchers.IO) { + runCatching { ApkManifestReader.read(java.io.File(outputApkPath)) }.getOrNull() + } + val sources = currentResolvedPatchFiles().map { f -> + PatchedAppRecord.PatchedSourceSnapshot( + sourceId = f.nameWithoutExtension, + sourceName = f.nameWithoutExtension, + version = ApkOutputNaming.extractPatchesVersion(f.name) ?: "unknown", + ) + } + patchedAppStore.upsert( + PatchedAppRecord( + packageName = pkg, + currentPackageName = manifest?.packageName, + displayName = displayName.ifEmpty { pkg }, + // Prefer the manifest's versionName (e.g. "21.20.400") over the numeric + // versionCode so update-detection version comparisons work. + apkVersion = manifest?.versionName?.takeIf { it.isNotBlank() } ?: result.packageVersion, + inputApkPath = inputApkPath, + outputApkPath = outputApkPath, + outputApkSha256 = sha, + outputApkSize = size, + sourcesSnapshot = sources, + patchedAt = System.currentTimeMillis(), + patchedWithMorpheVersion = UpdateChecker.currentVersion() ?: "unknown", + ) + ) + } catch (e: Exception) { + Logger.error("Failed to record patched app (quick mode)", e) + } + } + 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) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index 64b0d6c..52bd0fd 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -55,6 +55,9 @@ 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 app.morphe.engine.util.ApkManifestReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.awt.Desktop import java.io.File @@ -93,6 +96,22 @@ fun ResultScreenContent(outputPath: String) { var installError by remember { mutableStateOf(null) } var installSuccess by remember { mutableStateOf(false) } + // Whether the patched package is already on the selected device → show "Update" + // instead of "Install" (the install itself already reinstalls with -r). + var outputPackage by remember { mutableStateOf(null) } + var alreadyInstalled by remember { mutableStateOf(false) } + LaunchedEffect(outputPath) { + outputPackage = withContext(Dispatchers.IO) { + runCatching { ApkManifestReader.read(outputFile)?.packageName }.getOrNull() + } + } + LaunchedEffect(monitorState.selectedDevice?.id, monitorState.selectedDevice?.isReady, outputPackage) { + val device = monitorState.selectedDevice + val pkg = outputPackage + alreadyInstalled = device != null && device.isReady && pkg != null && + adbManager.listInstalledPackages(device.id).getOrNull()?.contains(pkg) == true + } + // Cleanup state var hasTempFiles by remember { mutableStateOf(false) } var tempFilesSize by remember { mutableStateOf(0L) } @@ -118,18 +137,22 @@ fun ResultScreenContent(outputPath: String) { scope.launch { isInstalling = true installError = null - installProgress = "Installing on ${device.displayName}..." + installProgress = "${if (alreadyInstalled) "Updating" else "Installing"} on ${device.displayName}..." + // Always record a non-Play installer so the Play Store won't clobber + // the patched app with an official update. + val installer = adbManager.resolveSpoofInstaller(device.id) val result = adbManager.installApk( apkPath = outputPath, deviceId = device.id, + installerPackage = installer, onProgress = { installProgress = it } ) result.fold( onSuccess = { installSuccess = true - installProgress = "Installation successful!" + installProgress = if (alreadyInstalled) "Update successful!" else "Installation successful!" }, onFailure = { exception -> installError = (exception as? AdbException)?.message ?: exception.message ?: "Unknown error" @@ -225,134 +248,7 @@ fun ResultScreenContent(outputPath: String) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically) ) { - // Output file info - Box( - modifier = Modifier - .widthIn(max = 520.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(corners.medium)) - .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) - .background(MaterialTheme.colorScheme.surface) - ) { - // Teal left stripe - Box( - modifier = Modifier - .width(3.dp) - .fillMaxHeight() - .background(accents.secondary) - .align(Alignment.CenterStart) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 3.dp) - ) { - // File name (first line) + size (second line) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp) - ) { - Text( - text = "OUTPUT FILE", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - letterSpacing = 1.5.sp - ) - Spacer(Modifier.height(4.dp)) - Text( - text = outputFile.name, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (outputFile.exists()) { - Spacer(Modifier.height(4.dp)) - Text( - text = formatFileSize(outputFile.length()), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accents.secondary - ) - } - Spacer(Modifier.height(2.dp)) - Text( - text = outputFile.parent ?: "", - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - // Open folder button row - Row( - modifier = Modifier - .fillMaxWidth() - .drawBehind { - drawLine( - color = borderColor, - start = Offset(20.dp.toPx(), 0f), - end = Offset(size.width - 20.dp.toPx(), 0f), - strokeWidth = 1f - ) - } - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val folderHover = remember { MutableInteractionSource() } - val isFolderHovered by folderHover.collectIsHoveredAsState() - val folderColor by animateColorAsState( - if (isFolderHovered) accents.primary else accents.primary.copy(alpha = 0.7f), - animationSpec = tween(150) - ) - val folderBg by animateColorAsState( - if (isFolderHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, - animationSpec = tween(150) - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .hoverable(folderHover) - .clip(RoundedCornerShape(corners.small)) - .background(folderBg, RoundedCornerShape(corners.small)) - .border( - 1.dp, - accents.primary.copy(alpha = if (isFolderHovered) 0.5f else 0.3f), - RoundedCornerShape(corners.small) - ) - .clickable { - try { - val folder = outputFile.parentFile - if (folder != null && Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(folder) - } - } catch (_: Exception) {} - } - .padding(horizontal = 14.dp, vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "OPEN FOLDER →", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = folderColor, - letterSpacing = 0.5.sp - ) - } - } - } - } + OutputFileCard(outputFile = outputFile, corners = corners, mono = mono, borderColor = borderColor) // ADB Install section if (isAdbDisabledByUser) { @@ -366,6 +262,7 @@ fun ResultScreenContent(outputPath: String) { AdbInstallSection( devices = monitorState.devices, selectedDevice = monitorState.selectedDevice, + alreadyInstalled = alreadyInstalled, isInstalling = isInstalling, installProgress = installProgress, installError = installError, @@ -419,34 +316,7 @@ fun ResultScreenContent(outputPath: String) { // Patch Another button Spacer(Modifier.height(4.dp)) - - val patchAnotherHover = remember { MutableInteractionSource() } - val isPatchAnotherHovered by patchAnotherHover.collectIsHoveredAsState() - val patchAnotherBg by animateColorAsState( - if (isPatchAnotherHovered) accents.primary.copy(alpha = 0.9f) else accents.primary, - animationSpec = tween(150) - ) - - Box( - modifier = Modifier - .widthIn(max = 520.dp) - .fillMaxWidth() - .height(42.dp) - .hoverable(patchAnotherHover) - .clip(RoundedCornerShape(corners.small)) - .background(patchAnotherBg, RoundedCornerShape(corners.small)) - .clickable { navigator.popUntilRoot() }, - contentAlignment = Alignment.Center - ) { - Text( - text = "PATCH ANOTHER", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = Color.White, - letterSpacing = 1.sp - ) - } + PatchAnotherButton(corners = corners, mono = mono) Spacer(Modifier.height(8.dp)) } @@ -473,6 +343,7 @@ fun ResultScreenContent(outputPath: String) { private fun AdbInstallSection( devices: List, selectedDevice: AdbDevice?, + alreadyInstalled: Boolean = false, isInstalling: Boolean, installProgress: String, installError: String?, @@ -768,7 +639,7 @@ private fun AdbInstallSection( ) { Text( text = if (selectedDevice != null) - "INSTALL ON ${selectedDevice.displayName.uppercase()}" + "${if (alreadyInstalled) "UPDATE" else "INSTALL"} ON ${selectedDevice.displayName.uppercase()}" else "SELECT A DEVICE", fontSize = 11.sp, @@ -969,3 +840,176 @@ private fun formatFileSize(bytes: Long): String { else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) } } + +@Composable +private fun OutputFileCard( + outputFile: File, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, +) { + val accents = LocalMorpheAccents.current + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + // Teal left stripe + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(accents.secondary) + .align(Alignment.CenterStart) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 3.dp) + ) { + // File name (first line) + size (second line) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + Text( + text = "OUTPUT FILE", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(4.dp)) + Text( + text = outputFile.name, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (outputFile.exists()) { + Spacer(Modifier.height(4.dp)) + Text( + text = formatFileSize(outputFile.length()), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = outputFile.parent ?: "", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Open folder button row + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val folderHover = remember { MutableInteractionSource() } + val isFolderHovered by folderHover.collectIsHoveredAsState() + val folderColor by animateColorAsState( + if (isFolderHovered) accents.primary else accents.primary.copy(alpha = 0.7f), + animationSpec = tween(150) + ) + val folderBg by animateColorAsState( + if (isFolderHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .hoverable(folderHover) + .clip(RoundedCornerShape(corners.small)) + .background(folderBg, RoundedCornerShape(corners.small)) + .border( + 1.dp, + accents.primary.copy(alpha = if (isFolderHovered) 0.5f else 0.3f), + RoundedCornerShape(corners.small) + ) + .clickable { + try { + val folder = outputFile.parentFile + if (folder != null && Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(folder) + } + } catch (_: Exception) {} + } + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "OPEN FOLDER →", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = folderColor, + letterSpacing = 0.5.sp + ) + } + } + } + } +} + +@Composable +private fun PatchAnotherButton( + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, +) { + val navigator = LocalNavigator.currentOrThrow + val accents = LocalMorpheAccents.current + val patchAnotherHover = remember { MutableInteractionSource() } + val isPatchAnotherHovered by patchAnotherHover.collectIsHoveredAsState() + val patchAnotherBg by animateColorAsState( + if (isPatchAnotherHovered) accents.primary.copy(alpha = 0.9f) else accents.primary, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .height(42.dp) + .hoverable(patchAnotherHover) + .clip(RoundedCornerShape(corners.small)) + .background(patchAnotherBg, RoundedCornerShape(corners.small)) + .clickable { navigator.popUntilRoot() }, + contentAlignment = Alignment.Center + ) { + Text( + text = "PATCH ANOTHER", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 1.sp + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt index b8fa6e1..2adab97 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -5,6 +5,7 @@ package app.morphe.gui.util +import app.morphe.engine.util.SignatureIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -12,13 +13,24 @@ import java.net.InetSocketAddress import java.net.Socket /** - * Manages ADB (Android Debug Bridge) operations for installing APKs. - * Works across macOS, Linux, and Windows. + * Manages ADB (Android Debug Bridge) operations for installing APKs. Works across macOS, Linux, and Windows. */ class AdbManager { private var adbPath: String? = null + private companion object { + /* Popular store packages. The spoof installer is the first one NOT on the device — see [resolveSpoofInstaller]. */ + val SPOOF_STORE_CANDIDATES = listOf( + "com.amazon.venezia", // Amazon Appstore + "com.sec.android.app.samsungapps", // Samsung Galaxy Store + "com.huawei.appmarket", // Huawei AppGallery + "com.apkpure.aegon", // APKPure + "com.aurora.store", // Aurora Store + "org.fdroid.fdroid", // F-Droid + ) + } + /** * Set to true once [startServer] confirms Morphe was the process that * spawned the ADB daemon (vs. attaching to one that was already running — @@ -283,10 +295,23 @@ class AdbManager { /** * Install an APK on the specified device (or default device if only one connected). */ + /** + * Pick an installer-source package to spoof for [deviceId]: the most-popular + * store NOT installed on the device, so nothing real tries to manage the app + * (and the Play Store won't auto-update it). Falls back to Amazon if all present. + */ + suspend fun resolveSpoofInstaller(deviceId: String): String { + val installed = listInstalledPackages(deviceId).getOrNull() ?: emptySet() + return SPOOF_STORE_CANDIDATES.firstOrNull { it !in installed } ?: SPOOF_STORE_CANDIDATES.first() + } + suspend fun installApk( apkPath: String, deviceId: String? = null, allowDowngrade: Boolean = true, + /** Set the recorded installer package (`pm install -i`). A non-Play value + * stops the Play Store from auto-updating (and clobbering) the patched app. */ + installerPackage: String? = null, onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { val adb = findAdb() ?: return@withContext Result.failure( @@ -333,48 +358,61 @@ class AdbManager { ) } - // 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) + // Build + run the install, factored so we can transparently retry + // without installer attribution. Stricter Android builds could reject an + // `-i` pointing at a store the user doesn't have; if that's what failed, + // we'd rather install without Play-update blocking than not install at + // all. (Validated on Android 12 that an absent `-i` is accepted; this is + // a safety net for versions we haven't tested.) + fun attemptInstall(withInstaller: Boolean): Result { + val command = mutableListOf(adb, "-s", targetDevice.id, "install", "-r") + if (allowDowngrade) command.add("-d") // Allow downgrade + if (withInstaller && !installerPackage.isNullOrBlank()) { + command.add("-i") // Record installer source (blocks Play auto-update) + command.add(installerPackage) + } + command.add(apkPath) - onProgress("Installing on ${targetDevice.displayName}...") - Logger.info("Running: ${command.joinToString(" ")}") + onProgress("Installing on ${targetDevice.displayName}...") + Logger.info("Running: ${command.joinToString(" ")}") - try { - val process = ProcessBuilder(command) - .redirectErrorStream(true) - .start() + return 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") - } + // Read output in real-time + val output = StringBuilder() + process.inputStream.bufferedReader().forEachLine { line -> + output.appendLine(line) + onProgress(line) + Logger.debug("ADB: $line") + } - val exitCode = process.waitFor() - val outputStr = output.toString() + 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)) + 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}")) } - } catch (e: Exception) { - Logger.error("Error installing APK", e) - Result.failure(AdbException("Installation failed: ${e.message}")) + } + + val withInstaller = !installerPackage.isNullOrBlank() + val first = attemptInstall(withInstaller = withInstaller) + if (first.isFailure && withInstaller) { + Logger.info("Install with '-i $installerPackage' failed; retrying without installer attribution") + attemptInstall(withInstaller = false) + } else { + first } } @@ -455,6 +493,55 @@ class AdbManager { } } + // ── Patched-app recall: device-side queries ────────────────────────────── + + /** Package names installed on [deviceId] (`pm list packages`). */ + suspend fun listInstalledPackages(deviceId: String): Result> = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure(AdbException("ADB not found")) + try { + val process = ProcessBuilder(adb, "-s", deviceId, "shell", "pm", "list", "packages") + .redirectErrorStream(true).start() + val out = process.inputStream.bufferedReader().readText() + process.waitFor() + if (process.exitValue() != 0) return@withContext Result.failure(AdbException("pm list packages failed")) + val packages = out.lineSequence() + .map { it.trim() } + .filter { it.startsWith("package:") } + .map { it.removePrefix("package:").trim() } + .filter { it.isNotBlank() } + .toSet() + Result.success(packages) + } catch (e: Exception) { + Result.failure(AdbException("pm list packages failed: ${e.message}")) + } + } + + /** + * Installed `versionName` and signing-cert id of [pkg] on [deviceId] from a + * single `dumpsys package` call. Returns `(versionName, signatureId)` (either + * may be null if absent/unparseable), or null if the package isn't dumpable. + */ + suspend fun getInstalledPackageInfo(deviceId: String, pkg: String): Pair? = + withContext(Dispatchers.IO) { + val out = dumpsysPackage(deviceId, pkg) ?: return@withContext null + val version = Regex("""versionName=(\S+)""").find(out)?.groupValues?.get(1) + val signatureId = SignatureIdentity.parseDeviceSignatureId(out) + version to signatureId + } + + private suspend fun dumpsysPackage(deviceId: String, pkg: String): String? = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext null + try { + val process = ProcessBuilder(adb, "-s", deviceId, "shell", "dumpsys", "package", pkg) + .redirectErrorStream(true).start() + val out = process.inputStream.bufferedReader().readText() + process.waitFor() + out.ifBlank { null } + } catch (e: Exception) { + null + } + } + /** * Parse output from 'adb devices -l' command. * Example line: "XXXXXXXX device usb:1-1 product:flame model:Pixel_4 device:flame transport_id:1" diff --git a/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt b/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt index 6645842..b4129f8 100644 --- a/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt @@ -5,9 +5,8 @@ package app.morphe.gui.util +import app.morphe.engine.util.FileChecksum import java.io.File -import java.io.FileInputStream -import java.security.MessageDigest /** * Utility for calculating and verifying file checksums. @@ -18,19 +17,7 @@ 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) } - } + fun calculateSha256(file: File): String = FileChecksum.sha256(file) /** * Verify a file's checksum against expected value. diff --git a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt index 8364a71..ffe4e6d 100644 --- a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt +++ b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt @@ -43,6 +43,13 @@ object EnabledSourcesLoader { val source: PatchSource, val patchFile: File? = null, val resolvedVersion: String? = null, + /** + * Newest available release tag in the resolved channel (stable/dev), + * regardless of what's currently downloaded — lets the UI flag "a newer + * patch file is available" without the user having to select it first. + * Null when unknown (offline / cache fallback). + */ + val latestAvailableVersion: String? = null, val isOffline: Boolean = false, val error: String? = null, val channel: Channel = Channel.UNKNOWN, @@ -216,6 +223,8 @@ object EnabledSourcesLoader { source = source, patchFile = patchFile, resolvedVersion = release.tagName, + // Latest in the resolved channel — what an "update" would move to. + latestAvailableVersion = if (release.isDevRelease()) latestDevTag else latestStableTag, isOffline = false, channel = channel, ) diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 5f96e18..c802e76 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -166,6 +166,8 @@ class PatchService { appliedPatches = engineResult.appliedPatches, failedPatches = engineResult.failedPatches.map { it.name }, failureReason = failureReason, + packageName = engineResult.packageName, + packageVersion = engineResult.packageVersion, )) } finally { tempCopies.forEach { runCatching { it.delete() } } @@ -276,4 +278,8 @@ data class PatchResult( // failed patch's error or — when patching succeeded but a later step // (rebuild, sign) blew up — that step's error. Null on success. val failureReason: String? = null, + // Surfaced from the engine so callers (e.g. patched-app history) can record + // what was actually patched. Empty when the patcher didn't report them. + val packageName: String = "", + val packageVersion: String = "", ) diff --git a/src/test/kotlin/app/morphe/engine/PatchedAppStoreTest.kt b/src/test/kotlin/app/morphe/engine/PatchedAppStoreTest.kt new file mode 100644 index 0000000..8579483 --- /dev/null +++ b/src/test/kotlin/app/morphe/engine/PatchedAppStoreTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.engine.model.PatchedAppRecord +import kotlinx.coroutines.runBlocking +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PatchedAppStoreTest { + + private val tmpDir: File = Files.createTempDirectory("morphe-store-test").toFile() + private val storeFile: File get() = File(tmpDir, PatchedAppStore.FILE_NAME) + private fun newStore() = PatchedAppStore(storeFile) + + @AfterTest + fun cleanup() { + tmpDir.deleteRecursively() + } + + private fun record(pkg: String, apkVersion: String = "1.0", patchedAt: Long = 1L) = + PatchedAppRecord( + packageName = pkg, + displayName = pkg.substringAfterLast('.'), + apkVersion = apkVersion, + inputApkPath = "/in/$pkg.apk", + outputApkPath = "/out/$pkg-patched.apk", + patchedAt = patchedAt, + patchedWithMorpheVersion = "test", + ) + + @Test + fun `empty when no file exists`() = runBlocking { + assertEquals(emptyList(), newStore().getAll()) + assertNull(newStore().get("com.whatever")) + } + + @Test + fun `upsert then read back`() = runBlocking { + val store = newStore() + store.upsert(record("com.a")) + assertEquals(1, store.getAll().size) + assertEquals("com.a", store.get("com.a")?.packageName) + assertNull(store.get("com.missing")) + } + + @Test + fun `upsert replaces the record for the same package`() = runBlocking { + val store = newStore() + store.upsert(record("com.a", apkVersion = "1.0")) + store.upsert(record("com.a", apkVersion = "2.0")) + val all = store.getAll() + assertEquals(1, all.size) + assertEquals("2.0", all.single().apkVersion) + } + + @Test + fun `most recently upserted comes first`() = runBlocking { + val store = newStore() + store.upsert(record("com.a")) + store.upsert(record("com.b")) + assertEquals("com.b", store.getAll().first().packageName) + } + + @Test + fun `delete removes the record`() = runBlocking { + val store = newStore() + store.upsert(record("com.a")) + store.delete("com.a") + assertTrue(store.getAll().isEmpty()) + } + + @Test + fun `records persist across store instances`() = runBlocking { + PatchedAppStore(storeFile).upsert(record("com.a")) + // Fresh instance (empty cache) must read the persisted file. + assertEquals("com.a", PatchedAppStore(storeFile).get("com.a")?.packageName) + } + + @Test + fun `tolerates a corrupt file and heals on next write`() = runBlocking { + storeFile.parentFile.mkdirs() + storeFile.writeText("{ not valid json ") + val store = PatchedAppStore(storeFile) + assertEquals(emptyList(), store.getAll()) // no throw + store.upsert(record("com.a")) + assertEquals(1, PatchedAppStore(storeFile).getAll().size) + } +} From abe2c1d84fa4718de1c0c4e35375dcf23e9bdfef Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 15 Jun 2026 08:21:49 +0000 Subject: [PATCH 04/39] chore: Release v1.10.0-dev.1 [skip ci] # [1.10.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.2-dev.1...v1.10.0-dev.1) (2026-06-15) ### Features * "your apps" section + settings and tools split + re-orderable patch sources + block play store updates for patched apps ([#176](https://github.com/MorpheApp/morphe-cli/issues/176)) ([d865abd](https://github.com/MorpheApp/morphe-cli/commit/d865abdbbe2258cf24476add02a361b05f3fdf7e)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db56899..832b551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.2-dev.1...v1.10.0-dev.1) (2026-06-15) + + +### Features + +* "your apps" section + settings and tools split + re-orderable patch sources + block play store updates for patched apps ([#176](https://github.com/MorpheApp/morphe-cli/issues/176)) ([d865abd](https://github.com/MorpheApp/morphe-cli/commit/d865abdbbe2258cf24476add02a361b05f3fdf7e)) + ## [1.9.2-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.1...v1.9.2-dev.1) (2026-06-15) diff --git a/gradle.properties b/gradle.properties index bda22d9..0f78e7f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.9.2-dev.1 - +version = 1.10.0-dev.1 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From 1626ce5ffe3300de2299443ed9b7587ebd1cdcb6 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:34:41 +0530 Subject: [PATCH 05/39] feat: route links through patched app + uninstall from "Your Apps" section's cards (#179) - Route a patched app's web links to it instead of the browser or the stock/default app. - Added an "Uninstall" button to the Your Apps section's cards. Allows for better tracking of an app. (The GUI still tracks and says "Not on this device" if an app was uninstalled outside.) - Added `--route-links` and `--disable-stock ` flag to utility install for the cli. This will route links through our patched app and also stop stock app from opening those links respectively. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + .../cli/command/utility/InstallCommand.kt | 54 +++- .../app/morphe/engine/util/AppLinkCommands.kt | 70 ++++ .../app/morphe/gui/data/model/AppConfig.kt | 8 + .../gui/data/repository/ConfigRepository.kt | 16 + .../gui/ui/components/SettingsButton.kt | 14 + .../gui/ui/components/SettingsDialog.kt | 32 ++ .../morphe/gui/ui/screens/home/HomeScreen.kt | 63 ++++ .../gui/ui/screens/home/HomeViewModel.kt | 45 +++ .../screens/home/components/YourAppsPane.kt | 52 ++- .../gui/ui/screens/quick/QuickPatchScreen.kt | 20 +- .../gui/ui/screens/result/ResultScreen.kt | 304 ++++++++++++++++++ .../kotlin/app/morphe/gui/util/AdbManager.kt | 148 +++++++++ .../app/morphe/engine/AppLinkCommandsTest.kt | 54 ++++ 15 files changed, 875 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt create mode 100644 src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index d9bc4ea..82a9b6c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { api(libs.morphe.patcher) implementation(libs.arsclib) implementation(libs.morphe.library) + implementation(libs.jadb) implementation(libs.picocli) // -- Compose Desktop --------------------------------------------------- diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 480721a..91e52e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ picocli = "4.7.7" arsclib = "a28c6fb2a7" morphe-patcher = "1.5.2" morphe-library = "1.3.0" +jadb = "1.2.1" # Compose Desktop compose = "1.10.3" @@ -44,6 +45,7 @@ junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref 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" } +jadb = { module = "app.morphe:jadb", version.ref = "jadb" } # Ktor Client ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } diff --git a/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt index 412e849..bfb99b3 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt @@ -1,10 +1,13 @@ package app.morphe.cli.command.utility +import app.morphe.engine.util.ApkManifestReader +import app.morphe.engine.util.AppLinkCommands import app.morphe.library.installation.installer.* import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import picocli.CommandLine.* +import se.vidstige.jadb.JadbConnection import java.io.File import java.util.logging.Logger @@ -34,6 +37,18 @@ internal object InstallCommand : Runnable { ) private var packageName: String? = null + @Option( + names = ["--route-links"], + description = ["After installing, route this app's supported web links to it (\"open with\")."], + ) + private var routeLinks: Boolean = false + + @Option( + names = ["--disable-stock"], + description = ["With --route-links: also stop this stock package from handling the links."], + ) + private var stockPackage: String? = null + override fun run() { suspend fun install(deviceSerial: String? = null) { val result = try { @@ -44,20 +59,51 @@ internal object InstallCommand : Runnable { }.install(Installer.Apk(apk, packageName)) } catch (e: Exception) { logger.severe(e.toString()) + return } when (result) { - RootInstallerResult.FAILURE -> + RootInstallerResult.FAILURE -> { logger.severe("Failed to mount the APK file") - is AdbInstallerResult.Failure -> + return + } + is AdbInstallerResult.Failure -> { logger.severe(result.exception.toString()) - else -> - logger.info("Installed the APK file") + return + } + else -> logger.info("Installed the APK file") } + + if (routeLinks) routeLinks(deviceSerial) } runBlocking { deviceSerials?.map { async { install(it) } }?.awaitAll() ?: install() } } + + private fun routeLinks(deviceSerial: String?) { + val patched = ApkManifestReader.read(apk)?.packageName ?: run { + logger.severe("Could not read package name from APK; skipping link routing") + return + } + val commands = AppLinkCommands.enablePatched(patched) + + (stockPackage?.let { AppLinkCommands.disableStock(it) } ?: emptyList()) + + val devices = JadbConnection().devices + val device = deviceSerial?.let { s -> devices.firstOrNull { it.serial == s } } + ?: devices.firstOrNull() + ?: run { logger.severe("No ADB device for link routing"); return } + + commands.forEach { argv -> + val cmd = argv.joinToString(" ") + val process = device.shellProcessBuilder(cmd).start() + val out = process.inputStream.bufferedReader().readText().trim() + val exit = process.waitFor() + if (exit != 0 || out.contains("Error", true) || out.contains("Failure", true)) { + logger.severe("Link command failed: $cmd -> ${out.ifBlank { "exit $exit" }}") + } + } + logger.info("Routed links to $patched") + } } diff --git a/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt b/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt new file mode 100644 index 0000000..a7d6c76 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +/** + * Builds the `pm` shell command lines that route web links ("open with") to a + * patched app and, optionally, stop the stock app from grabbing those same + * links. + * + * Pure argv construction only — no process execution. Each frontend runs these + * through its own adb path (the GUI's `AdbManager`, the CLI's installer), so the + * "what commands to run" decision lives in one tested place while "how to exec" + * stays per-frontend. + * + * Each returned entry is one `adb shell` invocation's arguments (i.e. everything + * after `adb -s shell`). Run them in order. + * + * Background: the patched app's web intent filters are NOT `autoVerify`, so + * Android's domain-verification commands no-op on them. The *user-selection* + * state, however, can be set for any host the app declares — that's the route + * used here (and the one Morphe's own docs prescribe). + */ +object AppLinkCommands { + + /** Default Android user. Primary user is always 0; `cur` also works. */ + const val DEFAULT_USER = "0" + + /** + * Approve every web host the patched app declares so it handles those links. + * + * `set-app-links-allowed true` flips the per-app "open supported links" + * master switch on; `set-app-links-user-selection true all` then approves + * all declared hosts (verified or not). Both are needed: the master switch + * alone doesn't approve unverified hosts. + */ + fun enablePatched(patchedPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", patchedPackage, "true"), + listOf("pm", "set-app-links-user-selection", "--user", user, "--package", patchedPackage, "true", "all"), + ) + + /** + * Stop the stock app from handling its web links, without disabling the app + * itself. The master "open supported links" switch off is surgical and + * reversible — far lighter than `pm disable-user`. + * + * Only meaningful when a rename patch was used (stock + patched coexist as + * different packages) and the stock package is actually installed. + */ + fun disableStock(stockPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", stockPackage, "false"), + ) + + /** + * Reverse [enablePatched]: revoke the patched app's host approvals so link + * routing returns to Android's defaults. + */ + fun restorePatched(patchedPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-user-selection", "--user", user, "--package", patchedPackage, "false", "all"), + ) + + /** + * Reverse [disableStock]: hand link handling back to the stock app. + */ + fun restoreStock(stockPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", stockPackage, "true"), + ) +} diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index bdbfa52..6a5ef9d 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -85,6 +85,14 @@ data class AppConfig( // Which home apps tab the user last viewed ("ALL" or "YOURS"), restored on // next launch. Stored as a string so this data layer stays free of UI enums. val homeAppListFilter: String = "ALL", + // After an ADB install, automatically route the patched app's web links to it + // ("open with"). Default OFF — it changes how the device opens links, so it's + // opt-in. See AppLinkCommands / AdbManager.setLinkHandling. + val autoRouteLinksAfterInstall: Boolean = false, + // When auto-routing links, also stop the stock app from opening those links + // (only applies when a rename patch was used and stock is installed). Default + // OFF — it reaches into a stock app's behavior. + val disableStockLinksAfterInstall: Boolean = false, ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index 6ebdc20..7199476 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -204,6 +204,22 @@ class ConfigRepository { saveConfig(current.copy(autoCleanupTempFiles = enabled)) } + /** + * Update the "route links to patched app after install" setting. + */ + suspend fun setAutoRouteLinksAfterInstall(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(autoRouteLinksAfterInstall = enabled)) + } + + /** + * Update the "also disable stock app's links" sub-setting. + */ + suspend fun setDisableStockLinksAfterInstall(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(disableStockLinksAfterInstall = enabled)) + } + /** * Update simplified mode setting. */ diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index 79b273e..456b8ca 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -74,6 +74,8 @@ fun SettingsButton( var keepArchitectures by remember { mutableStateOf>(emptySet()) } var collapsibleSectionStates by remember { mutableStateOf>(emptyMap()) } var updateChannelPreference by remember { mutableStateOf(UpdateChannelPreference.STABLE) } + var autoRouteLinksAfterInstall by remember { mutableStateOf(false) } + var disableStockLinksAfterInstall by remember { mutableStateOf(false) } LaunchedEffect(showSettingsDialog) { if (showSettingsDialog) { @@ -89,6 +91,8 @@ fun SettingsButton( keystoreEntryPassword = config.keystoreEntryPassword keepArchitectures = config.keepArchitectures collapsibleSectionStates = config.collapsibleSectionStates + autoRouteLinksAfterInstall = config.autoRouteLinksAfterInstall + disableStockLinksAfterInstall = config.disableStockLinksAfterInstall // Resolve the smart-default if the user has never picked a channel // (returns DEV when the running build is dev, STABLE otherwise). updateChannelPreference = configRepository.getOrInitUpdateChannelPreference( @@ -191,6 +195,16 @@ fun SettingsButton( }, autoStartAdb = adbPreference.enabled, onAutoStartAdbChange = { adbPreference.onChange(it) }, + autoRouteLinksAfterInstall = autoRouteLinksAfterInstall, + onAutoRouteLinksChange = { enabled -> + autoRouteLinksAfterInstall = enabled + scope.launch { configRepository.setAutoRouteLinksAfterInstall(enabled) } + }, + disableStockLinksAfterInstall = disableStockLinksAfterInstall, + onDisableStockLinksChange = { enabled -> + disableStockLinksAfterInstall = enabled + scope.launch { configRepository.setDisableStockLinksAfterInstall(enabled) } + }, collapsibleSectionStates = collapsibleSectionStates, onCollapsibleSectionToggle = { id, expanded -> collapsibleSectionStates = collapsibleSectionStates + (id to expanded) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index 35df0b4..388bcd0 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -91,6 +91,10 @@ fun SettingsDialog( onUpdateChannelChange: (app.morphe.gui.data.model.UpdateChannelPreference) -> Unit = {}, autoStartAdb: Boolean = false, onAutoStartAdbChange: (Boolean) -> Unit = {}, + autoRouteLinksAfterInstall: Boolean = false, + onAutoRouteLinksChange: (Boolean) -> Unit = {}, + disableStockLinksAfterInstall: Boolean = false, + onDisableStockLinksChange: (Boolean) -> Unit = {}, collapsibleSectionStates: Map = emptyMap(), onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> } ) { @@ -271,6 +275,34 @@ fun SettingsDialog( SettingsDivider(borderColor) + // ── Link handling ("open with") ── + SettingToggleRow( + label = "Route links to patched app", + description = "After installing via ADB, make the patched app open its supported web links instead of the browser or the stock/default app.", + checked = autoRouteLinksAfterInstall, + onCheckedChange = onAutoRouteLinksChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) + AnimatedVisibility(visible = autoRouteLinksAfterInstall) { + Column { + Spacer(Modifier.height(12.dp)) + SettingToggleRow( + label = "Disable stock app's links", + description = "Also stop the original app from opening these links (only when a " + + "rename patch was used and the stock app is installed). Reversible.", + checked = disableStockLinksAfterInstall, + onCheckedChange = onDisableStockLinksChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) + } + } + + SettingsDivider(borderColor) + // ── Patched App Runtime Logs ── PatchedAppRuntimeLogsSection( mono = mono, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 11c85bc..8e809cc 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -195,6 +195,57 @@ fun HomeScreenContent( } } + // "Uninstall" — removes the patched app from the connected device. The dialog + // offers the keep-history vs delete-history choice via a checkbox. + var uninstallConfirm by remember { mutableStateOf(null) } + var uninstallAlsoForget by remember { mutableStateOf(false) } + val onUninstall: (String) -> Unit = { pkg -> + uninstallAlsoForget = false + uninstallConfirm = viewModel.getPatchedRecord(pkg) + } + uninstallConfirm?.let { record -> + MorpheDialogCard( + onDismiss = { uninstallConfirm = null }, + title = "Uninstall ${record.displayName}?", + ) { + MorpheDialogText( + "This removes ${record.displayName} from the connected device. " + + "The patched APK on disk and your history are kept unless you choose otherwise below." + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(LocalMorpheCorners.current.small)) + .clickable { uninstallAlsoForget = !uninstallAlsoForget } + .padding(vertical = 4.dp), + ) { + Checkbox( + checked = uninstallAlsoForget, + onCheckedChange = { uninstallAlsoForget = it }, + colors = CheckboxDefaults.colors(checkedColor = Color(0xFFE0504D)), + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + "Also remove from Your Apps", + fontSize = 12.sp, + fontFamily = LocalMorpheFont.current, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + uninstallConfirm = null + } + MorpheDialogButton("UNINSTALL", Color(0xFFE0504D), filled = true) { + viewModel.uninstallPatchedApp(record.packageName, alsoForget = uninstallAlsoForget) + uninstallConfirm = null + } + } + } + } + repatchMissingRecord?.let { record -> MorpheDialogCard(onDismiss = { repatchMissingRecord = null }, title = "Original APK not found") { MorpheDialogText( @@ -240,7 +291,9 @@ fun HomeScreenContent( } }, onInstall = { viewModel.installPatchedApp(record.packageName) }, + onUninstall = { onUninstall(record.packageName) }, installing = uiState.installingPackage == record.packageName, + uninstalling = uiState.uninstallingPackage == record.packageName, ) } @@ -592,6 +645,8 @@ fun HomeScreenContent( onUpdate = onUpdate, onInstall = { viewModel.installPatchedApp(it) }, installingPackage = uiState.installingPackage, + onUninstall = onUninstall, + uninstallingPackage = uiState.uninstallingPackage, onShowDetail = onShowDetail, filter = uiState.appListFilter, onFilterChange = { viewModel.setAppListFilter(it) }, @@ -1286,6 +1341,8 @@ private fun SupportedAppsListPane( onUpdate: (String) -> Unit = {}, onInstall: (String) -> Unit = {}, installingPackage: String? = null, + onUninstall: (String) -> Unit = {}, + uninstallingPackage: String? = null, onShowDetail: (PatchedAppRecord) -> Unit = {}, filter: AppListFilter = AppListFilter.ALL, onFilterChange: (AppListFilter) -> Unit = {}, @@ -1376,6 +1433,8 @@ private fun SupportedAppsListPane( onForget = onForget, onInstall = onInstall, installingPackage = installingPackage, + onUninstall = onUninstall, + uninstallingPackage = uninstallingPackage, paneMaxHeight = paneMaxHeight, showSearch = activeCount > 4, ) @@ -1527,6 +1586,8 @@ private fun YourAppsListBody( onForget: (String) -> Unit, onInstall: (String) -> Unit, installingPackage: String?, + onUninstall: (String) -> Unit, + uninstallingPackage: String?, paneMaxHeight: Dp, showSearch: Boolean, ) { @@ -1567,6 +1628,8 @@ private fun YourAppsListBody( onForget = { onForget(record.packageName) }, onInstall = { onInstall(record.packageName) }, installing = installingPackage == record.packageName, + onUninstall = { onUninstall(record.packageName) }, + uninstalling = uninstallingPackage == record.packageName, ) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 896d5c2..f8e6aad 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -272,6 +272,21 @@ class HomeViewModel( // the patched app with an official update. val installer = adbManager.resolveSpoofInstaller(device.id) val result = adbManager.installApk(record.outputApkPath, device.id, installerPackage = installer) + + // Mirror ResultScreen: if the user opted into auto-routing links, + // point the patched app at its web links right after a good install. + if (result.isSuccess) { + val config = configRepository.loadConfig() + if (config.autoRouteLinksAfterInstall) { + adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = record.installedPackageName, + stockPackage = if (config.disableStockLinksAfterInstall) record.packageName else null, + enable = true, + ) + } + } + _uiState.value = _uiState.value.copy( installingPackage = null, error = result.exceptionOrNull()?.let { "Install failed: ${it.message}" } ?: _uiState.value.error, @@ -280,6 +295,34 @@ class HomeViewModel( } } + /** + * Uninstall the patched app for [packageName] from the selected device. When + * [alsoForget] is true, the recall record is removed afterward (uninstall + + * delete history); otherwise the record is kept (uninstall + keep history) so + * the card stays as a not-installed entry the user can re-install/re-patch. + * + * Removing through Morphe (vs the launcher) keeps our device-state tracking + * accurate — [refreshDeviceInfo] runs on completion so the card flips to + * not-installed immediately. + */ + fun uninstallPatchedApp(packageName: String, alsoForget: Boolean) { + val record = patchedRecordsByPackage[packageName] ?: return + val device = DeviceMonitor.state.value.selectedDevice ?: return + if (!device.isReady || _uiState.value.uninstallingPackage != null) return + _uiState.value = _uiState.value.copy(uninstallingPackage = packageName) + screenModelScope.launch { + val result = adbManager.uninstallApk(record.installedPackageName, device.id) + if (result.isSuccess && alsoForget) { + patchedAppStore.delete(packageName) + } + _uiState.value = _uiState.value.copy( + uninstallingPackage = null, + error = result.exceptionOrNull()?.let { "Uninstall failed: ${it.message}" } ?: _uiState.value.error, + ) + refreshDeviceInfo() + } + } + /** Switch the home apps tab (ALL/YOURS) and remember it for next launch. */ fun setAppListFilter(filter: app.morphe.gui.ui.screens.home.components.AppListFilter) { if (_uiState.value.appListFilter == filter) return @@ -1193,6 +1236,8 @@ data class HomeUiState( val updatePrep: UpdatePrep? = null, /** Package currently being installed to the device from its stored output APK. */ val installingPackage: String? = null, + /** Package currently being uninstalled from the device. */ + val uninstallingPackage: String? = null, /** Per-package device install info (optional layer; empty when no device connected). */ val deviceAppInfo: Map = emptyMap(), val patchesVersion: String? = null, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt index 474cf80..97bb7d3 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt @@ -16,8 +16,10 @@ import androidx.compose.foundation.hoverable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -56,7 +58,10 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import app.morphe.engine.model.PatchedAppRecord import app.morphe.gui.ui.screens.home.DeviceAppInfo import app.morphe.gui.ui.screens.home.PatchedAppState @@ -219,7 +224,9 @@ fun YourAppRow( onUpdate: () -> Unit, onForget: () -> Unit, onInstall: () -> Unit = {}, + onUninstall: () -> Unit = {}, installing: Boolean = false, + uninstalling: Boolean = false, ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -363,6 +370,15 @@ fun YourAppRow( ) } DetailActionPill("RE-PATCH", Icons.Default.Refresh, accents.primary, mono, corners.small, onClick = onRepatch) + // Only offer uninstall when the app is actually on the connected device. + if (deviceInfo?.installed == true) { + DetailActionPill( + if (uninstalling) "UNINSTALLING…" else "UNINSTALL", + Icons.Default.Delete, + Color(0xFFE0504D), mono, corners.small, + onClick = if (uninstalling) ({}) else onUninstall, + ) + } DetailActionPill( "FORGET", Icons.Default.Delete, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), mono, corners.small, @@ -389,7 +405,9 @@ fun PatchedAppDetailDialog( onForget: () -> Unit, onOpenFolder: () -> Unit, onInstall: () -> Unit = {}, + onUninstall: () -> Unit = {}, installing: Boolean = false, + uninstalling: Boolean = false, ) { val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current @@ -398,18 +416,34 @@ fun PatchedAppDetailDialog( val hasUpdate = updateInfo != null && (updateInfo.appOutdated || updateInfo.sources.any { it.outdated }) val installPending = deviceInfo?.installPending == true - Dialog(onDismissRequest = onDismiss) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + // Tap outside the card to dismiss (the card swallows its own taps below). + .pointerInput(Unit) { detectTapGestures { onDismiss() } }, + contentAlignment = Alignment.Center, + ) { + // Grow with content, but cap at ~90% of the window height so the dialog + // can use a tall screen like Settings does, instead of the old fixed + // 560dp cap — while still wrapping shorter content. + val maxDialogHeight = maxHeight * 0.9f Surface( shape = RoundedCornerShape(corners.large), color = MaterialTheme.colorScheme.surface, border = androidx.compose.foundation.BorderStroke( 1.dp, accents.primary.copy(alpha = 0.25f), ), - modifier = Modifier.widthIn(max = 480.dp), + modifier = Modifier + .widthIn(max = 480.dp) + .pointerInput(Unit) { detectTapGestures { } }, ) { Column( modifier = Modifier - .heightIn(max = 560.dp) + .heightIn(max = maxDialogHeight) .verticalScroll(rememberScrollState()) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -594,6 +628,17 @@ fun PatchedAppDetailDialog( onDismiss(); onRepatch() } } + // Uninstall from the connected device — only when it's actually installed. + if (deviceInfo?.installed == true) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + WideActionButton( + if (uninstalling) "UNINSTALLING…" else "UNINSTALL", + "remove from device", Icons.Default.Delete, + Color(0xFFE0504D), mono, corners.small, + onClick = if (uninstalling) ({}) else ({ onDismiss(); onUninstall() }), + ) + } + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { WideActionButton("FOLDER", null, Icons.AutoMirrored.Filled.OpenInNew, accents.secondary, mono, corners.small, onClick = onOpenFolder) WideActionButton( @@ -603,6 +648,7 @@ fun PatchedAppDetailDialog( } } } + } } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 1c92b6c..06377a3 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -39,6 +39,7 @@ import cafe.adriel.voyager.core.screen.Screen 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.engine.PatchedAppStore import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp @@ -1192,6 +1193,7 @@ private fun CompletedContent( val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } + val configRepository: ConfigRepository = koinInject() val monitorState by DeviceMonitor.state.collectAsState() val adbPreference = LocalAdbPreference.current val isAdbDisabledByUser = !adbPreference.enabled @@ -1444,7 +1446,23 @@ private fun CompletedContent( deviceId = device.id ) result.fold( - onSuccess = { installSuccess = true }, + onSuccess = { + installSuccess = true + // Parity with ResultScreen: auto-route links when opted in. + val config = configRepository.loadConfig() + if (config.autoRouteLinksAfterInstall) { + val record = PatchedAppStore.shared.getAll() + .firstOrNull { it.outputApkPath == outputPath } + record?.let { + adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = it.installedPackageName, + stockPackage = if (config.disableStockLinksAfterInstall) it.packageName else null, + enable = true, + ) + } + } + }, onFailure = { installError = it.message } ) isInstalling = false diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index 52bd0fd..e2f9cd4 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -55,7 +55,9 @@ 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 app.morphe.engine.PatchedAppStore import app.morphe.engine.util.ApkManifestReader +import app.morphe.gui.data.model.SupportedApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.awt.Desktop @@ -112,6 +114,26 @@ fun ResultScreenContent(outputPath: String) { adbManager.listInstalledPackages(device.id).getOrNull()?.contains(pkg) == true } + // Link-handling ("open with") state. The stock package — needed only for the + // optional "stop stock from opening links" half — comes from the recall + // record for this output (which stores original + renamed package names). + var stockPackage by remember { mutableStateOf(null) } + var disableStockLinks by remember { mutableStateOf(false) } + var isApplyingLinks by remember { mutableStateOf(false) } + var linkProgress by remember { mutableStateOf("") } + var linkError by remember { mutableStateOf(null) } + var linkSuccess by remember { mutableStateOf(false) } + var autoRouteLinks by remember { mutableStateOf(false) } + LaunchedEffect(outputPath, outputPackage) { + stockPackage = withContext(Dispatchers.IO) { + runCatching { + val records = PatchedAppStore.shared.getAll() + records.firstOrNull { it.outputApkPath == outputPath }?.packageName + ?: outputPackage?.let { pkg -> records.firstOrNull { it.installedPackageName == pkg }?.packageName } + }.getOrNull() + } + } + // Cleanup state var hasTempFiles by remember { mutableStateOf(false) } var tempFilesSize by remember { mutableStateOf(0L) } @@ -121,6 +143,8 @@ fun ResultScreenContent(outputPath: String) { LaunchedEffect(Unit) { val config = configRepository.loadConfig() autoCleanupEnabled = config.autoCleanupTempFiles + autoRouteLinks = config.autoRouteLinksAfterInstall + disableStockLinks = config.disableStockLinksAfterInstall hasTempFiles = FileUtils.hasTempFiles() tempFilesSize = FileUtils.getTempDirSize() @@ -163,6 +187,46 @@ fun ResultScreenContent(outputPath: String) { } } + fun applyLinkHandling(enable: Boolean) { + val device = monitorState.selectedDevice ?: return + val patched = outputPackage ?: return + scope.launch { + isApplyingLinks = true + linkError = null + val result = adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = patched, + stockPackage = if (disableStockLinks) stockPackage else null, + enable = enable, + onProgress = { linkProgress = it }, + ) + result.fold( + onSuccess = { outcome -> + linkSuccess = enable + linkProgress = when { + !enable -> "Default link handling restored" + outcome.stockChanged -> "Links routed to patched app, stock disabled" + else -> "Links routed to patched app" + } + }, + onFailure = { e -> + linkError = (e as? AdbException)?.message ?: e.message ?: "Unknown error" + } + ) + isApplyingLinks = false + } + } + + // Auto-route links once, right after a successful install, when the global + // setting is on. outputPackage is required (the apply no-ops without it). + LaunchedEffect(installSuccess, autoRouteLinks, outputPackage) { + if (installSuccess && autoRouteLinks && outputPackage != null && + !linkSuccess && !isApplyingLinks && linkError == null + ) { + applyLinkHandling(enable = true) + } + } + Column( modifier = Modifier .fillMaxSize() @@ -279,6 +343,30 @@ fun ResultScreenContent(outputPath: String) { }, onDismissError = { installError = null } ) + + // Link handling ("open with"). Only meaningful once the patched + // app is on the device, so gate on a successful install (or the + // app already being present) + a ready, selected device. + val device = monitorState.selectedDevice + if (outputPackage != null && device?.isReady == true && (installSuccess || alreadyInstalled)) { + LinkHandlingSection( + patchedPackage = outputPackage!!, + stockPackage = stockPackage?.takeIf { it != outputPackage }, + disableStockLinks = disableStockLinks, + onToggleDisableStock = { disableStockLinks = it }, + isApplying = isApplyingLinks, + progress = linkProgress, + error = linkError, + success = linkSuccess, + selectedDeviceName = device.displayName, + corners = corners, + mono = mono, + borderColor = borderColor, + onApply = { applyLinkHandling(enable = true) }, + onRestore = { applyLinkHandling(enable = false) }, + onDismissError = { linkError = null }, + ) + } } // Cleanup section @@ -656,6 +744,222 @@ private fun AdbInstallSection( } } +// ═══════════════════════════════════════════════════════════════════ +// LINK HANDLING ("OPEN WITH") SECTION +// ═══════════════════════════════════════════════════════════════════ + +/** + * Route the patched app's web links to it (and optionally stop the stock app + * from grabbing them). Shown only once the patched app is installed on a ready + * device. The stock-disable checkbox appears only when a rename patch was used + * (a distinct [stockPackage]); on-device, [AdbManager.setLinkHandling] still + * verifies the stock app is actually installed before touching it. + */ +@Composable +private fun LinkHandlingSection( + patchedPackage: String, + stockPackage: String?, + disableStockLinks: Boolean, + onToggleDisableStock: (Boolean) -> Unit, + isApplying: Boolean, + progress: String, + error: String?, + success: Boolean, + selectedDeviceName: String?, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + onApply: () -> Unit, + onRestore: () -> Unit, + onDismissError: () -> Unit, +) { + val accents = LocalMorpheAccents.current + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = "LINK HANDLING", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Open supported web links in the patched app instead of the browser.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + ) + + // Optional OFF half — only when a rename was used so stock + patched coexist. + if (stockPackage != null) { + Spacer(Modifier.height(12.dp)) + val stockName = SupportedApp.getDisplayName(stockPackage) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .clickable(enabled = !isApplying) { onToggleDisableStock(!disableStockLinks) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Checkbox( + checked = disableStockLinks, + onCheckedChange = { onToggleDisableStock(it) }, + enabled = !isApplying, + colors = CheckboxDefaults.colors(checkedColor = accents.secondary), + modifier = Modifier.size(20.dp) + ) + Text( + text = "Also stop $stockName from opening these links", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + } + + Spacer(Modifier.height(14.dp)) + + when { + error != null -> { + Text( + text = error, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(10.dp)) + SecondaryActionChip(text = "DISMISS", corners = corners, mono = mono, onClick = onDismissError) + } + + isApplying -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = accents.primary + ) + Spacer(Modifier.width(10.dp)) + Text( + text = progress.ifEmpty { "Applying..." }.uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + } + + success -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = accents.secondary, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = progress.ifEmpty { "Links routed to patched app" }.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 0.5.sp, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + SecondaryActionChip(text = "RESTORE", corners = corners, mono = mono, onClick = onRestore) + } + } + + else -> { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val bg by animateColorAsState( + if (isHovered) accents.secondary.copy(alpha = 0.9f) else accents.secondary, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .background(bg, RoundedCornerShape(corners.small)) + .clickable(onClick = onApply), + contentAlignment = Alignment.Center + ) { + Text( + text = "OPEN LINKS WITH PATCHED APP", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 0.5.sp + ) + } + } + } + } + } +} + +/** Small bordered text button used for secondary actions (Dismiss/Restore). */ +@Composable +private fun SecondaryActionChip( + text: String, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Box( + modifier = Modifier + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.3f else 0.12f), + RoundedCornerShape(corners.small) + ) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp + ) + } +} + // ═══════════════════════════════════════════════════════════════════ // CLEANUP SECTION // ═══════════════════════════════════════════════════════════════════ diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt index 2adab97..adf40fa 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -5,6 +5,7 @@ package app.morphe.gui.util +import app.morphe.engine.util.AppLinkCommands import app.morphe.engine.util.SignatureIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -416,6 +417,44 @@ class AdbManager { } } + /** + * Uninstall [packageName] from [deviceId]. Used by the "Your apps" cards so a + * patched app can be removed through Morphe (keeping our recall state accurate) + * instead of from the launcher behind our back. + * + * Treats "package not installed" as success — the desired end state (app gone) + * already holds, so the caller can refresh and move on. + */ + suspend fun uninstallApk( + packageName: String, + deviceId: String, + ): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + try { + val process = ProcessBuilder(adb, "-s", deviceId, "uninstall", packageName) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + val exitCode = process.waitFor() + Logger.info("uninstall $packageName on $deviceId -> exit $exitCode: $output") + + when { + exitCode == 0 && output.contains("Success") -> Result.success(Unit) + // Already gone is the end state we wanted. + output.contains("not installed", ignoreCase = true) || + output.contains("DELETE_FAILED_INTERNAL_ERROR", ignoreCase = true) && + listInstalledPackages(deviceId).getOrNull()?.contains(packageName) == false -> + Result.success(Unit) + else -> Result.failure(AdbException("Uninstall failed: ${output.ifBlank { "exit $exitCode" }}")) + } + } catch (e: Exception) { + Logger.error("Error uninstalling $packageName", e) + Result.failure(AdbException("Uninstall failed: ${e.message}")) + } + } + /** * Clear the device's logcat buffers (main + crash). * Crash buffer clear is best-effort — older devices may not have it. @@ -493,6 +532,106 @@ class AdbManager { } } + // ── Link handling ("open with") ────────────────────────────────────────── + + /** + * Route the patched app's declared web links to it, and optionally stop the + * stock app from handling those same links. Reverse with [enable] = false. + * + * The OFF half (stock) only runs when [stockPackage] is a real, different, + * *installed* package — i.e. a rename patch was used and stock is present. + * Otherwise it's skipped (reported via [LinkHandlingResult.stockChanged]), + * never silently no-op'd. + * + * Commands come from [AppLinkCommands] so the CLI and GUI share one source + * of truth; here we just execute them through `adb -s shell`. + * + * Must be called AFTER the patched app is installed — the package has to + * exist on the device for `pm set-app-links-*` to take effect. + */ + suspend fun setLinkHandling( + deviceId: String, + patchedPackage: String, + stockPackage: String? = null, + enable: Boolean = true, + onProgress: (String) -> Unit = {}, + ): Result = withContext(Dispatchers.IO) { + findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + // Stock OFF only applies to a genuinely different, installed package. + val installed = listInstalledPackages(deviceId).getOrNull() ?: emptySet() + val stockEligible = !stockPackage.isNullOrBlank() && + stockPackage != patchedPackage && + stockPackage in installed + + onProgress( + if (enable) "Routing links to $patchedPackage..." + else "Restoring default link handling..." + ) + + val patchedCommands = if (enable) AppLinkCommands.enablePatched(patchedPackage) + else AppLinkCommands.restorePatched(patchedPackage) + runShellCommands(deviceId, patchedCommands).onFailure { + return@withContext Result.failure(it) + } + + var stockChanged = false + if (stockEligible) { + val stockCommands = if (enable) AppLinkCommands.disableStock(stockPackage!!) + else AppLinkCommands.restoreStock(stockPackage!!) + onProgress( + if (enable) "Disabling links in $stockPackage..." + else "Re-enabling links in $stockPackage..." + ) + runShellCommands(deviceId, stockCommands).onFailure { + return@withContext Result.failure(it) + } + stockChanged = true + } + + Logger.info( + "Link handling ${if (enable) "enabled" else "restored"} for $patchedPackage" + + (if (stockChanged) " (stock $stockPackage toggled)" else "") + ) + Result.success(LinkHandlingResult(patchedChanged = true, stockChanged = stockChanged)) + } + + /** + * Run a sequence of `adb -s shell ` commands, stopping at the + * first failure. `pm set-app-links-*` print nothing on success and exit 0; a + * non-zero exit (or "Error"/"Failure" in output) is treated as a failure. + */ + private suspend fun runShellCommands( + deviceId: String, + commands: List>, + ): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure(AdbException("ADB not found")) + for (argv in commands) { + try { + val process = ProcessBuilder(listOf(adb, "-s", deviceId, "shell") + argv) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + val exitCode = process.waitFor() + Logger.debug("ADB shell ${argv.joinToString(" ")} -> exit $exitCode${if (output.isNotBlank()) ": $output" else ""}") + if (exitCode != 0 || + output.contains("Error", ignoreCase = true) || + output.contains("Failure", ignoreCase = true) + ) { + return@withContext Result.failure( + AdbException("Command failed (pm ${argv.getOrNull(1) ?: ""}): ${output.ifBlank { "exit $exitCode" }}") + ) + } + } catch (e: Exception) { + Logger.error("Error running adb shell ${argv.joinToString(" ")}", e) + return@withContext Result.failure(AdbException("Failed to run command: ${e.message}")) + } + } + Result.success(Unit) + } + // ── Patched-app recall: device-side queries ────────────────────────────── /** Package names installed on [deviceId] (`pm list packages`). */ @@ -683,6 +822,15 @@ enum class DeviceStatus { UNKNOWN // Unknown status } +/** + * Outcome of [AdbManager.setLinkHandling]. [stockChanged] is false when the + * stock-app OFF step was skipped (no rename, or stock not installed). + */ +data class LinkHandlingResult( + val patchedChanged: Boolean, + val stockChanged: Boolean, +) + open class AdbException(message: String) : Exception(message) class AdbMultipleDevicesException( diff --git a/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt b/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt new file mode 100644 index 0000000..74c5255 --- /dev/null +++ b/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.engine.util.AppLinkCommands +import kotlin.test.Test +import kotlin.test.assertEquals + +class AppLinkCommandsTest { + + @Test + fun `enablePatched sets master switch then approves all hosts`() { + val cmds = AppLinkCommands.enablePatched("app.morphe.android.youtube") + assertEquals( + listOf( + listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "app.morphe.android.youtube", "true"), + listOf("pm", "set-app-links-user-selection", "--user", "0", "--package", "app.morphe.android.youtube", "true", "all"), + ), + cmds, + ) + } + + @Test + fun `disableStock turns off the master switch only`() { + val cmds = AppLinkCommands.disableStock("com.google.android.youtube") + assertEquals( + listOf( + listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "com.google.android.youtube", "false"), + ), + cmds, + ) + } + + @Test + fun `restore commands invert apply commands`() { + assertEquals( + listOf(listOf("pm", "set-app-links-user-selection", "--user", "0", "--package", "p", "false", "all")), + AppLinkCommands.restorePatched("p"), + ) + assertEquals( + listOf(listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "s", "true")), + AppLinkCommands.restoreStock("s"), + ) + } + + @Test + fun `custom user id is threaded through`() { + val cmds = AppLinkCommands.enablePatched("p", user = "10") + assertEquals("10", cmds.first()[3]) + } +} From 44be4adde37746e3fdb3880129386d4c1e48c746 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 23 Jun 2026 07:06:31 +0000 Subject: [PATCH 06/39] chore: Release v1.10.0-dev.2 [skip ci] # [1.10.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.1...v1.10.0-dev.2) (2026-06-23) ### Features * route links through patched app + uninstall from "Your Apps" section's cards ([#179](https://github.com/MorpheApp/morphe-cli/issues/179)) ([1626ce5](https://github.com/MorpheApp/morphe-cli/commit/1626ce5ffe3300de2299443ed9b7587ebd1cdcb6)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 832b551..88394fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.1...v1.10.0-dev.2) (2026-06-23) + + +### Features + +* route links through patched app + uninstall from "Your Apps" section's cards ([#179](https://github.com/MorpheApp/morphe-cli/issues/179)) ([1626ce5](https://github.com/MorpheApp/morphe-cli/commit/1626ce5ffe3300de2299443ed9b7587ebd1cdcb6)) + # [1.10.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.2-dev.1...v1.10.0-dev.1) (2026-06-15) diff --git a/gradle.properties b/gradle.properties index 0f78e7f..e25631f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.1 +version = 1.10.0-dev.2 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From 0d47c625aa37c1f021244266206a346a38c71929 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:12:00 +0300 Subject: [PATCH 07/39] chore: Update npm dependencies --- package-lock.json | 275 +++++++++++++++++++++++++--------------------- 1 file changed, 151 insertions(+), 124 deletions(-) diff --git a/package-lock.json b/package-lock.json index f5a43ad..690d927 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,9 +45,9 @@ } }, "node_modules/@actions/http-client/node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "dev": true, "license": "MIT", "engines": { @@ -62,13 +62,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -77,9 +77,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -229,16 +229,16 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", - "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.10.tgz", + "integrity": "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==", "dev": true, "license": "MIT", "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", + "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" }, @@ -300,9 +300,9 @@ "license": "ISC" }, "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", "dev": true, "license": "MIT", "dependencies": { @@ -1142,9 +1142,9 @@ } }, "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1284,6 +1284,20 @@ "proto-list": "~1.2.1" } }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/conventional-changelog-angular": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", @@ -1365,9 +1379,9 @@ "license": "MIT" }, "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { @@ -1751,23 +1765,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -2028,28 +2025,30 @@ } }, "node_modules/http-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", - "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "9.0.0", - "debug": "^4.3.4" + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { "node": ">= 20" } }, "node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "9.0.0", - "debug": "^4.3.4" + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { "node": ">= 20" @@ -2279,10 +2278,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2426,9 +2435,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2636,9 +2645,9 @@ } }, "node_modules/normalize-url": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", - "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", "dev": true, "license": "MIT", "engines": { @@ -2649,9 +2658,9 @@ } }, "node_modules/npm": { - "version": "11.14.1", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.14.1.tgz", - "integrity": "sha512-aopNZ0eEl6LbxoFcrXLmTEPzNBNxfiQnVgR9RmJBqzm+5h5pFoOmRljpRJbsXxocBeSl7GLcx3MoDf2UlEOjZw==", + "version": "11.17.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.17.0.tgz", + "integrity": "sha512-PurxiZexEHDTE4SSaLI3ZrnbAGiZfeyUcQcxcP5D+hfytNAze/D1IzDuInTn9XVLIbAQUnQuSPXJx02LHjLvQw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -2730,8 +2739,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.5.0", - "@npmcli/config": "^10.9.0", + "@npmcli/arborist": "^9.8.0", + "@npmcli/config": "^10.11.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -2749,27 +2758,27 @@ "fs-minipass": "^3.0.3", "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "hosted-git-info": "^9.0.2", + "hosted-git-info": "^9.0.3", "ini": "^6.0.0", "init-package-json": "^8.2.5", "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.7", - "libnpmexec": "^10.2.7", - "libnpmfund": "^7.0.21", + "libnpmdiff": "^8.1.10", + "libnpmexec": "^10.3.0", + "libnpmfund": "^7.0.24", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.7", - "libnpmpublish": "^11.1.3", + "libnpmpack": "^9.1.10", + "libnpmpublish": "^11.2.0", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.3", - "make-fetch-happen": "^15.0.5", + "libnpmversion": "^8.0.4", + "make-fetch-happen": "^15.0.6", "minimatch": "^10.2.5", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^12.3.0", + "node-gyp": "^12.4.0", "nopt": "^9.0.0", "npm-audit-report": "^7.0.0", "npm-install-checks": "^8.0.0", @@ -2779,16 +2788,16 @@ "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", - "pacote": "^21.5.0", + "pacote": "^21.5.1", "parse-conflict-json": "^5.0.1", "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.7.4", + "semver": "^7.8.4", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.13", + "tar": "^7.5.16", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -2844,7 +2853,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { - "version": "4.0.0", + "version": "4.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -2860,7 +2869,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.5.0", + "version": "9.8.0", "dev": true, "inBundle": true, "license": "ISC", @@ -2908,7 +2917,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.9.0", + "version": "10.11.0", "dev": true, "inBundle": true, "license": "ISC", @@ -3102,7 +3111,7 @@ } }, "node_modules/npm/node_modules/@sigstore/core": { - "version": "3.2.0", + "version": "3.2.1", "dev": true, "inBundle": true, "license": "Apache-2.0", @@ -3150,13 +3159,13 @@ } }, "node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.1.0", + "version": "3.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -3225,7 +3234,7 @@ } }, "node_modules/npm/node_modules/bin-links": { - "version": "6.0.0", + "version": "6.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -3253,7 +3262,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.5", + "version": "5.0.6", "dev": true, "inBundle": true, "license": "MIT", @@ -3446,7 +3455,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "9.0.2", + "version": "9.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -3545,7 +3554,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.1.1", + "version": "10.2.0", "dev": true, "inBundle": true, "license": "MIT", @@ -3627,12 +3636,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.7", + "version": "8.1.10", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.5.0", + "@npmcli/arborist": "^9.8.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -3646,13 +3655,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.7", + "version": "10.3.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.5.0", + "@npmcli/arborist": "^9.8.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -3669,12 +3678,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.21", + "version": "7.0.24", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.5.0" + "@npmcli/arborist": "^9.8.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -3694,12 +3703,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.7", + "version": "9.1.10", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.5.0", + "@npmcli/arborist": "^9.8.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -3709,7 +3718,7 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.3", + "version": "11.2.0", "dev": true, "inBundle": true, "license": "ISC", @@ -3753,7 +3762,7 @@ } }, "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.3", + "version": "8.0.4", "dev": true, "inBundle": true, "license": "ISC", @@ -3769,7 +3778,7 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "11.3.5", + "version": "11.5.1", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -3778,7 +3787,7 @@ } }, "node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.5", + "version": "15.0.6", "dev": true, "inBundle": true, "license": "ISC", @@ -3944,7 +3953,7 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "12.3.0", + "version": "12.4.0", "dev": true, "inBundle": true, "license": "MIT", @@ -4121,7 +4130,7 @@ } }, "node_modules/npm/node_modules/pacote": { - "version": "21.5.0", + "version": "21.5.1", "dev": true, "inBundle": true, "license": "ISC", @@ -4182,7 +4191,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.1", + "version": "7.1.4", "dev": true, "inBundle": true, "license": "MIT", @@ -4279,7 +4288,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.7.4", + "version": "7.8.4", "dev": true, "inBundle": true, "license": "ISC", @@ -4303,17 +4312,17 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "4.1.0", + "version": "4.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -4330,7 +4339,7 @@ } }, "node_modules/npm/node_modules/socks": { - "version": "2.8.8", + "version": "2.8.9", "dev": true, "inBundle": true, "license": "MIT", @@ -4404,7 +4413,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.13", + "version": "7.5.16", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -4432,7 +4441,7 @@ "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.16", + "version": "0.2.17", "dev": true, "inBundle": true, "license": "MIT", @@ -4500,7 +4509,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.25.0", + "version": "6.26.0", "dev": true, "inBundle": true, "license": "MIT", @@ -4905,6 +4914,24 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -5047,9 +5074,9 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", - "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", + "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", "dev": true, "license": "MIT", "dependencies": { @@ -5295,9 +5322,9 @@ } }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5788,9 +5815,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5872,9 +5899,9 @@ } }, "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -5902,9 +5929,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { From d8245f081b507cac74b06e16120e4271ce0e1f96 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:17:46 +0530 Subject: [PATCH 08/39] fix: Handle GUI patching xapk / apks, add distinct patch-source channel colors, improve patch loading errors (#180) - The engine only merged `.apkm` bundles; `.xapk`/`.apks` were handed to the patcher unmerged and crashed with a null-manifest NPE. Fixed now. - Older-stable, older-dev, and latest-dev all rendered as yellow/amber. That impossible to tell apart. Now four distinct colors ( green = stable latest, amber = stable older, blue = dev latest, red = dev older ) - Better patch load errors thrown. --- .../app/morphe/cli/command/PatchCommand.kt | 2 +- .../kotlin/app/morphe/engine/PatchEngine.kt | 9 ++++-- .../app/morphe/engine/util/BundleFormats.kt | 26 +++++++++++++++ .../ui/components/SourceManagementSheet.kt | 14 ++++---- .../morphe/gui/ui/components/SourcesPill.kt | 21 ++++++------ .../app/morphe/gui/ui/theme/ChannelColors.kt | 32 +++++++++++++++++++ .../kotlin/app/morphe/gui/util/FileUtils.kt | 2 +- .../app/morphe/gui/util/PatchService.kt | 9 +++++- 8 files changed, 91 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/app/morphe/engine/util/BundleFormats.kt create mode 100644 src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 4fa7747..ebefc2f 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -620,7 +620,7 @@ internal object PatchCommand : Callable { // We need to check for apkm (like reddit), xapk and apks formats here - val inputApk = if (apk.extension.lowercase() in setOf("apkm", "xapk", "apks")) { + val inputApk = if (app.morphe.engine.util.BundleFormats.isBundle(apk)) { logger.info("Merging split APK bundle") diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index 26d64a2..49b4040 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -8,6 +8,7 @@ package app.morphe.engine +import app.morphe.engine.util.BundleFormats import app.morphe.engine.util.signWithLegacyFallback import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig @@ -100,9 +101,11 @@ object PatchEngine { 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...") + // 1. Handle split-APK bundles (.apkm/.xapk/.apks) — merge splits into + // a single APK. ApkMerger is format-agnostic (it just extracts the + // .apk entries from the zip), so all three formats go through here. + val actualInputApk = if (BundleFormats.isBundle(config.inputApk)) { + onProgress("Merging split APKs...") val mergedApk = File(tempDir, "${config.inputApk.nameWithoutExtension}-merged.apk") ApkMerger(logger.toMorpheLogger()).merge( inputFile = config.inputApk, diff --git a/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt b/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt new file mode 100644 index 0000000..ed10df8 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File + +/** + * Split-APK bundle container formats — ZIP archives that hold a base APK plus + * config/density/abi split APKs (`.apkm` from APKMirror, `.xapk` from APKPure, + * `.apks` from bundletool/SAI). + * + * Single source of truth for "is this a bundle?" so the engine, GUI, and CLI + * all agree. Lives in `engine.util` because it's a pure data check with no UI + * or CLI dependencies, and the GUI/CLI both depend on the engine (not the + * reverse). + */ +object BundleFormats { + /** Bundle file extensions, lowercase, without the leading dot. */ + val EXTENSIONS = setOf("apkm", "xapk", "apks") + + /** True if [file]'s extension is a split-APK bundle format. */ + fun isBundle(file: File): Boolean = file.extension.lowercase() in EXTENSIONS +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt index f53a2c2..630ff67 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt @@ -642,14 +642,14 @@ private fun ChannelBadge( mono: androidx.compose.ui.text.font.FontFamily, ) { val corners = LocalMorpheCorners.current - val accents = LocalMorpheAccents.current - val (label, color) = when (channel) { - app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_LATEST -> "STABLE LATEST" to accents.secondary - app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_OLDER -> "STABLE OLDER" to accents.warning - app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_LATEST -> "DEV LATEST" to androidx.compose.ui.graphics.Color(0xFFFFD43B) - app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_OLDER -> "DEV OLDER" to accents.warning - else -> "STABLE LATEST" to accents.secondary + val label = when (channel) { + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_LATEST -> "STABLE LATEST" + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_OLDER -> "STABLE OLDER" + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_LATEST -> "DEV LATEST" + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_OLDER -> "DEV OLDER" + else -> "STABLE LATEST" } + val color = app.morphe.gui.ui.theme.channelColor(channel) Box( modifier = Modifier .border(1.dp, color.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt index 7eb200e..c47e595 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt @@ -25,7 +25,6 @@ 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.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.font.FontWeight @@ -36,11 +35,10 @@ import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.LocalMorpheFont -import app.morphe.gui.ui.theme.MorpheAccentColors import app.morphe.gui.util.EnabledSourcesLoader /** Per-source LED state surfaced in [SourcesCountPill]. */ -enum class SourceLedState { DISABLED, STABLE_LATEST, OLDER, DEV } +enum class SourceLedState { DISABLED, STABLE_LATEST, STABLE_OLDER, DEV_LATEST, DEV_OLDER } /** * Header pill showing source count + per-source channel LEDs + trailing "+". @@ -98,7 +96,7 @@ fun SourcesCountPill( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp), ) { - sourceStates.forEach { state -> SourceLed(state = state, accents = accents) } + sourceStates.forEach { state -> SourceLed(state = state) } } } if (interactive) { @@ -113,12 +111,13 @@ fun SourcesCountPill( } @Composable -private fun SourceLed(state: SourceLedState, accents: MorpheAccentColors) { +private fun SourceLed(state: SourceLedState) { val color = when (state) { SourceLedState.DISABLED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - SourceLedState.STABLE_LATEST -> accents.primary - SourceLedState.OLDER -> accents.warning - SourceLedState.DEV -> Color(0xFFFFD43B) + SourceLedState.STABLE_LATEST -> app.morphe.gui.ui.theme.channelColor(EnabledSourcesLoader.Channel.STABLE_LATEST) + SourceLedState.STABLE_OLDER -> app.morphe.gui.ui.theme.channelColor(EnabledSourcesLoader.Channel.STABLE_OLDER) + SourceLedState.DEV_LATEST -> app.morphe.gui.ui.theme.channelColor(EnabledSourcesLoader.Channel.DEV_LATEST) + SourceLedState.DEV_OLDER -> app.morphe.gui.ui.theme.channelColor(EnabledSourcesLoader.Channel.DEV_OLDER) } Box( modifier = Modifier @@ -135,9 +134,9 @@ fun sourceLedState( if (!source.enabled) return SourceLedState.DISABLED return when (channel) { EnabledSourcesLoader.Channel.STABLE_LATEST -> SourceLedState.STABLE_LATEST - EnabledSourcesLoader.Channel.STABLE_OLDER -> SourceLedState.OLDER - EnabledSourcesLoader.Channel.DEV_LATEST, - EnabledSourcesLoader.Channel.DEV_OLDER -> SourceLedState.DEV + EnabledSourcesLoader.Channel.STABLE_OLDER -> SourceLedState.STABLE_OLDER + EnabledSourcesLoader.Channel.DEV_LATEST -> SourceLedState.DEV_LATEST + EnabledSourcesLoader.Channel.DEV_OLDER -> SourceLedState.DEV_OLDER // No load yet — assume latest until we know otherwise. null, EnabledSourcesLoader.Channel.UNKNOWN -> SourceLedState.STABLE_LATEST } diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt b/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt new file mode 100644 index 0000000..7e2eff2 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt @@ -0,0 +1,32 @@ +package app.morphe.gui.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import app.morphe.gui.util.EnabledSourcesLoader.Channel + +/** + * Semantic color for a patch source's release channel. Single source of truth so + * the source-card badge and the home-screen pill LED always agree. + * + * Two axes: channel (stable/dev) and recency (latest/older). Map to four distinct, intuitive hues: + * - green (secondary) stable + latest → recommended, current + * - amber (warning) stable + older → safe channel, update available + * - blue (tertiary) dev + latest → experimental, on the newest build + * - red (error) dev + older → experimental AND behind, riskiest + * + * UNKNOWN / not-yet-loaded falls back to the "recommended" green. + */ +@Composable +@ReadOnlyComposable +fun channelColor(channel: Channel?): Color { + val accents = LocalMorpheAccents.current + return when (channel) { + Channel.STABLE_LATEST -> accents.secondary + Channel.STABLE_OLDER -> accents.warning + Channel.DEV_LATEST -> accents.tertiary + Channel.DEV_OLDER -> MaterialTheme.colorScheme.error + null, Channel.UNKNOWN -> accents.secondary + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt index 45532ca..e78552f 100644 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -26,7 +26,7 @@ object FileUtils { */ val ANDROID_ARCHITECTURES = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") - private val EXTENSION_APK_BUNDLES = setOf("apkm", "xapk", "apks") + private val EXTENSION_APK_BUNDLES = app.morphe.engine.util.BundleFormats.EXTENSIONS private val EXTENSION_APK_ANY = EXTENSION_APK_BUNDLES + "apk" /** Returns the unified Morphe data root. Was: per-OS app-data folder. */ diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index c802e76..9660fbe 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -172,7 +172,14 @@ class PatchService { } finally { tempCopies.forEach { runCatching { it.delete() } } } - } catch (e: Exception) { + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + // Catch Throwable, not just Exception: a mismatched patch bundle can + // throw java.lang.Error (e.g. NoSuchMethodError when two sources ship + // the same class compiled against different patcher versions). Those + // are Errors, not Exceptions, so catch(Exception) would let them escape and the UI + // would hang on "Loading patches" forever instead of surfacing a failure. Logger.error("Patching failed", e) Result.failure(e) } From 89801d26423d45f4361bc16b3cb813a39c4c1f57 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 23 Jun 2026 07:49:18 +0000 Subject: [PATCH 09/39] chore: Release v1.10.0-dev.3 [skip ci] # [1.10.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.2...v1.10.0-dev.3) (2026-06-23) ### Bug Fixes * Handle GUI patching xapk / apks, add distinct patch-source channel colors, improve patch loading errors ([#180](https://github.com/MorpheApp/morphe-cli/issues/180)) ([d8245f0](https://github.com/MorpheApp/morphe-cli/commit/d8245f081b507cac74b06e16120e4271ce0e1f96)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88394fa..c99e042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.2...v1.10.0-dev.3) (2026-06-23) + + +### Bug Fixes + +* Handle GUI patching xapk / apks, add distinct patch-source channel colors, improve patch loading errors ([#180](https://github.com/MorpheApp/morphe-cli/issues/180)) ([d8245f0](https://github.com/MorpheApp/morphe-cli/commit/d8245f081b507cac74b06e16120e4271ce0e1f96)) + # [1.10.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.1...v1.10.0-dev.2) (2026-06-23) diff --git a/gradle.properties b/gradle.properties index e25631f..04349e5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.2 +version = 1.10.0-dev.3 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From 755909ca2ab8735ffe99b3ad200a80316e6ec0c9 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:20:09 +0530 Subject: [PATCH 10/39] feat: Array GUI patches alphabetically, improve error logging, use separate class loader for multiple patch bundles (#187) --- .../patches/PatchSelectionViewModel.kt | 2 +- .../app/morphe/gui/util/PatchService.kt | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt index 06c8e38..e82c48a 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -260,7 +260,7 @@ class PatchSelectionViewModel( BundlePatches( bundleId = "bundle-$idx-${File(path).nameWithoutExtension}", bundleName = displayName, - patches = patches, + patches = patches.sortedBy { it.name.lowercase() }, ) } diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 9660fbe..750417b 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -15,6 +15,7 @@ import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import app.morphe.patcher.apk.ApkUtils +import app.morphe.engine.patches.PatchBundleLoader import java.io.File import kotlin.reflect.KType import app.morphe.patcher.patch.Patch as LibraryPatch @@ -115,7 +116,7 @@ class PatchService { tmp } try { - val loadedPatches = loadPatchesFromJar(tempCopies.toSet()) + val loadedPatches = PatchBundleLoader.loadFlat(tempCopies) // Convert GUI's flat "patchName.optionKey" -> value map // to engine's Map> format @@ -150,9 +151,28 @@ class PatchService { val engineResult = PatchEngine.patch(config, onProgress) + // Build the COMPLETE failure detail (full stack traces, incl. any + // nested "Caused by:" cause). Used for both the log file and the + // UI's expandable "Details" section. The short [failureReason] + // below is only the one-line banner summary. It must never be the + // single source, or the cause gets lost (e.g. a PatchException + // ending in "...which raised an exception:" with the cause dropped). + val failureDetail: String? = if (engineResult.success) null else buildString { + engineResult.failedPatches.forEach { fp -> + appendLine("Patch '${fp.name}' failed:") + appendLine(fp.error) + } + engineResult.stepResults + .filter { !it.success && it.error != null } + .forEach { appendLine("Step ${it.step.name} failed:"); appendLine(it.error) } + }.takeIf { it.isNotBlank() } + failureDetail?.let { Logger.error("Patching failed — full detail:\n$it") } + val failureReason = if (engineResult.success) null else { // Prefer a specific failed-patch error, else the last failed // step's error (rebuild/sign), else a generic fallback. + // First line only — this is the short UI-banner summary; the + // full traces are already logged above. engineResult.failedPatches.firstOrNull()?.let { fp -> "${fp.name}: ${fp.error.lineSequence().first()}" } @@ -166,6 +186,7 @@ class PatchService { appliedPatches = engineResult.appliedPatches, failedPatches = engineResult.failedPatches.map { it.name }, failureReason = failureReason, + failureDetail = failureDetail, packageName = engineResult.packageName, packageVersion = engineResult.packageVersion, )) @@ -285,6 +306,10 @@ data class PatchResult( // failed patch's error or — when patching succeeded but a later step // (rebuild, sign) blew up — that step's error. Null on success. val failureReason: String? = null, + // Full failure detail: complete stack traces (incl. nested "Caused by:" + // causes) for every failed patch and step. The expandable "Details" + // counterpart to the one-line [failureReason]. Null on success. + val failureDetail: String? = null, // Surfaced from the engine so callers (e.g. patched-app history) can record // what was actually patched. Empty when the patcher didn't report them. val packageName: String = "", From 6085b6f8e252b63c3eb1650ba960e3a664c08175 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 27 Jun 2026 06:51:26 +0000 Subject: [PATCH 11/39] chore: Release v1.10.0-dev.4 [skip ci] # [1.10.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.3...v1.10.0-dev.4) (2026-06-27) ### Features * Array GUI patches alphabetically, improve error logging, use separate class loader for multiple patch bundles ([#187](https://github.com/MorpheApp/morphe-cli/issues/187)) ([755909c](https://github.com/MorpheApp/morphe-cli/commit/755909ca2ab8735ffe99b3ad200a80316e6ec0c9)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c99e042..2f5f32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.3...v1.10.0-dev.4) (2026-06-27) + + +### Features + +* Array GUI patches alphabetically, improve error logging, use separate class loader for multiple patch bundles ([#187](https://github.com/MorpheApp/morphe-cli/issues/187)) ([755909c](https://github.com/MorpheApp/morphe-cli/commit/755909ca2ab8735ffe99b3ad200a80316e6ec0c9)) + # [1.10.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.2...v1.10.0-dev.3) (2026-06-23) diff --git a/gradle.properties b/gradle.properties index 04349e5..3525490 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.3 +version = 1.10.0-dev.4 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From 5d349931b283f62f617e7a1ceb10009cf2639a8e Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:14:02 +0530 Subject: [PATCH 12/39] feat: Patch sources follows latest (stable/dev) instead of to last used version (#185) On startup, the gui automatically gets the latest version of dev/stable if available based on user's previous choice and shifts to it. An older version is pinned only if the user had previously pinned an older version. !Important: Existing sources are moved onto the latest of their channel. Anyone who had deliberately pinned an older version will be bumped to latest. Users will need to re-pin for now. --- .../app/morphe/gui/data/model/AppConfig.kt | 49 ++++++++++++--- .../gui/data/repository/ConfigRepository.kt | 60 +++++++++++-------- .../gui/ui/screens/home/HomeViewModel.kt | 11 ++-- .../ui/screens/patches/PatchesViewModel.kt | 56 +++++++++++++---- .../morphe/gui/util/EnabledSourcesLoader.kt | 40 ++++++++----- 5 files changed, 151 insertions(+), 65 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index 6a5ef9d..92165dc 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -25,24 +25,57 @@ val DEFAULT_PATCH_SOURCE = PatchSource( deletable = false ) +/** + * How a patch source decides which release to load. + * + * - [FOLLOW_STABLE]: ride the newest **stable** (non-pre-release) — auto-updates + * as new stables ship. The default for an untouched source. + * - [FOLLOW_DEV]: ride the newest release **overall**, pre-releases included + * ("bleeding edge"). When a dev is newest you get the dev; when a stable is the + * newest thing out, you get that stable — without losing the dev track. + * - [PINNED]: stay frozen on one exact tag (chosen deliberately), ignoring newer + * releases. The version lives in [SourceVersionPref.pinnedTag]. + */ +@Serializable +enum class FollowMode { FOLLOW_STABLE, FOLLOW_DEV, PINNED } + +/** + * A source's version preference: which release-tracking [mode], plus the exact + * tag when [mode] is [FollowMode.PINNED] (null otherwise). + */ +@Serializable +data class SourceVersionPref( + val mode: FollowMode, + val pinnedTag: String? = null, +) + @Serializable data class AppConfig( val themePreference: String = ThemePreference.SYSTEM.name, val lastCliVersion: String? = null, /** - * LEGACY single-source version pin. Kept only for one-version migration into - * [lastPatchesVersionBySource] — read it on first load if the map is empty, - * then phase out. Do not read this directly anywhere new — go through - * [ConfigRepository.getLastPatchesVersionsBySource]. + * LEGACY single-source version pin. Kept only so it can be migrated (via + * [lastPatchesVersionBySource]) into [sourceVersionPrefs]. Do not read directly + * anywhere new — go through [ConfigRepository.getSourceVersionPrefs]. */ val lastPatchesVersion: String? = null, /** - * Per-source version pin: sourceId → release tag. Absence of a key means - * "no pin — use that source's latest stable". Replaces the legacy single - * [lastPatchesVersion] which silently contaminated other sources whose tag - * names happened to overlap. + * LEGACY per-source version pin: sourceId → release tag. Superseded by + * [sourceVersionPrefs]; kept only so existing configs can migrate (every old + * tag becomes a follow-track based on whether it was a dev tag). Do not read + * directly — go through [ConfigRepository.getSourceVersionPrefs]. */ val lastPatchesVersionBySource: Map = emptyMap(), + /** + * Per-source version preference: sourceId → [SourceVersionPref]. + * + * Absence of a key = follow the source's latest stable (the default for a + * brand-new, untouched source). Otherwise the stored [SourceVersionPref] + * decides whether the source rides the latest stable, the latest overall + * (dev/bleeding-edge), or stays frozen on a specific tag. See + * [ConfigRepository.getSourceVersionPrefs] / [setSourceVersionPref]. + */ + val sourceVersionPrefs: Map = emptyMap(), val preferredPatchChannel: String = PatchChannel.STABLE.name, val defaultOutputDirectory: String? = null, val autoCleanupTempFiles: Boolean = true, // Default ON diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index 7199476..b9afc64 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -8,6 +8,8 @@ package app.morphe.gui.data.repository import app.morphe.engine.util.PortablePaths import app.morphe.gui.data.model.AppConfig import app.morphe.gui.data.model.DEFAULT_PATCH_SOURCE +import app.morphe.gui.data.model.FollowMode +import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.model.PatchChannel import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.UpdateChannelPreference @@ -102,40 +104,48 @@ class ConfigRepository { } /** - * LEGACY — kept so single-source callers don't break during the multi-source - * transition. New code should use [setLastPatchesVersionForSource]. + * Record a source's version preference (called by PatchesScreen when the user + * picks a release). Per-source, so sources with overlapping tag names don't + * contaminate each other. */ - @Deprecated("Use setLastPatchesVersionForSource", ReplaceWith("setLastPatchesVersionForSource(sourceId, version)")) - suspend fun setLastPatchesVersion(version: String) { + suspend fun setSourceVersionPref(sourceId: String, pref: SourceVersionPref) { val current = loadConfig() - saveConfig(current.copy(lastPatchesVersion = version)) + saveConfig(current.copy(sourceVersionPrefs = current.sourceVersionPrefs + (sourceId to pref))) } /** - * Pin a specific release tag for [sourceId]. Used by PatchesScreen when the - * user picks a version. Per-source = no cross-contamination across sources - * with overlapping tag names. + * Returns the per-source version preferences, with a one-time migration from + * the legacy tag-only fields ([AppConfig.lastPatchesVersionBySource] and the + * even-older single [AppConfig.lastPatchesVersion]). + * + * Migration intent: those legacy tags were auto-saved on every selection, not + * deliberate freezes, so we can't tell a real pin from "just used the latest." + * We therefore convert each old tag to a *follow track* based on whether it was + * a dev tag — `-dev` ⇒ [FollowMode.FOLLOW_DEV], else [FollowMode.FOLLOW_STABLE]. + * Net effect: everyone shifts onto the latest of their channel (the fix), at the + * cost of un-freezing anyone who had deliberately pinned an old version (they can + * re-pin). A dev user parked on a stable tag at migration looks like a stable + * user and is migrated as such — an accepted, self-healing one-time loss. */ - suspend fun setLastPatchesVersionForSource(sourceId: String, version: String) { + suspend fun getSourceVersionPrefs(): Map { val current = loadConfig() - val updated = current.lastPatchesVersionBySource + (sourceId to version) - saveConfig(current.copy(lastPatchesVersionBySource = updated)) - } + if (current.sourceVersionPrefs.isNotEmpty()) return current.sourceVersionPrefs + + // Build the legacy tag map (per-source map, or the single legacy field + // mapped onto the default source). + val legacyTags: Map = when { + current.lastPatchesVersionBySource.isNotEmpty() -> current.lastPatchesVersionBySource + current.lastPatchesVersion != null -> mapOf(DEFAULT_PATCH_SOURCE.id to current.lastPatchesVersion!!) + else -> emptyMap() + } + if (legacyTags.isEmpty()) return emptyMap() - /** - * Returns the per-source version pin map, with one-time migration from the - * legacy [AppConfig.lastPatchesVersion] field: if the map is empty and the - * legacy field is set, it's mapped to the default source. - */ - suspend fun getLastPatchesVersionsBySource(): Map { - val current = loadConfig() - if (current.lastPatchesVersionBySource.isNotEmpty()) { - return current.lastPatchesVersionBySource + val migrated = legacyTags.mapValues { (_, tag) -> + val mode = if (tag.contains("-dev", ignoreCase = true)) FollowMode.FOLLOW_DEV + else FollowMode.FOLLOW_STABLE + SourceVersionPref(mode = mode) } - val legacy = current.lastPatchesVersion ?: return emptyMap() - // Migrate: write the legacy pin onto the default source, return the new map. - val migrated = mapOf(DEFAULT_PATCH_SOURCE.id to legacy) - saveConfig(current.copy(lastPatchesVersionBySource = migrated)) + saveConfig(current.copy(sourceVersionPrefs = migrated)) return migrated } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index f8e6aad..039bc2d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -13,6 +13,7 @@ import app.morphe.engine.UpdateInfo import app.morphe.engine.model.PatchedAppRecord import app.morphe.engine.util.SignatureIdentity import app.morphe.gui.data.model.Patch +import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository @@ -347,7 +348,7 @@ class HomeViewModel( private var lastLoadedVersion: String? = null // Snapshot of per-source pinned versions used in the last load — drives // refreshPatchesIfNeeded so we reload when ANY source's pin changes. - private var lastLoadedVersionsBySource: Map = emptyMap() + private var lastLoadedVersionsBySource: Map = emptyMap() /** * Load patches from all enabled sources via [EnabledSourcesLoader] and build @@ -372,9 +373,9 @@ class HomeViewModel( // Per-source pinned versions (with one-time migration from legacy // single-source field). Each source's resolver looks up its own pin; // no cross-source contamination. - val preferredVersions = configRepository.getLastPatchesVersionsBySource() - lastLoadedVersionsBySource = preferredVersions - val result = EnabledSourcesLoader.loadAll(enabled, patchService, preferredVersions) + val prefs = configRepository.getSourceVersionPrefs() + lastLoadedVersionsBySource = prefs + val result = EnabledSourcesLoader.loadAll(enabled, patchService, prefs) if (!result.anyLoaded) { val firstError = result.resolved.firstNotNullOfOrNull { it.error } @@ -780,7 +781,7 @@ class HomeViewModel( */ fun refreshPatchesIfNeeded() { screenModelScope.launch { - val saved = configRepository.getLastPatchesVersionsBySource() + val saved = configRepository.getSourceVersionPrefs() if (saved != lastLoadedVersionsBySource) { Logger.info("Patches versions changed across sources: $lastLoadedVersionsBySource -> $saved, reloading...") loadPatchesAndSupportedApps(forceRefresh = true) 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 index f03ec7e..8ca2f1f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt @@ -10,6 +10,8 @@ import app.morphe.patcher.patch.loadPatchesFromJar import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import app.morphe.engine.model.Release +import app.morphe.gui.data.model.FollowMode +import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager @@ -81,10 +83,16 @@ class PatchesViewModel( val stableReleases = releases.filter { !it.isDevRelease() } val devReleases = releases.filter { it.isDevRelease() } - // Check config for previously selected version FOR THIS SOURCE + // Resolve this source's version preference to a concrete tag + // to pre-select: a pin → its tag; follow-stable → newest stable; + // follow-dev → newest overall. val activeSourceId = patchSourceManager?.getActiveSource()?.id - val savedVersion = activeSourceId?.let { - configRepository.getLastPatchesVersionsBySource()[it] + val pref = activeSourceId?.let { configRepository.getSourceVersionPrefs()[it] } + val savedVersion = when (pref?.mode) { + FollowMode.PINNED -> pref.pinnedTag + FollowMode.FOLLOW_STABLE -> stableReleases.firstOrNull()?.tagName + FollowMode.FOLLOW_DEV -> releases.firstOrNull()?.tagName + null -> null } // Find the saved release, or fall back to latest stable @@ -137,8 +145,12 @@ class PatchesViewModel( .fold(0L) { acc, part -> acc * 10000 + part } } val activeSourceId = patchSourceManager?.getActiveSource()?.id - val savedVersion = activeSourceId?.let { - configRepository.getLastPatchesVersionsBySource()[it] + val pref = activeSourceId?.let { configRepository.getSourceVersionPrefs()[it] } + val savedVersion = when (pref?.mode) { + FollowMode.PINNED -> pref.pinnedTag + FollowMode.FOLLOW_STABLE -> offlineReleases.firstOrNull { !it.isDevRelease() }?.tagName + FollowMode.FOLLOW_DEV -> offlineReleases.firstOrNull()?.tagName + null -> null } // Pre-select the saved version, or fall back to the first (most recent) @@ -306,12 +318,13 @@ class PatchesViewModel( ) Logger.info("Patches downloaded: ${patchFile.absolutePath}") - // Save the selected version PER SOURCE so HomeScreen can pick it up - // without contaminating other enabled sources. + // Save the version preference PER SOURCE so HomeScreen can pick + // it up without contaminating other enabled sources. val activeSourceId = patchSourceManager?.getActiveSource()?.id if (activeSourceId != null) { - configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) - Logger.info("Saved selected patches version for source '$activeSourceId': ${release.tagName}") + val pref = versionPrefFor(release) + configRepository.setSourceVersionPref(activeSourceId, pref) + Logger.info("Saved version pref for source '$activeSourceId': $pref") } }, onFailure = { e -> @@ -338,12 +351,33 @@ class PatchesViewModel( screenModelScope.launch { val activeSourceId = patchSourceManager?.getActiveSource()?.id if (activeSourceId != null) { - configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) - Logger.info("Confirmed patches selection for source '$activeSourceId': ${release.tagName}") + val pref = versionPrefFor(release) + configRepository.setSourceVersionPref(activeSourceId, pref) + Logger.info("Confirmed version pref for source '$activeSourceId': $pref") } } } + /** + * Turn a user-selected release into a [SourceVersionPref]: + * - the newest stable → [FollowMode.FOLLOW_STABLE] (ride latest stable) + * - the newest dev → [FollowMode.FOLLOW_DEV] (ride newest overall) + * - anything older → [FollowMode.PINNED] to that exact tag + * + * "Picked the latest of a channel" is read as "stay on that channel's latest," + * which is what auto-updates the source going forward. + */ + private fun versionPrefFor(release: Release): SourceVersionPref { + val newestStableTag = _uiState.value.stableReleases.firstOrNull()?.tagName + val newestDevTag = _uiState.value.devReleases.firstOrNull()?.tagName + val mode = when { + release.isDevRelease() && release.tagName == newestDevTag -> FollowMode.FOLLOW_DEV + !release.isDevRelease() && release.tagName == newestStableTag -> FollowMode.FOLLOW_STABLE + else -> FollowMode.PINNED + } + return SourceVersionPref(mode = mode, pinnedTag = release.tagName.takeIf { mode == FollowMode.PINNED }) + } + /** * Export patch options from the downloaded .mpp file to a JSON file. */ diff --git a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt index ffe4e6d..653a216 100644 --- a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt +++ b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt @@ -6,8 +6,10 @@ package app.morphe.gui.util import app.morphe.engine.MultiSourceLoader +import app.morphe.gui.data.model.FollowMode import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.repository.PatchRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -79,7 +81,7 @@ object EnabledSourcesLoader { suspend fun loadAll( enabled: List>, patchService: PatchService, - preferredVersionsBySource: Map = emptyMap(), + prefsBySource: Map = emptyMap(), ): Result = supervisorScope { // supervisorScope (not coroutineScope) so a single source's failure // doesn't cancel the other in-flight resolves. Each async catches its @@ -89,7 +91,7 @@ object EnabledSourcesLoader { val resolved = enabled.map { (source, repo) -> async(Dispatchers.IO) { try { - resolve(source, repo, preferredVersionsBySource[source.id]) + resolve(source, repo, prefsBySource[source.id]) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -136,7 +138,7 @@ object EnabledSourcesLoader { private suspend fun resolve( source: PatchSource, repo: PatchRepository?, - preferredVersion: String?, + pref: SourceVersionPref?, ): ResolvedSource = withContext(Dispatchers.IO) { when (source.type) { PatchSourceType.LOCAL -> resolveLocal(source) @@ -145,7 +147,7 @@ object EnabledSourcesLoader { // which API to talk to based on the source's provider type. PatchSourceType.DEFAULT, PatchSourceType.GITHUB, - PatchSourceType.GITLAB -> resolveRemote(source, repo, preferredVersion) + PatchSourceType.GITLAB -> resolveRemote(source, repo, pref) } } @@ -169,7 +171,7 @@ object EnabledSourcesLoader { private suspend fun resolveRemote( source: PatchSource, repo: PatchRepository?, - preferredVersion: String?, + pref: SourceVersionPref?, ): ResolvedSource { if (repo == null) { return ResolvedSource(source = source, error = "No repository configured for source") @@ -193,17 +195,23 @@ object EnabledSourcesLoader { return ResolvedSource(source = source, error = errMsg) } - // Honor a user-pinned version if it exists in this source's releases. - // Otherwise pick latest stable, falling back to latest dev. - val release = preferredVersion - ?.let { pinned -> releases.find { it.tagName == pinned } } - ?: releases.firstOrNull { !it.isDevRelease() } - ?: releases.firstOrNull() - ?: return ResolvedSource(source = source, error = "No releases found") - - // Classify against this source's release list so the LED + badge can - // distinguish "latest stable" from "older stable" from "dev". - val latestStableTag = releases.firstOrNull { !it.isDevRelease() }?.tagName + // Resolve which release to load from this source's version preference: + // - PINNED → the exact tag (fall back to latest stable if it's gone) + // - FOLLOW_DEV → newest release overall, pre-releases included + // - FOLLOW_STABLE → newest stable (also the default when there's no pref) + // The release list is newest-first, so firstOrNull() is the newest overall. + val latestStable = releases.firstOrNull { !it.isDevRelease() } + val latestOverall = releases.firstOrNull() + val release = when (pref?.mode) { + FollowMode.PINNED -> + releases.find { it.tagName == pref.pinnedTag } ?: latestStable ?: latestOverall + FollowMode.FOLLOW_DEV -> latestOverall ?: latestStable + FollowMode.FOLLOW_STABLE, null -> latestStable ?: latestOverall + } ?: return ResolvedSource(source = source, error = "No releases found") + + // Classify where the resolved release actually sits (for the LED + badge), + // independent of which track it's following. + val latestStableTag = latestStable?.tagName val latestDevTag = releases.firstOrNull { it.isDevRelease() }?.tagName val channel = when { release.isDevRelease() && release.tagName == latestDevTag -> Channel.DEV_LATEST From 93cedb4c9b99ec912b93a82c4ec9bb7185bff8e5 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 28 Jun 2026 10:45:53 +0000 Subject: [PATCH 13/39] chore: Release v1.10.0-dev.5 [skip ci] # [1.10.0-dev.5](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.4...v1.10.0-dev.5) (2026-06-28) ### Features * Patch sources follows latest (stable/dev) instead of to last used version ([#185](https://github.com/MorpheApp/morphe-cli/issues/185)) ([5d34993](https://github.com/MorpheApp/morphe-cli/commit/5d349931b283f62f617e7a1ceb10009cf2639a8e)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5f32c..e505233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.5](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.4...v1.10.0-dev.5) (2026-06-28) + + +### Features + +* Patch sources follows latest (stable/dev) instead of to last used version ([#185](https://github.com/MorpheApp/morphe-cli/issues/185)) ([5d34993](https://github.com/MorpheApp/morphe-cli/commit/5d349931b283f62f617e7a1ceb10009cf2639a8e)) + # [1.10.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.3...v1.10.0-dev.4) (2026-06-27) diff --git a/gradle.properties b/gradle.properties index 3525490..815b7ef 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.4 +version = 1.10.0-dev.5 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From a994b1e9e4b2516666938c5eab0ea1250cfff284 Mon Sep 17 00:00:00 2001 From: "krvstek." <138402769+krvstek@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:35:44 +0200 Subject: [PATCH 14/39] chore: Add missing Morphe license headers (#191) --- src/main/kotlin/app/morphe/cli/command/CommandUtils.kt | 5 +++++ .../kotlin/app/morphe/cli/command/ListCompatibleVersions.kt | 5 +++++ src/main/kotlin/app/morphe/cli/command/MainCommand.kt | 5 +++++ src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt | 5 +++++ src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt | 5 +++++ src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt | 5 +++++ .../kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt | 5 +++++ .../kotlin/app/morphe/cli/command/model/PatchingResult.kt | 5 +++++ src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt | 5 +++++ .../app/morphe/cli/command/model/PatchingStepResult.kt | 5 +++++ .../kotlin/app/morphe/cli/command/model/SerializablePatch.kt | 5 +++++ .../kotlin/app/morphe/cli/command/utility/InstallCommand.kt | 5 +++++ .../app/morphe/cli/command/utility/UninstallCommand.kt | 5 +++++ .../kotlin/app/morphe/cli/command/utility/UtilityCommand.kt | 5 +++++ src/main/kotlin/app/morphe/engine/PlatformCheck.kt | 5 +++++ src/main/kotlin/app/morphe/engine/UpdateChecker.kt | 5 +++++ .../morphe/gui/ui/screens/patches/PatchSelectionScreen.kt | 5 +++++ src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt | 5 +++++ src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt | 5 +++++ src/main/kotlin/app/morphe/gui/util/VersionUtils.kt | 5 +++++ .../app/morphe/cli/command/OptionValueConverterTest.kt | 5 +++++ .../kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt | 5 +++++ 22 files changed, 110 insertions(+) diff --git a/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt b/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt index 5f240e5..76df390 100644 --- a/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt +++ b/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import picocli.CommandLine diff --git a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt index 523389c..ca27469 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import app.morphe.engine.MorpheData diff --git a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt index 979c709..b90bc36 100644 --- a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import app.morphe.cli.command.utility.UtilityCommand diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index 3229d55..9eccea8 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import app.morphe.cli.command.model.PatchBundle diff --git a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt index ece55d2..8266372 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import app.morphe.engine.patches.RemotePatchSourceFactory diff --git a/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt b/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt index 3b80ce4..a26fbbb 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import kotlinx.serialization.ExperimentalSerializationApi diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt index 2d65757..1dccb32 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import app.morphe.patcher.patch.Patch diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt index 2ffaaf2..c56862b 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import kotlinx.serialization.EncodeDefault diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt index 8f521da..ed8873c 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model enum class PatchingStep { diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt index 419ba2f..27c2ba9 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import kotlinx.serialization.ExperimentalSerializationApi diff --git a/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt b/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt index fb484cd..57d4594 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import app.morphe.patcher.patch.Patch diff --git a/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt index bfb99b3..721d59e 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.utility import app.morphe.engine.util.ApkManifestReader diff --git a/src/main/kotlin/app/morphe/cli/command/utility/UninstallCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/UninstallCommand.kt index ffd13ad..cd0c174 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/UninstallCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/UninstallCommand.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.utility import app.morphe.library.installation.installer.AdbInstaller diff --git a/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt index 18b1e9e..1ef5520 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.utility import picocli.CommandLine diff --git a/src/main/kotlin/app/morphe/engine/PlatformCheck.kt b/src/main/kotlin/app/morphe/engine/PlatformCheck.kt index cdc4088..108a56a 100644 --- a/src/main/kotlin/app/morphe/engine/PlatformCheck.kt +++ b/src/main/kotlin/app/morphe/engine/PlatformCheck.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.engine internal fun isWindows(): Boolean { diff --git a/src/main/kotlin/app/morphe/engine/UpdateChecker.kt b/src/main/kotlin/app/morphe/engine/UpdateChecker.kt index 1b70291..fd49784 100644 --- a/src/main/kotlin/app/morphe/engine/UpdateChecker.kt +++ b/src/main/kotlin/app/morphe/engine/UpdateChecker.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.engine import java.net.HttpURLConnection diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt index 1d9a3c2..5df6dc9 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.gui.ui.screens.patches import androidx.compose.animation.AnimatedVisibility diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt b/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt index 7e2eff2..c6198c6 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.gui.ui.theme import androidx.compose.material3.MaterialTheme diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt b/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt index f3c3a4e..482c86c 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.gui.ui.theme import androidx.compose.runtime.Composable diff --git a/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt b/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt index 14cfa5f..044f0ee 100644 --- a/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.gui.util import app.morphe.gui.data.model.SupportedApp diff --git a/src/test/kotlin/app/morphe/cli/command/OptionValueConverterTest.kt b/src/test/kotlin/app/morphe/cli/command/OptionValueConverterTest.kt index 89fe5b3..70d248c 100644 --- a/src/test/kotlin/app/morphe/cli/command/OptionValueConverterTest.kt +++ b/src/test/kotlin/app/morphe/cli/command/OptionValueConverterTest.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import kotlin.test.Test diff --git a/src/test/kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt b/src/test/kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt index edb34da..2a64192 100644 --- a/src/test/kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt +++ b/src/test/kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import app.morphe.cli.command.model.PatchBundle From 2c891284460762e15115f21c83036ac544549372 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:36:47 +0300 Subject: [PATCH 15/39] fix: Update to latest patcher --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91e52e2..e98be17 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlin = "2.3.21" # CLI picocli = "4.7.7" arsclib = "a28c6fb2a7" -morphe-patcher = "1.5.2" +morphe-patcher = "1.5.3-dev.3" morphe-library = "1.3.0" jadb = "1.2.1" From c47f055ae34f2baf12bcb235b203b091bdfbc70c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 29 Jun 2026 08:38:25 +0000 Subject: [PATCH 16/39] chore: Release v1.10.0-dev.6 [skip ci] # [1.10.0-dev.6](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.5...v1.10.0-dev.6) (2026-06-29) ### Bug Fixes * Update to latest patcher ([2c89128](https://github.com/MorpheApp/morphe-cli/commit/2c891284460762e15115f21c83036ac544549372)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e505233..905a315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.6](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.5...v1.10.0-dev.6) (2026-06-29) + + +### Bug Fixes + +* Update to latest patcher ([2c89128](https://github.com/MorpheApp/morphe-cli/commit/2c891284460762e15115f21c83036ac544549372)) + # [1.10.0-dev.5](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.4...v1.10.0-dev.5) (2026-06-28) diff --git a/gradle.properties b/gradle.properties index 815b7ef..f0ca920 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.5 +version = 1.10.0-dev.6 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From 8ef916cdaab28e51c244906341a32d9434126ccf Mon Sep 17 00:00:00 2001 From: "krvstek." <138402769+krvstek@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:47:18 +0200 Subject: [PATCH 17/39] feat: Add `--include-experimental` flag to `list-patches` and `list-versions` (#189) Closes #171. Adds `-x` / `--include-experimental` to both `list-patches` and `list-versions`. Without it, the output is identical to before. The old `compatiblePackages` API has no concept of experimental versions, everything gets flattened. The new `compatibility` API has an `isExperimental` flag per version target, so we now use that as the source of truth, with a fallback to the legacy field for older patch bundles. The filtering/rendering logic lives in a new `PatchExtensions.kt` in `app.morphe.engine` to keep it out of the command classes. **`list-patches -p -v -x`** now shows experimental versions under "Compatible versions" and labels universal patches as `(universal)` instead of silently dropping them. **`list-versions -x`** now counts experimental versions in the frequency table: ``` $ java -jar cli.jar list-versions --patches=patches.mpp -x Package name: com.google.android.youtube Most common compatible versions: 21.25.523 (70 patches) 21.24.360 (70 patches) ... ``` --- .../cli/command/ListCompatibleVersions.kt | 16 +- .../morphe/cli/command/ListPatchesCommand.kt | 58 ++--- .../app/morphe/cli/command/PatchCommand.kt | 69 +++--- .../kotlin/app/morphe/engine/PatchEngine.kt | 28 +-- .../app/morphe/engine/PatchExtensions.kt | 202 ++++++++++++++++++ 5 files changed, 295 insertions(+), 78 deletions(-) create mode 100644 src/main/kotlin/app/morphe/engine/PatchExtensions.kt diff --git a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt index ca27469..a1978f5 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -5,11 +5,11 @@ package app.morphe.cli.command +import app.morphe.engine.CompatibleVersionsMap import app.morphe.engine.MorpheData -import app.morphe.patcher.patch.PackageName -import app.morphe.patcher.patch.VersionMap +import app.morphe.engine.VersionMap +import app.morphe.engine.mostCommonCompatibleVersions import app.morphe.patcher.patch.loadPatchesFromJar -import app.morphe.patcher.patch.mostCommonCompatibleVersions import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Help.Visibility.ALWAYS @@ -61,6 +61,13 @@ internal class ListCompatibleVersions : Runnable { ) private var countUnusedPatches: Boolean = false + @Option( + names = ["-x", "--include-experimental"], + description = ["Include experimental app versions in the output."], + showDefaultValue = ALWAYS, + ) + private var includeExperimental: Boolean = false + @Option( names = ["-t", "--temporary-files-path"], description = ["Path to store temporary files."], @@ -81,7 +88,7 @@ internal class ListCompatibleVersions : Runnable { } } - fun buildString(entry: Map.Entry) = + fun buildString(entry: Map.Entry) = buildString { val (name, versions) = entry appendLine("Package name: $name") @@ -110,6 +117,7 @@ internal class ListCompatibleVersions : Runnable { patches.mostCommonCompatibleVersions( packageNames, countUnusedPatches, + includeExperimental, ).entries.joinToString("\n", transform = ::buildString).let(logger::info) } } diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index bf2a315..71ca16f 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -9,7 +9,8 @@ package app.morphe.cli.command import app.morphe.engine.MorpheData -import app.morphe.patcher.patch.Package +import app.morphe.engine.compatibleVersionsForDisplay +import app.morphe.engine.isCompatibleWith import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar import picocli.CommandLine @@ -109,24 +110,17 @@ internal object ListPatchesCommand : Runnable { ) private var packageName: String? = null + @Option( + names = ["-x", "--include-experimental"], + description = ["Include experimental app versions in the output."], + showDefaultValue = ALWAYS, + ) + private var includeExperimental: Boolean = false + @Spec private lateinit var spec: CommandSpec override fun run() { - fun Package.buildString(): String { - val (name, versions) = this - - return buildString { - if (withVersions && versions != null) { - appendLine("Package name: $name") - appendLine("Compatible versions:") - append(versions.joinToString("\n") { version -> version }.prependIndent("\t")) - } else { - append("Package name: $name") - } - } - } - fun PatchOption<*>.buildString() = buildString { appendLine("Title: $title") @@ -165,21 +159,35 @@ internal object ListPatchesCommand : Runnable { ) } - if (withPackages && patch.compatiblePackages != null) { - appendLine("\nCompatible packages:") - append( - patch.compatiblePackages!!.joinToString("\n") { - it.buildString() - }.prependIndent("\t"), - ) + if (withPackages) { + val packages = patch.compatibleVersionsForDisplay(includeExperimental) + if (packages.isNotEmpty()) { + appendLine("\nCompatible packages:") + append( + packages.joinToString("\n") { (name, versions) -> + buildString { + val displayName = name ?: "(universal)" + if (withVersions && versions.isNotEmpty()) { + appendLine("Package name: $displayName") + appendLine("Compatible versions:") + append(versions.joinToString("\n").prependIndent("\t")) + } else { + append("Package name: $displayName") + } + } + }.prependIndent("\t"), + ) + } } } } fun Patch<*>.filterCompatiblePackages(name: String) = - compatiblePackages?.any { (compatiblePackageName, _) -> - compatiblePackageName == name - } ?: withUniversalPatches + isCompatibleWith( + packageName = name, + includeExperimental = includeExperimental, + includeUniversalPatches = withUniversalPatches, + ) val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index ebefc2f..7ffbb8a 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -12,6 +12,7 @@ import app.morphe.cli.command.model.* import app.morphe.engine.MorpheData import app.morphe.engine.PatchEngine import app.morphe.engine.isWindows +import app.morphe.engine.supportedVersionsFor import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME @@ -673,8 +674,13 @@ internal object PatchCommand : Callable { val compatiblePatchNames = bundlePatches .filter { patch -> - patch.compatiblePackages == null || - patch.compatiblePackages!!.any { (name, _) -> name == packageName } + val compat = patch.compatibility + if (!compat.isNullOrEmpty()) { + compat.any { it.packageName == packageName || it.packageName == null } + } else { + @Suppress("DEPRECATION") + patch.compatiblePackages == null || patch.compatiblePackages!!.any { (name, _) -> name == packageName } + } } .mapNotNull { it.name?.lowercase() } .toSet() @@ -701,11 +707,15 @@ internal object PatchCommand : Callable { // Compare against the live patch in this bundle (not the snapshot) // so multi-app patches with the same name aren't merged together. - val actualPatch = bundlePatches.find { - it.name.equals(patchName, ignoreCase = true) && - (it.compatiblePackages == null || it.compatiblePackages!!.any { - (name, _) -> name == packageName - }) + val actualPatch = bundlePatches.find { patch -> + if (!patch.name.equals(patchName, ignoreCase = true)) return@find false + val compat = patch.compatibility + if (!compat.isNullOrEmpty()) { + compat.any { it.packageName == packageName || it.packageName == null } + } else { + @Suppress("DEPRECATION") + patch.compatiblePackages == null || patch.compatiblePackages!!.any { (name, _) -> name == packageName } + } } val actualOptionKeys = actualPatch?.options?.keys ?: emptySet() @@ -1019,34 +1029,31 @@ internal object PatchCommand : Callable { val patchNameLower = patchName.lowercase() // Check package compatibility first to avoid duplicate logs for multi-app patches. - patch.compatiblePackages?.let { packages -> - packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) -> - if (versions?.isEmpty() == true) { - return@patchLoop logger.warning( - "Skipping \"$patchName\": incompatible with $packageName" - ) - } - - val matchesVersion = - force || versions?.let { it.any { version -> version == packageVersion } } ?: true - + val supportedVersions = patch.supportedVersionsFor(packageName) + when { + supportedVersions != null && supportedVersions.isEmpty() -> { + return@patchLoop logger.fine( + "Skipping \"$patchName\": incompatible with $packageName " + + "(only compatible with " + + (patch.compatibility + ?.mapNotNull { it.packageName } + ?.joinToString(", ") + ?: @Suppress("DEPRECATION") patch.compatiblePackages + ?.joinToString(", ") { (name, _) -> name } + ?: "") + ")" + ) + } + supportedVersions != null -> { + val matchesVersion = force || packageVersion in supportedVersions if (!matchesVersion) { - val compatibilityHint = packages.joinToString("; ") { (pkg, vers) -> - pkg + " " + (vers ?: emptySet()).joinToString(", ") - } + val hint = supportedVersions.joinToString(", ") return@patchLoop logger.warning( "Skipping \"$patchName\": incompatible with $packageName $packageVersion " + - "(supported: $compatibilityHint)" + "(supported: $packageName $hint)" ) } - } ?: return@patchLoop logger.fine( - "Skipping \"$patchName\": incompatible with $packageName " + - "(only compatible with " + - packages.joinToString(", ") { (name, _) -> name } + ")" - ) - - return@let - } ?: logger.fine("\"$patchName\" has no package constraints") + } + } // CLI flags take precedence over JSON, JSON takes precedence over defaults. // Log strings match the GUI engine's "Skipping disabled: …" format so @@ -1075,7 +1082,7 @@ internal object PatchCommand : Callable { val isJsonEnabled = patchNameLower in jsonEnabledPatches - val isEnabled = !exclusive && patch.use + val isEnabled = !exclusive && patch.default if (!(isEnabled || isCliEnabled || isJsonEnabled)) { // Default-disabled patches (the patch ships with use=false and diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index 49b4040..a40d064 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -320,23 +320,15 @@ object PatchEngine { 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 + val supportedVersions = patch.supportedVersionsFor(packageName) + when { + supportedVersions != null && supportedVersions.isEmpty() -> return@patchLoop + supportedVersions != null -> { + val matchesVersion = forceCompatibility || packageVersion in supportedVersions + if (!matchesVersion) { + onProgress("Skipping \"$patchName\": incompatible with $packageName $packageVersion") + return@patchLoop + } } } @@ -347,7 +339,7 @@ object PatchEngine { } val isManuallyEnabled = patchName in enabledPatches - val isEnabledByDefault = !exclusiveMode && patch.use + val isEnabledByDefault = !exclusiveMode && patch.default if (!(isEnabledByDefault || isManuallyEnabled)) { return@patchLoop diff --git a/src/main/kotlin/app/morphe/engine/PatchExtensions.kt b/src/main/kotlin/app/morphe/engine/PatchExtensions.kt new file mode 100644 index 0000000..09bb206 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/PatchExtensions.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.patcher.patch.Patch + +typealias VersionMap = LinkedHashMap +typealias CompatibleVersionsMap = Map + + +@Suppress("DEPRECATION") +fun Patch<*>.versionsFor( + packageName: String?, + includeExperimental: Boolean, +): List { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + val matchingEntries = compat.filter { entry -> + packageName == null || entry.packageName == null || entry.packageName == packageName + } + return matchingEntries.flatMap { entry -> + entry.targets + .filter { target -> includeExperimental || !target.isExperimental } + .mapNotNull { it.version } + } + } + + val legacyPackages = compatiblePackages ?: return emptyList() + val matchingPkgs = if (packageName == null) { + legacyPackages + } else { + legacyPackages.filter { (name, _) -> name == packageName } + } + return matchingPkgs.flatMap { (_, versions) -> versions?.toList() ?: emptyList() } +} + +@Suppress("DEPRECATION") +fun Patch<*>.isCompatibleWith( + packageName: String, + includeExperimental: Boolean, + includeUniversalPatches: Boolean, +): Boolean { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + return compat.any { entry -> + when { + entry.packageName == null -> includeUniversalPatches + entry.packageName != packageName -> false + else -> { + entry.targets.any { target -> + includeExperimental || !target.isExperimental + } + } + } + } + } + + val legacyPackages = compatiblePackages + ?: return includeUniversalPatches + + return legacyPackages.any { (name, _) -> name == packageName } +} + +@Suppress("DEPRECATION") +fun Patch<*>.supportedVersionsFor(packageName: String): List? { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + val matching = compat.filter { it.packageName == packageName } + if (matching.isEmpty()) { + val hasUniversalEntry = compat.any { it.packageName == null } + return if (hasUniversalEntry) null else emptyList() + } + return matching.flatMap { entry -> entry.targets.mapNotNull { it.version } } + } + + val legacyPackages = compatiblePackages ?: return null + val match = legacyPackages.singleOrNull { (name, _) -> name == packageName } + ?: return emptyList() + val legacyVersions = match.second + return if (legacyVersions.isNullOrEmpty()) null else legacyVersions.toList() +} + +@Suppress("DEPRECATION") +fun Patch<*>.compatibleVersionsForDisplay( + includeExperimental: Boolean, +): List>> { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + return compat.map { entry -> + val versions = entry.targets + .filter { includeExperimental || !it.isExperimental } + .mapNotNull { it.version } + entry.packageName to versions + } + } + + val legacyPackages = compatiblePackages ?: return emptyList() + return legacyPackages.map { (name, versions) -> + name to (versions?.toList() ?: emptyList()) + } +} + +@Suppress("DEPRECATION") +fun Iterable>.mostCommonCompatibleVersions( + packageNames: Set?, + countUnusedPatches: Boolean, + includeExperimental: Boolean = false, +): CompatibleVersionsMap { + val allPackageNames: Set = buildSet { + for (patch in this@mostCommonCompatibleVersions) { + val compat = patch.compatibility + if (!compat.isNullOrEmpty()) { + for (entry in compat) { + entry.packageName?.let { add(it) } + } + } else { + @Suppress("DEPRECATION") + patch.compatiblePackages?.forEach { (name, _) -> add(name) } + } + } + } + + val targetPackages = if (packageNames != null) { + allPackageNames.intersect(packageNames) + } else { + allPackageNames + } + + val result: MutableMap = LinkedHashMap() + + for (pkgName in targetPackages) { + val versionCount = VersionMap() + + for (patch in this) { + if (!countUnusedPatches && !patch.use) continue + + val compat = patch.compatibility + + if (!compat.isNullOrEmpty()) { + val isUniversal = compat.all { it.packageName == null } + val matchingEntries = compat.filter { entry -> + entry.packageName == null || entry.packageName == pkgName + } + + if (matchingEntries.isEmpty()) continue + + val versions = matchingEntries.flatMap { entry -> + entry.targets + .filter { includeExperimental || !it.isExperimental } + .mapNotNull { it.version } + } + + if (versions.isEmpty() && !isUniversal) continue + + if (versions.isEmpty()) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + } else { + for (version in versions) { + versionCount[version] = (versionCount[version] ?: 0) + 1 + } + } + } else { + @Suppress("DEPRECATION") + val legacyPackages = patch.compatiblePackages + if (legacyPackages == null) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + continue + } + val matching = legacyPackages.filter { (name, _) -> name == pkgName } + if (matching.isEmpty()) continue + for ((_, versions) in matching) { + if (versions.isNullOrEmpty()) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + } else { + for (version in versions) { + versionCount[version] = (versionCount[version] ?: 0) + 1 + } + } + } + } + } + + if (versionCount.isNotEmpty()) { + val sorted = versionCount.entries + .sortedByDescending { it.value } + .associateTo(LinkedHashMap()) { it.key to it.value } + sorted.remove("") + result[pkgName] = sorted + } + } + + return result.entries + .sortedBy { it.key } + .associateTo(LinkedHashMap()) { it.key to it.value } +} From ee1008218000a1ffbe21c95f2d6d5149596573e7 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 29 Jun 2026 11:48:48 +0000 Subject: [PATCH 18/39] chore: Release v1.10.0-dev.7 [skip ci] # [1.10.0-dev.7](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.6...v1.10.0-dev.7) (2026-06-29) ### Features * Add `--include-experimental` flag to `list-patches` and `list-versions` ([#189](https://github.com/MorpheApp/morphe-cli/issues/189)) ([8ef916c](https://github.com/MorpheApp/morphe-cli/commit/8ef916cdaab28e51c244906341a32d9434126ccf)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 905a315..e3cd2d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.7](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.6...v1.10.0-dev.7) (2026-06-29) + + +### Features + +* Add `--include-experimental` flag to `list-patches` and `list-versions` ([#189](https://github.com/MorpheApp/morphe-cli/issues/189)) ([8ef916c](https://github.com/MorpheApp/morphe-cli/commit/8ef916cdaab28e51c244906341a32d9434126ccf)) + # [1.10.0-dev.6](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.5...v1.10.0-dev.6) (2026-06-29) diff --git a/gradle.properties b/gradle.properties index f0ca920..5e576cb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.6 +version = 1.10.0-dev.7 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From e4ec0be248472a2022b046350fdef4045278e514 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:35:06 +0300 Subject: [PATCH 19/39] chore: Use latest patcher --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e98be17..95b1109 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlin = "2.3.21" # CLI picocli = "4.7.7" arsclib = "a28c6fb2a7" -morphe-patcher = "1.5.3-dev.3" +morphe-patcher = "1.6.0-dev.1" morphe-library = "1.3.0" jadb = "1.2.1" From 32e413325bef5f290f7784a29ebb4437416ed555 Mon Sep 17 00:00:00 2001 From: Eric Ahn Date: Tue, 30 Jun 2026 01:37:41 -0700 Subject: [PATCH 20/39] chore: Cleanup old code --- .../app/morphe/cli/command/PatchCommand.kt | 36 ++----------------- .../kotlin/app/morphe/engine/PatchEngine.kt | 10 ++---- 2 files changed, 5 insertions(+), 41 deletions(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 7ffbb8a..06489ca 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -234,8 +234,6 @@ internal object PatchCommand : Callable { ) private var temporaryFilesPath: File? = null - private var aaptBinaryPath: File? = null - @CommandLine.Option( names = ["--purge"], description = ["Delete THIS run's scratch files after patching. " + @@ -268,28 +266,6 @@ internal object PatchCommand : Callable { ) private var prerelease: Boolean = false - @CommandLine.Option( - names = ["--custom-aapt2-binary"], - description = ["apktool is deprecated. This parameter has no effect and will be removed in a future release."], - ) - @Suppress("unused") - private fun setAaptBinaryPath(aaptBinaryPath: File) { - if (!aaptBinaryPath.exists()) { - throw CommandLine.ParameterException( - spec.commandLine(), - "AAPT binary ${aaptBinaryPath.name} does not exist", - ) - } - this.aaptBinaryPath = aaptBinaryPath - } - - @CommandLine.Option( - names = ["--force-apktool"], - description = ["apktool is deprecated. This parameter has no effect and will be removed in a future release."], - showDefaultValue = ALWAYS, - ) - private var forceApktool: Boolean = false - @CommandLine.Option( names = ["--unsigned"], description = ["Disable signing of the final apk."], @@ -632,7 +608,7 @@ internal object PatchCommand : Callable { ApkMerger(logger.toMorpheLogger()).merge( inputFile = apk, outputFile = outputApk, - cleanMetaInf = true + cleanMetaInf = false ) mergedApkToCleanup = outputApk @@ -646,15 +622,9 @@ internal object PatchCommand : Callable { PatcherConfig( inputApk, patcherTemporaryFilesPath, - aaptBinaryPath?.path, - patcherTemporaryFilesPath.absolutePath, - useArsclib = if (aaptBinaryPath != null) { false } else { !forceApktool }, + useArsclib = true, keepArchitectures = keepArchitectures, - /* - TODO: Remove Windows override once the patcher ships its proper fix - (reflection-based MappedByteBuffer release + copy-instead-of-rename for output DEX files). - */ - useBytecodeMode = if (isWindows()) { BytecodeMode.FULL } else { bytecodeMode }, + useBytecodeMode = bytecodeMode, verifier = verifier ), ).use { patcher -> diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index a40d064..5eeb72a 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -56,7 +56,6 @@ object PatchEngine { val signerName: String = DEFAULT_SIGNER_NAME, val keystoreDetails: ApkUtils.KeyStoreDetails? = null, val architecturesToKeep: Set = emptySet(), - val aaptBinaryPath: File? = null, val tempDir: File? = null, val failOnError: Boolean = true, val bytecodeMode: BytecodeMode = BytecodeMode.STRIP_FAST @@ -110,7 +109,7 @@ object PatchEngine { ApkMerger(logger.toMorpheLogger()).merge( inputFile = config.inputApk, outputFile = mergedApk, - cleanMetaInf = true + cleanMetaInf = false ) mergedApkToCleanup = mergedApk mergedApk @@ -128,15 +127,10 @@ object PatchEngine { val patcherConfig = PatcherConfig( actualInputApk, patcherTempDir, - config.aaptBinaryPath?.path, patcherTempDir.absolutePath, useArsclib = true, keepArchitectures = config.architecturesToKeep, - /* - TODO: Remove Windows override once the patcher ships its proper fix - (reflection-based MappedByteBuffer release + copy-instead-of-rename for output DEX files). - */ - useBytecodeMode = if (isWindows()) { BytecodeMode.FULL } else { config.bytecodeMode } + useBytecodeMode = config.bytecodeMode ) Patcher(patcherConfig).use { patcher -> From fa596af8c80998ba11f188c4587b795983a1f6f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:39:27 +0300 Subject: [PATCH 21/39] chore(deps): bump actions/checkout from 4 to 7 (#193) --- .github/workflows/build_pull_request.yml | 2 +- .github/workflows/open_pull_request.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 9d01efa..0f4ac83 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/open_pull_request.yml b/.github/workflows/open_pull_request.yml index 75b8e67..113a91c 100644 --- a/.github/workflows/open_pull_request.yml +++ b/.github/workflows/open_pull_request.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Open pull request uses: repo-sync/pull-request@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b13e7d0..3ffa72f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: # Make sure the release step uses its own credentials: # https://github.com/cycjimmy/semantic-release-action#private-packages From f579828ab12ab96f2592f3f19622a1e057d10583 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:39:43 +0300 Subject: [PATCH 22/39] chore(deps-dev): bump @cleyrop-org/semantic-release-backmerge from 5.2.4 to 6.1.0 (#194) --- package-lock.json | 215 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 2 +- 2 files changed, 207 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 690d927..41277c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "devDependencies": { - "@cleyrop-org/semantic-release-backmerge": "^5.2.4", + "@cleyrop-org/semantic-release-backmerge": "^6.1.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "gradle-semantic-release-plugin": "^1.10.3", @@ -87,18 +87,215 @@ } }, "node_modules/@cleyrop-org/semantic-release-backmerge": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@cleyrop-org/semantic-release-backmerge/-/semantic-release-backmerge-5.2.4.tgz", - "integrity": "sha512-Bp+jjc2WSfvucaUNFNx+dj97bYKpUEv26VWmr0azNqp1SkLiT5cYAnwCNEw7BRO2aYIPXZa58lfiHdKKwtfk1Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@cleyrop-org/semantic-release-backmerge/-/semantic-release-backmerge-6.1.0.tgz", + "integrity": "sha512-fbUFDC/cMZUYZ/ywOZXLpI3s9kcRy+eq8z3AUNvoNXmwg6vlzFFjeIr7ZT+7ZXQs1VXDVd8Qk8frTj6RxCu1eA==", "dev": true, "license": "MIT", "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.1.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", "debug": "^4.4.3", - "execa": "^5.1.1", - "lodash": "^4.17.21", - "semantic-release": "^25.0.0" + "execa": "^9.6.1", + "lodash": "^4.18.1" + }, + "engines": { + "node": "^22.14.0 || >=24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=24" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@colors/colors": { diff --git a/package.json b/package.json index e722f5f..0309e7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "@cleyrop-org/semantic-release-backmerge": "^5.2.4", + "@cleyrop-org/semantic-release-backmerge": "^6.1.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "gradle-semantic-release-plugin": "^1.10.3", From 6530bde592c72078a0081733dbb706779a2d3bf6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:40:21 +0300 Subject: [PATCH 23/39] chore(deps): bump voyager from 1.1.0-beta03 to 2.2.21-1.10.3 (#195) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95b1109..c0416da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ ktor = "3.5.0" koin-bom = "4.2.1" # Navigation -voyager = "1.1.0-beta03" +voyager = "2.2.21-1.10.3" # Async / Serialization coroutines = "1.10.2" From da626e0cb8234d65c3d2e1eff34774a982a511eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:40:37 +0300 Subject: [PATCH 24/39] chore(deps): bump com.gradleup.shadow:shadow-gradle-plugin from 9.4.1 to 9.4.3 (#196) --- buildSrc/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 74ef0d4..fa3188b 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -12,6 +12,6 @@ dependencies { // gets these classes on its buildscript classpath at runtime. The root // project must then apply the shadow plugin without a version to avoid // the "plugin is already on the classpath" conflict. - implementation("com.gradleup.shadow:shadow-gradle-plugin:9.4.1") + implementation("com.gradleup.shadow:shadow-gradle-plugin:9.4.3") implementation("org.jetbrains:annotations:26.1.0") } From d8289edc7a09a9204b9c4d9be5ed66046b655ea2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:40:56 +0300 Subject: [PATCH 25/39] chore(deps): bump jna from 5.18.1 to 5.19.1 (#197) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0416da..981944c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ coroutines = "1.10.2" kotlinx-serialization = "1.11.0" # JNA (Windows DWM title bar tinting) -jna = "5.18.1" +jna = "5.19.1" # Testing mockk = "1.14.11" From 04a3b3712ee0c74ae6e20e373a7c2c6ca489ac40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:41:13 +0300 Subject: [PATCH 26/39] chore(deps): bump org.jetbrains.compose from 1.10.3 to 1.11.1 (#198) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 981944c..7aedb41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ morphe-library = "1.3.0" jadb = "1.2.1" # Compose Desktop -compose = "1.10.3" +compose = "1.11.1" # Networking ktor = "3.5.0" From 331d85cbb65fd56f88e6c6197bf7f24622492eef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:49:28 +0300 Subject: [PATCH 27/39] chore(deps): bump about-libraries from 14.1.0 to 15.0.3 (#200) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7aedb41..a06b0d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ mockk = "1.14.11" slf4j = "2.0.18" # Libraries -about-libraries = "14.1.0" +about-libraries = "15.0.3" [libraries] # Morphe Core From 438d605571ea33976c3ca0a70881ddce383f896f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:49:48 +0300 Subject: [PATCH 28/39] chore(deps): bump ktor from 3.5.0 to 3.5.1 (#201) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a06b0d8..c292f98 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ jadb = "1.2.1" compose = "1.11.1" # Networking -ktor = "3.5.0" +ktor = "3.5.1" # DI koin-bom = "4.2.1" From d491a18864bb7f52891a3c72d5d4b3eee5537a49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:50:49 +0300 Subject: [PATCH 29/39] chore(deps): bump gradle-wrapper from 9.5.0 to 9.6.1 (#202) --- gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 4 +- gradlew.bat | 164 +++++++++++------------ 3 files changed, 86 insertions(+), 86 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4b271a6..dbe66e1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 24c62d5..8508ef6 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,82 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From 4035e0d677ffb40308700da75c4f3866fe43ec6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:51:05 +0300 Subject: [PATCH 30/39] chore(deps): bump org.junit.jupiter:junit-jupiter-params from 6.0.3 to 6.1.1 (#203) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c292f98..842e9a4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] # Core -junit = "6.0.3" +junit = "6.1.1" kotlin = "2.3.21" # CLI From 25b38dc45454e1d844f72d4c342004beebbee205 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:54:17 +0300 Subject: [PATCH 31/39] chore(deps): bump jadb, koin-bom, coroutines --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 842e9a4..860e811 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ picocli = "4.7.7" arsclib = "a28c6fb2a7" morphe-patcher = "1.6.0-dev.1" morphe-library = "1.3.0" -jadb = "1.2.1" +jadb = "1.2.3" # Compose Desktop compose = "1.11.1" @@ -17,13 +17,13 @@ compose = "1.11.1" ktor = "3.5.1" # DI -koin-bom = "4.2.1" +koin-bom = "4.2.2" # Navigation voyager = "2.2.21-1.10.3" # Async / Serialization -coroutines = "1.10.2" +coroutines = "1.11.0" kotlinx-serialization = "1.11.0" # JNA (Windows DWM title bar tinting) From 5a07bf7d6fe34d42e2dd1b95e601386e6e214f83 Mon Sep 17 00:00:00 2001 From: "krvstek." <138402769+krvstek@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:39:13 +0200 Subject: [PATCH 32/39] fix: Allow patches with empty targets to apply automatically (#207) Closes #204. Fixes a regression introduced during the migration to the new `patch.compatibility` API. --- src/main/kotlin/app/morphe/engine/PatchExtensions.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/app/morphe/engine/PatchExtensions.kt b/src/main/kotlin/app/morphe/engine/PatchExtensions.kt index 09bb206..86f6e0f 100644 --- a/src/main/kotlin/app/morphe/engine/PatchExtensions.kt +++ b/src/main/kotlin/app/morphe/engine/PatchExtensions.kt @@ -76,7 +76,8 @@ fun Patch<*>.supportedVersionsFor(packageName: String): List? { val hasUniversalEntry = compat.any { it.packageName == null } return if (hasUniversalEntry) null else emptyList() } - return matching.flatMap { entry -> entry.targets.mapNotNull { it.version } } + val versions = matching.flatMap { entry -> entry.targets.mapNotNull { it.version } } + return versions.ifEmpty { null } } val legacyPackages = compatiblePackages ?: return null From afbf69d3f85dfecc0c3a32756dd5de162add4f94 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Jul 2026 09:41:00 +0000 Subject: [PATCH 33/39] chore: Release v1.10.0-dev.8 [skip ci] # [1.10.0-dev.8](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.7...v1.10.0-dev.8) (2026-07-02) ### Bug Fixes * Allow patches with empty targets to apply automatically ([#207](https://github.com/MorpheApp/morphe-cli/issues/207)) ([5a07bf7](https://github.com/MorpheApp/morphe-cli/commit/5a07bf7d6fe34d42e2dd1b95e601386e6e214f83)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3cd2d0..b999248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.8](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.7...v1.10.0-dev.8) (2026-07-02) + + +### Bug Fixes + +* Allow patches with empty targets to apply automatically ([#207](https://github.com/MorpheApp/morphe-cli/issues/207)) ([5a07bf7](https://github.com/MorpheApp/morphe-cli/commit/5a07bf7d6fe34d42e2dd1b95e601386e6e214f83)) + # [1.10.0-dev.7](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.6...v1.10.0-dev.7) (2026-06-29) diff --git a/gradle.properties b/gradle.properties index 5e576cb..b9b2bf0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.7 +version = 1.10.0-dev.8 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From 70d16f0e105f6362ee45e5e600c9ded01ab6c09e Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:21:27 +0530 Subject: [PATCH 34/39] feat: cli ignores unknown keys (fix) + unify CLI/GUI patch cache + add clear-cache + make purge opt-out (#192) - cli now ignores unknown keys when parsing github manifest instead of trying to parse and breaking. - purge is now opt-out instead of opt-in. Flag renamed to `disable-purge`. - cli and gui now share a shared folder for patch files. - new command under utility: `clear-cache`. Used in cli to clear cache. `--info` runs this in verbose mode. Cache includes patch files and logs for now. - Removed `-t`/`--temporary-files-path` from list-patches, list-versions, and options-create. It no longer affects anything there now since downloads go to the shared cache folder. It only stays on `patch`. --- .../app/morphe/cli/command/CliHttpClient.kt | 5 +- .../cli/command/ListCompatibleVersions.kt | 10 --- .../morphe/cli/command/ListPatchesCommand.kt | 10 --- .../app/morphe/cli/command/OptionsCommand.kt | 10 --- .../app/morphe/cli/command/PatchCommand.kt | 16 ++--- .../morphe/cli/command/PatchFileResolver.kt | 43 ++++-------- .../cli/command/utility/ClearCacheCommand.kt | 64 +++++++++++++++++ .../cli/command/utility/UtilityCommand.kt | 2 +- .../kotlin/app/morphe/engine/CacheManager.kt | 68 +++++++++++++++++++ .../app/morphe/engine/patches/PatchCache.kt | 37 ++++++++++ .../gui/data/repository/PatchRepository.kt | 19 ++---- .../morphe/gui/ui/components/ToolsDialog.kt | 29 ++------ 12 files changed, 210 insertions(+), 103 deletions(-) create mode 100644 src/main/kotlin/app/morphe/cli/command/utility/ClearCacheCommand.kt create mode 100644 src/main/kotlin/app/morphe/engine/CacheManager.kt create mode 100644 src/main/kotlin/app/morphe/engine/patches/PatchCache.kt diff --git a/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt b/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt index 80cf781..65a8ddc 100644 --- a/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt +++ b/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt @@ -9,6 +9,7 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json /** * Lazy initialized HttpClient for CLI commands. One client per process is fine for short-lived @@ -19,7 +20,9 @@ import io.ktor.serialization.kotlinx.json.json object CliHttpClient { val instance: HttpClient by lazy { HttpClient(CIO) { - install(ContentNegotiation) { json() } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } } } } diff --git a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt index a1978f5..7fa88ce 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -6,7 +6,6 @@ package app.morphe.cli.command import app.morphe.engine.CompatibleVersionsMap -import app.morphe.engine.MorpheData import app.morphe.engine.VersionMap import app.morphe.engine.mostCommonCompatibleVersions import app.morphe.patcher.patch.loadPatchesFromJar @@ -68,12 +67,6 @@ internal class ListCompatibleVersions : Runnable { ) private var includeExperimental: Boolean = false - @Option( - names = ["-t", "--temporary-files-path"], - description = ["Path to store temporary files."], - ) - private var temporaryFilesPath: File? = null - @Spec private lateinit var spec: CommandSpec @@ -96,13 +89,10 @@ internal class ListCompatibleVersions : Runnable { appendLine(versions.buildVersionsString().prependIndent("\t")) } - val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - try { patchesFiles = PatchFileResolver.resolve( patchesFiles, prerelease, - temporaryFilesPath, CliHttpClient.instance ) } catch (e: IllegalArgumentException) { diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index 71ca16f..7eeca9a 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -8,7 +8,6 @@ package app.morphe.cli.command -import app.morphe.engine.MorpheData import app.morphe.engine.compatibleVersionsForDisplay import app.morphe.engine.isCompatibleWith import app.morphe.patcher.patch.Patch @@ -63,12 +62,6 @@ internal object ListPatchesCommand : Runnable { ) private var withDescriptions: Boolean = true - @Option( - names = ["-t", "--temporary-files-path"], - description = ["Path to store temporary files."], - ) - private var temporaryFilesPath: File? = null - @Option( names = ["-p", "--with-packages"], description = ["List the packages the patches are compatible with."], @@ -190,13 +183,10 @@ internal object ListPatchesCommand : Runnable { ) - val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - try { patchesFiles = PatchFileResolver.resolve( patchesFiles, prerelease, - temporaryFilesPath, CliHttpClient.instance ) } catch (e: IllegalArgumentException) { diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index 9eccea8..1e02773 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -6,7 +6,6 @@ package app.morphe.cli.command import app.morphe.cli.command.model.PatchBundle -import app.morphe.engine.MorpheData import app.morphe.engine.patches.LoadedBundle import app.morphe.engine.patches.PatchBundleLoader import app.morphe.cli.command.model.findMatchingBundle @@ -63,12 +62,6 @@ internal object OptionsCommand : Callable { ) private var prerelease: Boolean = false - @Option( - names = ["-t", "--temporary-files-path"], - description = ["Path to store temporary files."], - ) - private var temporaryFilesPath: File? = null - @Option( names = ["-f", "--filter-package-name"], description = ["Filter patches by compatible package name."], @@ -78,15 +71,12 @@ internal object OptionsCommand : Callable { private val json = Json { prettyPrint = true } override fun call(): Int { - val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - try { // Since we could have many URLs, we resolve each of them separately patchesFiles = patchesFiles.map { file -> val resolved = PatchFileResolver.resolve( setOf(file), prerelease, - temporaryFilesPath, CliHttpClient.instance ) resolved.single() diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 06489ca..c149571 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -235,12 +235,13 @@ internal object PatchCommand : Callable { private var temporaryFilesPath: File? = null @CommandLine.Option( - names = ["--purge"], - description = ["Delete THIS run's scratch files after patching. " + - "Does not affect cached patches, other sessions, or config."], + names = ["--disable-purge"], + description = ["Keep THIS run's scratch files instead of deleting them after patching. " + + "By default the scratch files are purged once patching finishes; this keeps them " + + "(e.g. for debugging a failed patch). Does not affect cached patches, other sessions, or config."], showDefaultValue = ALWAYS, ) - private var purge: Boolean = false + private var disablePurge: Boolean = false @CommandLine.Parameters( description = ["APK file to patch."], @@ -487,7 +488,6 @@ internal object PatchCommand : Callable { val resolved = PatchFileResolver.resolve( setOf(bundle.patchesFile), prerelease, - temporaryFilesPath, CliHttpClient.instance ) bundle.patchesFile = resolved.single() @@ -500,7 +500,7 @@ internal object PatchCommand : Callable { } // Per-session scratch dir. Hoisted out of the patching `try` block so - // the `finally` block can reference it for --purge scope (Phase 6). + // the `finally` block can reference it for the auto-purge (unless --disable-purge). // Naming matches the GUI's FileUtils.createPatchingTempDir() so the // tmp/ folder shows consistent siblings across CLI + GUI sessions. val patcherTemporaryFilesPath = @@ -593,7 +593,7 @@ internal object PatchCommand : Callable { // endregion // (patcherTemporaryFilesPath is declared above the outer try - // block so it's visible to --purge in the finally clause.) + // block so it's visible to the auto-purge in the finally clause.) // We need to check for apkm (like reddit), xapk and apks formats here @@ -946,7 +946,7 @@ internal object PatchCommand : Callable { } } - if (purge) { + if (!disablePurge) { // Scope: only THIS session's tmp subfolder. Cached patches, // logs, config, and other in-flight sessions (CLI or GUI) are // never touched. diff --git a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt index 8266372..5af03a6 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt @@ -5,6 +5,7 @@ package app.morphe.cli.command +import app.morphe.engine.patches.PatchCache import app.morphe.engine.patches.RemotePatchSourceFactory import app.morphe.engine.patches.findPatchAsset import io.ktor.client.HttpClient @@ -21,12 +22,12 @@ object PatchFileResolver { * Returns a new Set with URLs replaced by downloaded/cached .mpp files. * * Provider detection (GitHub vs GitLab) + URL parsing + API talk lives in the engine. - * This function only owns the CLI's disk cache layout and the "which release do we pick" decision. + * This function owns the "which release do we pick" decision. The on-disk cache + * layout is shared with the GUI via [PatchCache] (one cache for both surfaces). */ fun resolve( patchFiles: Set, prerelease: Boolean, - cacheDir: File, httpClient: HttpClient ): Set { val urlEntry = patchFiles.firstOrNull { @@ -72,37 +73,21 @@ object PatchFileResolver { val asset = targetRelease.findPatchAsset() ?: throw IllegalArgumentException("No .mpp file found in release ${targetRelease.tagName}") - // Disk-cache check (same layout as before: {cacheDir}/download/{owner}-{repo}/). - val versionNumber = targetRelease.tagName.removePrefix("v") - val repoCacheDir = - cacheDir.resolve("download").resolve(parsed.repoPath.replace("/", "-")) + // Shared on-disk cache (via PatchCache): the same directory and tag-prefixed filename + // the GUI uses, so a .mpp downloaded by either side is reused by the other. + // Layout: morphe-data/patches/-/__.mpp + val repoCacheDir = PatchCache.sourceDir(parsed.repoPath) + val targetFile = File(repoCacheDir, PatchCache.cachedFileName(targetRelease, asset)) - val cachedFile = repoCacheDir.listFiles()?.find { - it.name.endsWith(".mpp") && it.name.contains(versionNumber) - } - - val resolvedFile = if (cachedFile != null) { - val rel = cachedFile.relativeTo(cacheDir.parentFile).path - logger.info("Using cached patch file at $rel") - - cachedFile + val resolvedFile = if (targetFile.exists() && targetFile.length() > 0L) { + logger.info("Using cached patch file at ${targetFile.absolutePath}") + targetFile } else { - // Different version cached -> wipe it before downloading (matches the existing behavior) - repoCacheDir.listFiles() - ?.filter { it.name.endsWith(".mpp") } - ?.forEach{ it.delete() } - repoCacheDir.mkdirs() - - val targetFile = File(repoCacheDir, asset.name) - logger.info("Downloading patches from ${parsed.repoPath} $versionNumber...") - + logger.info("Downloading patches from ${parsed.repoPath} ${targetRelease.tagName}...") source.downloadAsset(asset, targetFile).getOrThrow() - - val rel = targetFile.relativeTo(cacheDir.parentFile).path - logger.info("Patches mpp saved to $rel. This file will be used on your next run as long as it is not deleted!") - + logger.info("Patches mpp saved to ${targetFile.absolutePath}. This file will be used on your next run as long as it is not deleted!") targetFile - } + } patchFiles - urlEntry + resolvedFile } catch (e: Exception) { throw IllegalArgumentException("Failed to download patches from URL: ${e.message}") diff --git a/src/main/kotlin/app/morphe/cli/command/utility/ClearCacheCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/ClearCacheCommand.kt new file mode 100644 index 0000000..67c0fdf --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/utility/ClearCacheCommand.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.cli.command.utility + +import app.morphe.engine.CacheManager +import picocli.CommandLine.Command +import picocli.CommandLine.Option +import java.util.concurrent.Callable +import java.util.logging.Logger + +@Command( + name = "clear-cache", + description = ["Delete cached patch files, logs, and temporary files."], +) +internal object ClearCacheCommand : Callable { + private val logger = Logger.getLogger(this::class.java.name) + + private const val EXIT_CODE_SUCCESS = 0 + private const val EXIT_CODE_ERROR = 1 + + @Option( + names = ["--info"], + description = ["Show a per-category breakdown of what was cleared and how much space was freed."], + ) + private var info: Boolean = false + + override fun call(): Int { + val result = CacheManager.clearCaches() + + if (info) { + logger.info( + buildString { + appendLine("Cache cleared:") + result.perDirectory.forEach { dir -> + appendLine( + " ${dir.label.padEnd(8)} ${humanReadable(dir.bytesFreed).padStart(9)}" + + " (${dir.filesDeleted} file${if (dir.filesDeleted == 1) "" else "s"})" + ) + } + append(" ${"Total".padEnd(8)} ${humanReadable(result.bytesFreed).padStart(9)}") + } + ) + } else { + logger.info("Cache cleared.") + } + + // Deletion failures always shown, even without --info. + return if (result.success) { + EXIT_CODE_SUCCESS + } else { + logger.warning("${result.failedFiles} file(s) could not be deleted (may be locked)") + EXIT_CODE_ERROR + } + } + + private fun humanReadable(bytes: Long): String = when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + } +} diff --git a/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt index 1ef5520..1194e4a 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt @@ -10,6 +10,6 @@ import picocli.CommandLine @CommandLine.Command( name = "utility", description = ["Commands for utility purposes."], - subcommands = [InstallCommand::class, UninstallCommand::class], + subcommands = [InstallCommand::class, UninstallCommand::class, ClearCacheCommand::class], ) internal object UtilityCommand diff --git a/src/main/kotlin/app/morphe/engine/CacheManager.kt b/src/main/kotlin/app/morphe/engine/CacheManager.kt new file mode 100644 index 0000000..5015d60 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/CacheManager.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine + +import java.io.File + +/** + * Clears Morphe's on-disk caches. Shared by the GUI's "Clear Cache" action and + * the CLI's `utility clear-cache` subcommand so both wipe the same set of + * directories: downloaded patches, logs, and temp scratch. + */ +object CacheManager { + + /** What was cleared from a single directory. */ + data class DirResult( + val label: String, + val bytesFreed: Long, + val filesDeleted: Int, + val failedFiles: Int, + ) + + /** Outcome of a [clearCaches] run, with a per-directory breakdown. */ + data class ClearResult( + val perDirectory: List, + ) { + val bytesFreed: Long get() = perDirectory.sumOf { it.bytesFreed } + val failedFiles: Int get() = perDirectory.sumOf { it.failedFiles } + val success: Boolean get() = failedFiles == 0 + } + + /** + * Deletes the contents of the patches cache, logs, and temp scratch + * directories (keeping the top-level directories themselves). + * + * Best-effort: a file that can't be deleted (e.g. a log still held open by + * the running process) is counted in [DirResult.failedFiles] rather than + * aborting the rest. + */ + fun clearCaches(): ClearResult = ClearResult( + listOf( + clearContents("Patches", MorpheData.patchesDir), + clearContents("Logs", MorpheData.logsDir), + clearContents("Temp", MorpheData.tmpDir), + ), + ) + + private fun clearContents(label: String, dir: File): DirResult { + var bytesFreed = 0L + var filesDeleted = 0 + var failedFiles = 0 + + dir.listFiles()?.forEach { entry -> + // Measure before deleting. + val files = entry.walkTopDown().filter { it.isFile }.toList() + val size = files.sumOf { it.length() } + if (entry.deleteRecursively()) { + bytesFreed += size + filesDeleted += files.size + } else { + failedFiles++ + } + } + return DirResult(label, bytesFreed, filesDeleted, failedFiles) + } +} diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt b/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt new file mode 100644 index 0000000..6d05f9d --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.patches + +import app.morphe.engine.MorpheData +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import java.io.File + +/** + * Shared on-disk cache layout for downloaded `.mpp` patch files. + * + * Both the GUI ([app.morphe.gui.data.repository.PatchRepository]) and the CLI + * ([app.morphe.cli.command.PatchFileResolver]) resolve their cache paths through here, + * so a patch file downloaded by one side is reused by the other instead of each keeping its own copy in a different place. + * + * Layout: `/-/__.mpp` + */ +object PatchCache { + + /** Per-source cache directory, e.g. `morphe-data/patches/MorpheApp-morphe-patches/`. */ + fun sourceDir(repoPath: String): File = + File(MorpheData.patchesDir, repoPath.replace("/", "-")).also { it.mkdirs() } + + /** + * Per-release cache filename, prefixed with the release tag. + * + * Many sources name their `.mpp` asset the same string across versions + * (e.g. `morphe-patches.mpp`); prefixing with the tag keeps versions from + * overwriting each other in the cache. + */ + fun cachedFileName(release: Release, asset: ReleaseAsset): String = + "${release.tagName}__${asset.name}" +} diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt index fcfc1fa..15c8ce2 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -7,9 +7,9 @@ package app.morphe.gui.data.repository import app.morphe.engine.model.Release import app.morphe.engine.model.ReleaseAsset +import app.morphe.engine.patches.PatchCache import app.morphe.engine.patches.RemotePatchSource import app.morphe.engine.patches.findPatchAsset -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -55,7 +55,7 @@ class PatchRepository( * to eyeball when grepping the cache directory than a single dash. */ fun cachedFileName(release: Release, asset: ReleaseAsset): String = - "${release.tagName}__${asset.name}" + PatchCache.cachedFileName(release, asset) } // In-memory cache so multiple callers don't re-fetch from the remote API @@ -124,8 +124,7 @@ class PatchRepository( Exception("No .mpp patch files found in release ${release.tagName}") ) - val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) - patchesDir.mkdirs() + val patchesDir = PatchCache.sourceDir(repoPath) val targetFile = File(patchesDir, cachedFileName(release, asset)) // Cache hit rules: @@ -155,7 +154,7 @@ class PatchRepository( /** Get cached patch file for a specific version. */ fun getCachedPatches(version: String): File? { - val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + val patchesDir = PatchCache.sourceDir(repoPath) return patchesDir.listFiles()?.find { it.name.contains(version) && isPatchFileName(it.name) } @@ -166,23 +165,19 @@ class PatchRepository( /** List all cached patch versions. */ fun listCachedPatches(): List { - val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + val patchesDir = PatchCache.sourceDir(repoPath) return patchesDir.listFiles()?.filter { isPatchFileName(it.name) } ?: emptyList() } /** Get the per-source cache directory for this repository. */ - fun getCacheDir(): File { - val dir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) - dir.mkdirs() - return dir - } + fun getCacheDir(): File = PatchCache.sourceDir(repoPath) /** Delete cached patches (both in-memory release list and on-disk files). */ fun clearCache(): Boolean { cachedReleases = null cacheTimestamp = 0L return try { - val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + val patchesDir = PatchCache.sourceDir(repoPath) var failedCount = 0 patchesDir.listFiles()?.forEach { file -> try { diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt index 422d8ab..a1a9dd1 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt @@ -24,6 +24,7 @@ import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.engine.CacheManager import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import java.awt.Desktop @@ -260,27 +261,11 @@ private fun calculateCacheSize(): String { } private fun clearAllCache(): Boolean { - return try { - var failedCount = 0 - FileUtils.getPatchesDir().listFiles()?.forEach { file -> - try { if (!file.deleteRecursively()) throw Exception("Could not delete") } - catch (e: Exception) { failedCount++; Logger.error("Failed to delete ${file.name}: ${e.message}") } - } - FileUtils.getLogsDir().listFiles()?.forEach { file -> - try { if (!file.deleteRecursively()) throw Exception("Could not delete") } - 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 + val result = CacheManager.clearCaches() + if (result.success) { + Logger.info("Cache cleared successfully (${result.bytesFreed} bytes freed)") + } else { + Logger.error("Cache clear incomplete: ${result.failedFiles} file(s) could not be deleted (may be locked)") } + return result.success } From efab4f94208cc1b3027e038c962f219bb033c2d3 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 2 Jul 2026 10:53:07 +0000 Subject: [PATCH 35/39] chore: Release v1.10.0-dev.9 [skip ci] # [1.10.0-dev.9](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.8...v1.10.0-dev.9) (2026-07-02) ### Features * cli ignores unknown keys (fix) + unify CLI/GUI patch cache + add clear-cache + make purge opt-out ([#192](https://github.com/MorpheApp/morphe-cli/issues/192)) ([70d16f0](https://github.com/MorpheApp/morphe-cli/commit/70d16f0e105f6362ee45e5e600c9ded01ab6c09e)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b999248..9523194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.9](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.8...v1.10.0-dev.9) (2026-07-02) + + +### Features + +* cli ignores unknown keys (fix) + unify CLI/GUI patch cache + add clear-cache + make purge opt-out ([#192](https://github.com/MorpheApp/morphe-cli/issues/192)) ([70d16f0](https://github.com/MorpheApp/morphe-cli/commit/70d16f0e105f6362ee45e5e600c9ded01ab6c09e)) + # [1.10.0-dev.8](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.7...v1.10.0-dev.8) (2026-07-02) diff --git a/gradle.properties b/gradle.properties index b9b2bf0..bc27cdc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.8 +version = 1.10.0-dev.9 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From 3d0af4b1d715ae3a8b07d5af7f78cd1a8d91a7e7 Mon Sep 17 00:00:00 2001 From: Eric Ahn Date: Mon, 6 Jul 2026 12:45:56 -0700 Subject: [PATCH 36/39] chore: Clarify intended usage of `--verify-with-sdk` --- src/main/kotlin/app/morphe/cli/command/PatchCommand.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index c149571..c6a4fab 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -314,7 +314,7 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["--verify-with-sdk"], - description = ["Verify the patched DEX and APK files using the provided Android SDK. If not specified, the patched files will not be verified."], + description = ["Verify the patched DEX and APK files using the provided Android SDK. If not specified, the patched files will not be verified. Verification may throw false positives and is for patch developer use only."], fallbackValue = "", arity = "0..1", ) From d7e9b625ae0571f2bafa082a3716138a2d7e2e8e Mon Sep 17 00:00:00 2001 From: Eric Ahn Date: Wed, 8 Jul 2026 20:15:20 -0700 Subject: [PATCH 37/39] fix: use latest patcher --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 860e811..2c0522b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ kotlin = "2.3.21" # CLI picocli = "4.7.7" arsclib = "a28c6fb2a7" -morphe-patcher = "1.6.0-dev.1" +morphe-patcher = "1.6.0-dev.2" morphe-library = "1.3.0" jadb = "1.2.3" From 6d260d6522c1bce71ad57eb7d75f62763950ae29 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 9 Jul 2026 03:19:15 +0000 Subject: [PATCH 38/39] chore: Release v1.10.0-dev.10 [skip ci] # [1.10.0-dev.10](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.9...v1.10.0-dev.10) (2026-07-09) ### Bug Fixes * use latest patcher ([d7e9b62](https://github.com/MorpheApp/morphe-cli/commit/d7e9b625ae0571f2bafa082a3716138a2d7e2e8e)) --- CHANGELOG.md | 7 +++++++ gradle.properties | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9523194..f4f4829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.10.0-dev.10](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.9...v1.10.0-dev.10) (2026-07-09) + + +### Bug Fixes + +* use latest patcher ([d7e9b62](https://github.com/MorpheApp/morphe-cli/commit/d7e9b625ae0571f2bafa082a3716138a2d7e2e8e)) + # [1.10.0-dev.9](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.8...v1.10.0-dev.9) (2026-07-02) diff --git a/gradle.properties b/gradle.properties index bc27cdc..6f933c7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.10.0-dev.9 +version = 1.10.0-dev.10 # Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than # the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit # exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each From f2322671057d70b86357f2f3cfeca7fc638ef8d3 Mon Sep 17 00:00:00 2001 From: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:19:47 +0300 Subject: [PATCH 39/39] chore: Add warranty statement to Notice and break out existing 7c into 7c and 7e sections --- NOTICE | 29 ++++++++++++++++++++--------- README.md | 4 ++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/NOTICE b/NOTICE index 7cee788..df948cd 100644 --- a/NOTICE +++ b/NOTICE @@ -4,15 +4,26 @@ https://github.com/MorpheApp/morphe-cli ============= -7b. Attribution Requirement ---------------------------- +GPLv3 Section 7b: Preservation of User Notices -This NOTICE file must be preserved and retained in all distributions -of the Source Code and any Derivative Works. +All derivative works (source code and binaries) must include this +NOTICE text and provide it to the software user. -7c. Project Name Restriction ----------------------------- +Portions of this software are provided "AS IS" by the Morphe software project. +Any express or implied warranties, including the implied warranties of +merchantability and fitness for a particular purpose, are disclaimed. -The project name "Morphe" is a protected identifier. Derivative works -must adopt a completely different identity that is not related to, -confusingly similar to, or an imitation of the name "Morphe". + +GPLv3 Section 7c: Prohibiting Misrepresentation of Origin + +You are prohibited from misrepresenting the origin of the Program, +and modified versions of the Program must be identified and marked in +reasonable ways as different from the original version so as not to cause +confusion regarding their origin. + + +GPLv3 Section 7e: Declining Grant of Trademark Rights + +This License does not grant any rights or permission under trademark law +to use the name "Morphe" or any of the Program's trade names, trademarks, +service marks, or logos. diff --git a/README.md b/README.md index 9621289..f731aa7 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ You can find the documentation of Morphe CLI [here](/docs). Morphe Patches are licensed under the [GNU General Public License v3.0](LICENSE), with additional conditions under GPLv3 Section 7: -- **Name Restriction (7c):** The name **"Morphe"** may not be used for derivative works. - Derivatives must adopt a distinct identity unrelated to "Morphe". +- **Attribution (7b):** Any use of this code, including derivative works, must preserve all original notices and disclaimers. +- **Name & Branding Restrictions (7c & 7e):** Derivative works must use their own distinct branding. The **"Morphe"** name, logos, and trademarks may not be used for the branding or title of derivative works (e.g., names like *"Morphe Plus"*, *"Morphe Expanded"*, or *"Morphe UserXYZ"* are strictly prohibited). See the [LICENSE](LICENSE) file for the full GPLv3 terms and the [NOTICE](NOTICE) file for full conditions of GPLv3 Section 7