diff --git a/CHANGELOG.md b/CHANGELOG.md index 01657333..832b5513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# [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) + + +### 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 2893a41a..0f78e7f3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,10 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.9.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 +# (~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 e99be645..480721a6 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 4134eb3a..4fa7747a 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 } @@ -823,7 +854,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, { diff --git a/src/main/kotlin/app/morphe/engine/MorpheData.kt b/src/main/kotlin/app/morphe/engine/MorpheData.kt index 4e6ea42c..18fae748 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 00000000..d83cd55e --- /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 00000000..44c6c01e --- /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 00000000..999095dd --- /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 00000000..9dc22780 --- /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 00000000..937bc816 --- /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 00000000..dfd784fc --- /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 00000000..499be162 --- /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 6e1b77cd..9a548bf7 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 4ef8ed1a..bdbfa52e 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 e2769a68..ff812549 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 a0477ffa..6ebdc202 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 496c62a4..9de335ad 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 9c2a68f5..004d2314 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 00000000..3122fa8e --- /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 00000000..df0474b5 --- /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 daa15b8f..79b273e4 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 db4e5f10..35df0b46 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 759275d7..f53a2c21 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 00000000..0ff179ee --- /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 00000000..422d8ab2 --- /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 6a56799d..11c85bcd 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 3ee85b97..896d5c27 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 e3ee6afe..fee6e345 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 00000000..474cf80f --- /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 9f7bbc59..1d9a3c28 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 5ec69c7b..06c8e386 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 480c4778..7d33101d 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 d0443513..94ca687a 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 08684763..1c92b6c1 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 cb076971..bbdf8ea0 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 64b0d6c8..52bd0fdd 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 b8fa6e1c..2adab97a 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 66458426..b4129f89 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 8364a712..ffe4e6dc 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 5f96e184..c802e76f 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 00000000..85794838 --- /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) + } +}