diff --git a/CHANGELOG.md b/CHANGELOG.md index a067dde4..eb803e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +# [1.9.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0-dev.2...v1.9.0-dev.3) (2026-05-25) + + +### Bug Fixes + +* Close adb when app closes ([#153](https://github.com/MorpheApp/morphe-cli/issues/153)) ([a43de5a](https://github.com/MorpheApp/morphe-cli/commit/a43de5a61ee30b7484b534cb6cc74e03bb297fa1)) + +# [1.9.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0-dev.1...v1.9.0-dev.2) (2026-05-20) + + +### Features + +* Apply patches from multiple patch bundles, add GUI patch source selector ([#145](https://github.com/MorpheApp/morphe-cli/issues/145)) ([44ed6c6](https://github.com/MorpheApp/morphe-cli/commit/44ed6c6efe5d7f97624557056b2caca23278eebf)) + +# [1.9.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.8.1...v1.9.0-dev.1) (2026-05-11) + + +### Features + +* Add setting menu to save patched app crash logs to file ([#143](https://github.com/MorpheApp/morphe-cli/issues/143)) ([90836b5](https://github.com/MorpheApp/morphe-cli/commit/90836b5cedbd6d0642a819abde7c33901a7e81a1)) + ## [1.8.1](https://github.com/MorpheApp/morphe-cli/compare/v1.8.0...v1.8.1) (2026-05-11) diff --git a/NOTICE b/NOTICE index 77a221fe..7cee7880 100644 --- a/NOTICE +++ b/NOTICE @@ -1,24 +1,18 @@ Morphe NOTICE -============= -This file contains Section 7 notices of the GNU General Public License v3 -as they apply to Morphe code. These notices apply to all code authored by -Morphe, including any modifications of code that may have originated -outside this repository, and do not change the terms of the GPLv3 license. -For the full license text, see the LICENSE file or: -https://www.gnu.org/licenses/gpl-3.0.html +https://github.com/MorpheApp/morphe-cli + +============= 7b. Attribution Requirement --------------------------- -Any distributed source code that incorporates Morphe CLI, -including modified versions and derivative works, must retain this NOTICE file. - -https://morphe.software +This NOTICE file must be preserved and retained in all distributions +of the Source Code and any Derivative Works. 7c. Project Name Restriction ---------------------------- -The project name "Morphe" may not be used for derivative works. -Derivatives must adopt a completely different identity that is not related -to or similar to the name "Morphe". +The project name "Morphe" is a protected identifier. Derivative works +must adopt a completely different identity that is not related to, +confusingly similar to, or an imitation of the name "Morphe". diff --git a/README.md b/README.md index 3ed24dad..f24a9380 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,6 @@ You can find the documentation of Morphe CLI [here](/docs). Morphe Patches are licensed under the [GNU General Public License v3.0](LICENSE), with additional conditions under GPLv3 Section 7: - **Name Restriction (7c):** The name **"Morphe"** may not be used for derivative works. - Derivatives must adopt a distinct identity unrelated to "Morphe." + Derivatives must adopt a distinct identity unrelated to "Morphe". See the [LICENSE](LICENSE) file for the full GPLv3 terms and the [NOTICE](NOTICE) file for full conditions of GPLv3 Section 7 diff --git a/build.gradle.kts b/build.gradle.kts index c428d2d1..d9bc4ea6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,7 @@ repositories { mavenLocal() mavenCentral() google() + maven { url = uri("https://maven.pkg.jetbrains.space/public/p/compose/dev") } maven { // A repository must be specified for some reason. "registry" is a dummy. url = uri("https://maven.pkg.github.com/MorpheApp/registry") @@ -85,8 +86,6 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.serialization.json) -// testImplementation(libs.kotlin.test) -//} // -- Networking (GUI) -------------------------------------------------- implementation(libs.ktor.client.core) @@ -94,6 +93,7 @@ dependencies { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.logging) + implementation(libs.slf4j.nop) // -- DI / Navigation (GUI) --------------------------------------------- implementation(platform(libs.koin.bom)) @@ -109,9 +109,7 @@ dependencies { implementation(libs.jna) implementation(libs.jna.platform) - // -- APK Parsing (GUI) ------------------------------------------------- - implementation(libs.apk.parser) - + // -- License attribution UI (About / Licenses screen) ----------------- implementation(libs.about.libraries.core) implementation(libs.about.libraries.m3) @@ -209,12 +207,15 @@ tasks { exclude(dependency("app.morphe:morphe-patcher")) // Ktor uses ServiceLoader exclude(dependency("io.ktor:.*")) + exclude(dependency("org.slf4j:.*")) // Koin uses reflection exclude(dependency("io.insert-koin:.*")) // Coroutines Swing provides Dispatchers.Main via ServiceLoader exclude(dependency("org.jetbrains.kotlinx:kotlinx-coroutines-swing")) // JNA uses reflection + native loading for DWM title bar tinting exclude(dependency("net.java.dev.jna:.*")) + // Skiko uses ServiceLoader for native registration. Same class of problem as Ktor / Koin / JNA above. + exclude(dependency("org.jetbrains.skiko:.*")) } mergeServiceFiles() diff --git a/gradle.properties b/gradle.properties index c7f410cc..788bc333 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.8.1 +version = 1.9.0-dev.3 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5d13b8d9..24557280 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] # Core -junit = "5.11.0" +junit = "6.0.3" kotlin = "2.3.21" # CLI picocli = "4.7.7" arsclib = "d78a66bcee" -morphe-patcher = "1.5.1-dev.4" +morphe-patcher = "1.5.1" morphe-library = "1.3.0" # Compose Desktop @@ -16,26 +16,26 @@ compose = "1.10.3" ktor = "3.4.3" # DI -koin-bom = "4.1.1" +koin-bom = "4.2.1" # Navigation voyager = "1.1.0-beta03" # Async / Serialization coroutines = "1.10.2" -kotlinx-serialization = "1.9.0" +kotlinx-serialization = "1.11.0" # JNA (Windows DWM title bar tinting) -jna = "5.14.0" - -# APK -apk-parser = "2.6.10" +jna = "5.18.1" # Testing mockk = "1.14.9" +# Logging +slf4j = "2.0.18" + # Libraries -about-libraries = "14.0.1" +about-libraries = "14.1.0" [libraries] # Morphe Core @@ -74,13 +74,13 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } -# APK -apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" } - # Testing kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +# Logging +slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } + # About Libraries about-libraries-core = { group = "com.mikepenz", name = "aboutlibraries-compose-core", version.ref = "about-libraries" } about-libraries-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "about-libraries" } diff --git a/package-lock.json b/package-lock.json index bf6315db..f5a43ada 100644 --- a/package-lock.json +++ b/package-lock.json @@ -397,9 +397,9 @@ } }, "node_modules/@semantic-release/github": { - "version": "12.0.6", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.6.tgz", - "integrity": "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA==", + "version": "12.0.8", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.8.tgz", + "integrity": "sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==", "dev": true, "license": "MIT", "dependencies": { @@ -411,8 +411,8 @@ "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", @@ -707,9 +707,9 @@ } }, "node_modules/@semantic-release/release-notes-generator": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", - "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", "dev": true, "license": "MIT", "dependencies": { @@ -718,9 +718,7 @@ "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", - "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", - "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, @@ -731,19 +729,6 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", - "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -908,13 +893,13 @@ "license": "MIT" }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/aggregate-error": { @@ -1855,21 +1840,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1905,9 +1879,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -2041,9 +2015,9 @@ } }, "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { @@ -2054,31 +2028,31 @@ } }, "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", + "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", + "agent-base": "9.0.0", "debug": "^4.3.4" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", + "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "agent-base": "9.0.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/human-signals": { @@ -2180,23 +2154,6 @@ "dev": true, "license": "ISC" }, - "node_modules/into-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", - "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2288,9 +2245,9 @@ "license": "ISC" }, "node_modules/issue-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", - "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", "dev": true, "license": "MIT", "dependencies": { @@ -2469,9 +2426,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2692,9 +2649,9 @@ } }, "node_modules/npm": { - "version": "11.13.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.13.0.tgz", - "integrity": "sha512-cRmhaghDWA1lFgl3Ug4/VxDJdPBK/U+tNtnrl9kXunFqhWw1x4xL5txkNn7qzPuVfvXOmXyjHpMwsuk2uisbkg==", + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.14.1.tgz", + "integrity": "sha512-aopNZ0eEl6LbxoFcrXLmTEPzNBNxfiQnVgR9RmJBqzm+5h5pFoOmRljpRJbsXxocBeSl7GLcx3MoDf2UlEOjZw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -2773,8 +2730,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.4.3", - "@npmcli/config": "^10.8.1", + "@npmcli/arborist": "^9.5.0", + "@npmcli/config": "^10.9.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -2798,11 +2755,11 @@ "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.6", - "libnpmexec": "^10.2.6", - "libnpmfund": "^7.0.20", + "libnpmdiff": "^8.1.7", + "libnpmexec": "^10.2.7", + "libnpmfund": "^7.0.21", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.6", + "libnpmpack": "^9.1.7", "libnpmpublish": "^11.1.3", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -2903,7 +2860,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.4.3", + "version": "9.5.0", "dev": true, "inBundle": true, "license": "ISC", @@ -2951,7 +2908,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.8.1", + "version": "10.9.0", "dev": true, "inBundle": true, "license": "ISC", @@ -3365,7 +3322,7 @@ } }, "node_modules/npm/node_modules/cidr-regex": { - "version": "5.0.4", + "version": "5.0.5", "dev": true, "inBundle": true, "license": "BSD-2-Clause", @@ -3588,7 +3545,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.1.0", + "version": "10.1.1", "dev": true, "inBundle": true, "license": "MIT", @@ -3670,12 +3627,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.6", + "version": "8.1.7", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.5.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -3689,13 +3646,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.6", + "version": "10.2.7", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.5.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -3712,12 +3669,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.20", + "version": "7.0.21", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.3" + "@npmcli/arborist": "^9.5.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -3737,12 +3694,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.6", + "version": "9.1.7", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.5.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -4373,12 +4330,12 @@ } }, "node_modules/npm/node_modules/socks": { - "version": "2.8.7", + "version": "2.8.8", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -4682,16 +4639,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", @@ -5348,9 +5295,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { diff --git a/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt b/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt new file mode 100644 index 00000000..80cf7814 --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.cli.command + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json + +/** + * Lazy initialized HttpClient for CLI commands. One client per process is fine for short-lived + * `morhpe-cli ....` invocations. Engine remote sources (like GitHub and GitLab) require this to be passed in. + * + * We could later swap `by lazy` for `fun create()` if we ever want the CLI to share lifecycle with anything else. + */ +object CliHttpClient { + val instance: HttpClient by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { json() } + } + } +} diff --git a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt index 78fcf0f2..523389c4 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -1,5 +1,6 @@ package app.morphe.cli.command +import app.morphe.engine.MorpheData import app.morphe.patcher.patch.PackageName import app.morphe.patcher.patch.VersionMap import app.morphe.patcher.patch.loadPatchesFromJar @@ -83,13 +84,14 @@ internal class ListCompatibleVersions : Runnable { appendLine(versions.buildVersionsString().prependIndent("\t")) } - val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir try { patchesFiles = PatchFileResolver.resolve( patchesFiles, prerelease, - temporaryFilesPath + temporaryFilesPath, + CliHttpClient.instance ) } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index 94384eaf..bf2a3156 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -8,6 +8,7 @@ package app.morphe.cli.command +import app.morphe.engine.MorpheData import app.morphe.patcher.patch.Package import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar @@ -181,13 +182,14 @@ internal object ListPatchesCommand : Runnable { } ?: withUniversalPatches - val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir try { patchesFiles = PatchFileResolver.resolve( patchesFiles, prerelease, - temporaryFilesPath + temporaryFilesPath, + CliHttpClient.instance ) } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index 00ac126c..3229d55c 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -1,10 +1,12 @@ package app.morphe.cli.command import app.morphe.cli.command.model.PatchBundle +import app.morphe.engine.MorpheData +import app.morphe.engine.patches.LoadedBundle +import app.morphe.engine.patches.PatchBundleLoader import app.morphe.cli.command.model.findMatchingBundle import app.morphe.cli.command.model.mergeWithBundle import app.morphe.cli.command.model.withUpdatedBundle -import app.morphe.patcher.patch.loadPatchesFromJar import kotlinx.serialization.json.Json import picocli.CommandLine import picocli.CommandLine.Command @@ -71,14 +73,19 @@ internal object OptionsCommand : Callable { private val json = Json { prettyPrint = true } override fun call(): Int { - val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir try { - patchesFiles = PatchFileResolver.resolve( - patchesFiles, - prerelease, - temporaryFilesPath - ) + // Since we could have many URLs, we resolve each of them separately + patchesFiles = patchesFiles.map { file -> + val resolved = PatchFileResolver.resolve( + setOf(file), + prerelease, + temporaryFilesPath, + CliHttpClient.instance + ) + resolved.single() + }.toSet() } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( spec.commandLine(), @@ -87,51 +94,64 @@ internal object OptionsCommand : Callable { } return try { - logger.info("Loading patches") - - val patches = loadPatchesFromJar(patchesFiles) + logger.info("Loading patches...") - val filtered = packageName?.let { pkg -> - patches.filter { patch -> - patch.compatiblePackages?.any { (name, _) -> name == pkg } ?: true - }.toSet() - } ?: patches + // Load each bundle separately so we produce one JSON entry per .mpp + // matches the shape PatchCommand expects when reading --options-file. + val loadedBundles: List = PatchBundleLoader.loadEach(patchesFiles) - // Read existing bundles list if the file already exists - val existingBundles: List? = if (outputFile.exists()) { + // Read existing bundles list if the file already exists. + val existingBundles: List = if (outputFile.exists()) + { try { Json.decodeFromString>(outputFile.readText()) } catch (e: Exception) { - logger.warning("Could not parse existing file, creating fresh: ${e.message}") - null + logger.warning( + "Could not parse existing file, creating fresh: ${e.message}" + ) + emptyList() } - } else null - - // Find the bundle matching the current .mpp file(s), merge with it (or create fresh) - val existingBundle = existingBundles?.findMatchingBundle(patchesFiles) - val updatedBundle = filtered.mergeWithBundle( - existing = existingBundle, - sourceFiles = patchesFiles, - ) - - // Replace the matching entry in the list (or start a new list) - val updatedBundles = existingBundles?.withUpdatedBundle(updatedBundle) - ?: listOf(updatedBundle) + } else emptyList() + + // For each bundle: apply optional package filter, find its matching JSON + // entry (by source filename), merge, splice updated entry back into the running list. + var updatedBundles = existingBundles + loadedBundles.forEach { lb -> + val filtered = packageName?.let { pkg -> + lb.patches.filter { patch -> + patch.compatiblePackages?.any { (name, _) -> name == pkg } ?: true + }.toSet() + } ?: lb.patches + + val existingBundle = updatedBundles.findMatchingBundle(setOf(lb.sourceFile)) + val updatedBundle = filtered.mergeWithBundle( + existing = existingBundle, + sourceFiles = setOf(lb.sourceFile), + ) + updatedBundles = updatedBundles.withUpdatedBundle(updatedBundle) + + // Per-bundle log line so users can see what changed for each .mpp + if (existingBundle != null) { + val existingNames = existingBundle.patches.keys.map { it.lowercase() }.toSet() + val newNames = updatedBundle.patches.keys.map { it.lowercase() }.toSet() + val added = newNames - existingNames + val removed = existingNames - newNames + val kept = newNames.intersect(existingNames) + + logger.info( + "Updated bundle for ${lb.sourceFile.name}: ${kept.size} preserved, ${added.size} added, ${removed.size} removed" + ) + } else { + logger.info( + "Created new bundle for ${lb.sourceFile.name} with ${updatedBundle.patches.size} patches" + ) + } + } outputFile.absoluteFile.parentFile?.mkdirs() outputFile.writeText(json.encodeToString(updatedBundles)) - if (existingBundle != null) { - val existingNames = existingBundle.patches.keys.map { it.lowercase() }.toSet() - val newNames = updatedBundle.patches.keys.map { it.lowercase() }.toSet() - val added = newNames - existingNames - val removed = existingNames - newNames - val kept = newNames.intersect(existingNames) - logger.info("Updated bundle in options file at ${outputFile.path}") - logger.info(" ${kept.size} patches preserved, ${added.size} added, ${removed.size} removed") - } else { - logger.info("Created new bundle in options file at ${outputFile.path} with ${updatedBundle.patches.size} patches") - } + logger.info("Options file saved to ${outputFile.path}") EXIT_CODE_SUCCESS } catch (e: Exception) { diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 5624f7bf..e24a1715 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -9,6 +9,7 @@ package app.morphe.cli.command import app.morphe.cli.command.model.* +import app.morphe.engine.MorpheData import app.morphe.engine.PatchEngine import app.morphe.engine.isWindows import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS @@ -17,6 +18,8 @@ import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_PASSWORD import app.morphe.engine.UpdateChecker +import app.morphe.engine.patches.LoadedBundle +import app.morphe.engine.patches.PatchBundleLoader import app.morphe.library.installation.installer.* import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig @@ -33,6 +36,7 @@ import app.morphe.patcher.patch.setOptions import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToStream import org.jetbrains.annotations.VisibleForTesting @@ -64,10 +68,22 @@ internal object PatchCommand : Callable { @Spec private lateinit var spec: CommandSpec - @ArgGroup(exclusive = false, multiplicity = "0..*") - private var selection = mutableSetOf() + @ArgGroup(exclusive = false, multiplicity = "1..*") + private var bundles = mutableListOf() - internal class Selection { + internal class BundleArgs { + @CommandLine.Option( + names = ["-p", "--patches"], + description = ["Path to a MPP file or a GitHub/Gitlab repo url such as https://github.com/MorpheApp/morphe-patches (Supports multiple patch files)"], + required = true, + ) + lateinit var patchesFile: File + + @ArgGroup(exclusive = false, multiplicity = "0..*") + var selections = mutableListOf() + } + + internal class Selection{ @ArgGroup(exclusive = false) internal var enabled: EnableSelection? = null @@ -222,7 +238,8 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["--purge"], - description = ["Purge temporary files directory after patching."], + description = ["Delete THIS run's scratch files after patching. " + + "Does not affect cached patches, other sessions, or config."], showDefaultValue = ALWAYS, ) private var purge: Boolean = false @@ -244,18 +261,6 @@ internal object PatchCommand : Callable { private lateinit var apk: File - @CommandLine.Option( - names = ["-p", "--patches"], - description = ["Path to a MPP file or a GitHub repo url such as https://github.com/MorpheApp/morphe-patches"], - required = true, - ) - @Suppress("unused") - private fun setPatchesFile(patchesFiles: Set) { - this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) - } - - private var patchesFiles = emptySet() - @CommandLine.Option( names = ["--prerelease"], description = ["Fetch the latest dev pre-release instead of the stable main release from the repo provided in --patches."], @@ -410,17 +415,25 @@ internal object PatchCommand : Callable { // region Setup - val outputFilePath = - outputFilePath ?: File("").absoluteFile.resolve( - "${apk.nameWithoutExtension}-patched.apk", + // Default output uses the unified scheme shared with the GUI: + // //-Morphe-{apkVer}-patches-{patchesVer}.apk + // The folder name uses the APK's human-friendly label (e.g. "Youtube") + // when readable from the manifest, falling back to the filename for + // corrupt or unparseable APKs. GUI populates this from apkInfo; + // CLI parses the APK here so both surfaces produce identical paths. + // Users who want the legacy `./-patched.apk` layout pass --out. + val outputFilePath = outputFilePath ?: run { + val displayName = app.morphe.engine.util.ApkOutputNaming.resolveAppDisplayName(apk) + app.morphe.engine.util.ApkOutputNaming.outputApkPath( + inputApk = apk, + patchesFile = bundles.firstOrNull()?.patchesFile, + appDisplayName = displayName, ) + } - val temporaryFilesPath = - temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - val keystoreFilePath = - keyStoreFilePath ?: outputFilePath.parentFile - .resolve("${outputFilePath.nameWithoutExtension}.keystore") + val keystoreFilePath = keyStoreFilePath ?: MorpheData.defaultKeystoreFile val installer = if (deviceSerial != null) { val deviceSerial = deviceSerial!!.ifEmpty { null } @@ -459,10 +472,19 @@ internal object PatchCommand : Callable { // Lightweight snapshot of patch metadata for use in finally block (auto-update). // Lightweight snapshot of current bundle metadata for use in finally block (auto-update). // The heavy Patch objects hold DEX classloaders and must not leak into finally. - var patchesSnapshot: PatchBundle? = null + var patchesSnapshotForFinally: List = emptyList() try { - patchesFiles = PatchFileResolver.resolve(patchesFiles, prerelease, temporaryFilesPath) + // We resolve each bundle's URL separately. + bundles.forEach { bundle -> + val resolved = PatchFileResolver.resolve( + setOf(bundle.patchesFile), + prerelease, + temporaryFilesPath, + CliHttpClient.instance + ) + bundle.patchesFile = resolved.single() + } } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( spec.commandLine(), @@ -470,60 +492,101 @@ internal object PatchCommand : Callable { ) } + // Per-session scratch dir. Hoisted out of the patching `try` block so + // the `finally` block can reference it for --purge scope (Phase 6). + // Naming matches the GUI's FileUtils.createPatchingTempDir() so the + // tmp/ folder shows consistent siblings across CLI + GUI sessions. + val patcherTemporaryFilesPath = + temporaryFilesPath.resolve("patching-${System.currentTimeMillis()}").also { it.mkdirs() } + try { - logger.info("Loading patches") - val patches: MutableSet> = loadPatchesFromJar(patchesFiles).toMutableSet() - patchesSnapshot = patches.toPatchBundle(sourceFiles = patchesFiles) + logger.info("Loading patches...") + + // We load each bundle separately so each bundle's options can be scoped correctly. + val loadedBundles: List = PatchBundleLoader.loadEach( + bundles.map { it.patchesFile } + ) - // region Parse options JSON - val patchOptionsBundle: PatchBundle? = optionsFilePath?.let { file -> - if (file.exists()) { + val patches: MutableSet> = loadedBundles.flatMap { it.patches }.toMutableSet() + val patchSnapshots: List = loadedBundles.map { lb -> + lb.patches.toPatchBundle(sourceFiles = setOf(lb.sourceFile)) + } + + // region Parse options JSON + val patchOptionsByFile: Map = optionsFilePath?.let { file -> + if (file.exists()){ logger.info("Reading options from ${file.path}") - val bundles = Json.decodeFromString>(file.readText()) - bundles.findMatchingBundle(patchesFiles) + val jsonBundles = Json.decodeFromString>(file.readText()) + loadedBundles.associate { lb -> + lb.sourceFile to jsonBundles.findMatchingBundle(setOf(lb.sourceFile)) + } } else { logger.info("Options file ${file.path} does not exist, generating with defaults") - val bundle = patches.toPatchBundle(sourceFiles = patchesFiles) + val freshBundles = patchSnapshots val json = Json { prettyPrint = true } file.absoluteFile.parentFile?.mkdirs() - file.writeText(json.encodeToString(listOf(bundle))) + file.writeText(json.encodeToString(freshBundles)) logger.info("Generated options file at ${file.path}") - bundle + loadedBundles.zip(freshBundles).associate { (lb, b) -> + lb.sourceFile to b + } } + } ?: emptyMap() + + // Per-bundle JSON-sourced enable/disable. Same patch name in two bundles can + // have different enabled states across mpps. + val jsonEnabledByFile: Map> = patchOptionsByFile.mapValues { (_, bundle) -> + bundle?.patches?.filter { it.value.enabled }?.keys?.map { + it.lowercase() }?.toSet() + ?: emptySet() } - // Build enable/disable sets from JSON (lowercase for case-insensitive matching) - val jsonEnabledPatches = patchOptionsBundle?.patches - ?.filter { (_, entry) -> entry.enabled } - ?.keys?.map { it.lowercase() }?.toSet() ?: emptySet() - val jsonDisabledPatches = patchOptionsBundle?.patches - ?.filter { (_, entry) -> !entry.enabled } - ?.keys?.map { it.lowercase() }?.toSet() ?: emptySet() - - // Build options map from JSON, deserializing values using each patch's option types - val jsonOptionsMap: Map> = patchOptionsBundle?.patches - ?.mapNotNull { (patchName, entry) -> - if (entry.options.isEmpty()) return@mapNotNull null - val patch = patches.firstOrNull { it.name.equals(patchName, ignoreCase = true) } - ?: return@mapNotNull null - val resolvedName = patch.name ?: return@mapNotNull null - val deserializedOptions = entry.options.mapNotNull { (key, element) -> - if (!patch.options.containsKey(key)) return@mapNotNull null - val option = patch.options[key] - try { - key to deserializeOptionValue(element, option.type) - } catch (e: Exception) { - logger.warning("Failed to deserialize option \"$key\" for \"$patchName\": ${e.message}") - null - } - }.toMap() - if (deserializedOptions.isEmpty()) null else resolvedName to deserializedOptions - }?.toMap() ?: emptyMap() + val jsonDisabledByFile: Map> = patchOptionsByFile.mapValues { (_, bundle) -> + bundle?.patches?.filter { !it.value.enabled }?.keys?.map { + it.lowercase() }?.toSet() + ?: emptySet() + } + + // Per-bundle options map. Same as before but indexed by source file. + // Option values are deserialized using the patch types from that file's bundle, not the global pool. + val jsonOptionsByFile: Map>> = + loadedBundles.associate { lb -> + val bundle = patchOptionsByFile[lb.sourceFile] + val opts = bundle?.patches?.mapNotNull { (patchName, entry) -> + if (entry.options.isEmpty()) return@mapNotNull null + val patch = lb.patches.firstOrNull { + it.name.equals(patchName, ignoreCase = true) + } ?: return@mapNotNull null + val resolvedName = patch.name ?: return@mapNotNull null + val deserializedOptions = entry.options.mapNotNull { (key, element) -> + if (!patch.options.containsKey(key)) return@mapNotNull null + val option = patch.options[key] + try { + key to deserializeOptionValue(element, option.type) + } catch (e: Exception) { + logger.warning( + "Failed to deserialize option $key for $patchName in ${lb.sourceFile.name}: ${e.message}" + ) + null + } + }.toMap() + + if (deserializedOptions.isEmpty()) null + else resolvedName to deserializedOptions + }?.toMap() ?: emptyMap() + lb.sourceFile to opts + } + + // Hand the per-bundle snapshots off to the finally block before we + // enter the Patcher use{} (which holds DEX classloaders we don't + // want leaking into finally). + patchesSnapshotForFinally = patchSnapshots // endregion - val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher") + // (patcherTemporaryFilesPath is declared above the outer try + // block so it's visible to --purge in the finally clause.) // We need to check for apkm (like reddit), xapk and apks formats here @@ -547,6 +610,7 @@ internal object PatchCommand : Callable { apk } + logger.info("Initializing patcher...") val (packageName, patcherResult) = Patcher( PatcherConfig( inputApk, @@ -569,141 +633,161 @@ internal object PatchCommand : Callable { patchingResult.packageName = packageName patchingResult.packageVersion = packageVersion - // Warn if options file is out of date (only for patches compatible with this app) - if (patchOptionsBundle != null && optionsFilePath?.exists() == true && !updateOptions) { - val compatiblePatchNames = patches - .filter { patch -> - patch.compatiblePackages == null || - patch.compatiblePackages!!.any { (name, _) -> name == packageName } - } - .mapNotNull { it.name?.lowercase() } - .toSet() - // All patch names in the .mpp regardless of app compatibility. - // Used for "removed" detection: a patch is only truly removed if it's - // gone from the .mpp entirely, not just incompatible with this app. - val allMppPatchNames = patches.mapNotNull { it.name?.lowercase() }.toSet() - val jsonPatchNames = patchOptionsBundle.patches.keys.map { it.lowercase() }.toSet() - - val newPatches = compatiblePatchNames - jsonPatchNames - val oldPatches = jsonPatchNames - compatiblePatchNames - val removedPatches = jsonPatchNames - allMppPatchNames - - // Check for new option keys within existing patches. For better messaging, store it as a map and show users which patch is outdated instead of just a number. - val patchesWithNewOptions = mutableMapOf>() - val patchesWithOldOptions = mutableMapOf>() - - for ((patchName, _) in patchesSnapshot.patches) { - if (patchName.lowercase() !in compatiblePatchNames) continue - val jsonEntry = patchOptionsBundle.patches.entries - .firstOrNull { it.key.equals(patchName, ignoreCase = true) }?.value - ?: continue - - // We compare from the patches list instead of the snapshot making it much better and accurate. - // Snapshot keeps merging all patches with same names but different options making it a problem. - val actualPatch = patches.find { - it.name.equals(patchName, ignoreCase = true) && - (it.compatiblePackages == null || it.compatiblePackages!!.any { - (name, _) -> name == packageName - }) - } - val actualOptionKeys = actualPatch?.options?.keys ?: emptySet() - - // This is for new keys that are introduced in the new patch - val newOptionKeys = actualOptionKeys - jsonEntry.options.keys - if (newOptionKeys.isNotEmpty()) patchesWithNewOptions[patchName] = newOptionKeys - - // This is for the old option keys that are not present in the new file - val oldOptionKeys = jsonEntry.options.keys - actualOptionKeys - if (oldOptionKeys.isNotEmpty()) patchesWithOldOptions[patchName] = oldOptionKeys - } - - if (newPatches.isNotEmpty() || oldPatches.isNotEmpty() || removedPatches.isNotEmpty() || patchesWithNewOptions.isNotEmpty() || patchesWithOldOptions.isNotEmpty()) { - logger.warning("Your options file is out of date with the current patches:") - if (newPatches.isNotEmpty()) { - logger.warning(" ${newPatches.size} new patches not in your options file, default patch values will be applied. New patches are:") - newPatches.forEach { - logger.warning(" - $it") + // Warn if options file is out of date — checked PER BUNDLE so + // each .mpp's drift is reported against its own JSON entry. + if (optionsFilePath?.exists() == true && !updateOptions) { + loadedBundles.forEachIndexed { i, lb -> + val bundleOpts = patchOptionsByFile[lb.sourceFile] ?: return@forEachIndexed + val bundleSnapshot = patchSnapshots[i] + val bundlePatches = lb.patches + + val compatiblePatchNames = bundlePatches + .filter { patch -> + patch.compatiblePackages == null || + patch.compatiblePackages!!.any { (name, _) -> name == packageName } } - } + .mapNotNull { it.name?.lowercase() } + .toSet() + // All patch names in this bundle regardless of app compatibility. + // Used for "removed" detection: a patch is only truly removed if + // it's gone from the .mpp entirely, not just incompatible with + // this app. + val allMppPatchNames = bundlePatches.mapNotNull { it.name?.lowercase() }.toSet() + val jsonPatchNames = bundleOpts.patches.keys.map { it.lowercase() }.toSet() + + val newPatches = compatiblePatchNames - jsonPatchNames + val oldPatches = jsonPatchNames - compatiblePatchNames + val removedPatches = jsonPatchNames - allMppPatchNames + + // Per-patch option-key drift. + val patchesWithNewOptions = mutableMapOf>() + val patchesWithOldOptions = mutableMapOf>() + + for ((patchName, _) in bundleSnapshot.patches) { + if (patchName.lowercase() !in compatiblePatchNames) continue + val jsonEntry = bundleOpts.patches.entries + .firstOrNull { it.key.equals(patchName, ignoreCase = true) }?.value + ?: continue + + // Compare against the live patch in this bundle (not the snapshot) + // so multi-app patches with the same name aren't merged together. + val actualPatch = bundlePatches.find { + it.name.equals(patchName, ignoreCase = true) && + (it.compatiblePackages == null || it.compatiblePackages!!.any { + (name, _) -> name == packageName + }) + } + val actualOptionKeys = actualPatch?.options?.keys ?: emptySet() - if (removedPatches.isNotEmpty()) { - logger.warning(" ${removedPatches.size} patches in your options file no longer exist and will be ignored") - } + val newOptionKeys = actualOptionKeys - jsonEntry.options.keys + if (newOptionKeys.isNotEmpty()) patchesWithNewOptions[patchName] = newOptionKeys - if (oldPatches.isNotEmpty()) { - logger.warning(" ${oldPatches.size} patches in your options file are not compatible with the app:") - oldPatches.forEach { - logger.warning(" - $it") - } + val oldOptionKeys = jsonEntry.options.keys - actualOptionKeys + if (oldOptionKeys.isNotEmpty()) patchesWithOldOptions[patchName] = oldOptionKeys } - if (patchesWithNewOptions.isNotEmpty()) { - patchesWithNewOptions.forEach { - (patch, key) -> - logger.warning(" \"$patch\" has new options: ${key.joinToString(", ")}") + if (newPatches.isNotEmpty() || oldPatches.isNotEmpty() || removedPatches.isNotEmpty() || + patchesWithNewOptions.isNotEmpty() || patchesWithOldOptions.isNotEmpty() + ) { + logger.warning("Options file is out of date for ${lb.sourceFile.name}:") + if (newPatches.isNotEmpty()) { + logger.warning(" ${newPatches.size} new patches not in your options file, default patch values will be applied. New patches are:") + newPatches.forEach { logger.warning(" - $it") } } - } - - if (patchesWithOldOptions.isNotEmpty()) { - patchesWithOldOptions.forEach { - (patch, key) -> - logger.warning(" \"$patch\" has old options: ${key.joinToString(", ")} that were removed.") + if (removedPatches.isNotEmpty()) { + logger.warning(" ${removedPatches.size} patches in your options file no longer exist and will be ignored") + } + if (oldPatches.isNotEmpty()) { + logger.warning(" ${oldPatches.size} patches in your options file are not compatible with the app:") + oldPatches.forEach { logger.warning(" - $it") } + } + if (patchesWithNewOptions.isNotEmpty()) { + patchesWithNewOptions.forEach { (patch, key) -> + logger.warning(" \"$patch\" has new options: ${key.joinToString(", ")}") + } + } + if (patchesWithOldOptions.isNotEmpty()) { + patchesWithOldOptions.forEach { (patch, key) -> + logger.warning(" \"$patch\" has old options: ${key.joinToString(", ")} that were removed.") + } } + logger.warning(" Use --options-update parameter to sync, or use 'options-create' command to regenerate.") } - logger.warning(" Use --options-update parameter to sync, or use 'options-create' command to regenerate.") } } - logger.info("Setting patch options") - - // Scope filteredPatches inside let so it goes out of scope immediately after - // patcher += filteredPatches, matching the pattern from PR #54. - patches.filterPatchSelection( - packageName, - packageVersion, - jsonEnabledPatches, - jsonDisabledPatches, - ).let { filteredPatches -> - val patchesList = patches.toList() - val cliOptionsMap = selection.filter { it.enabled != null }.associate { - val enabledSelection = it.enabled!! - - val resolvedName = enabledSelection.selector.name?.let { userInput -> - patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true) }?.name ?: userInput - } ?: patchesList[enabledSelection.selector.index!!].name!! - - resolvedName to enabledSelection.options + logger.info("Filtering patches for $packageName v$packageVersion...") + + // Filter + apply options PER BUNDLE so each bundle's selectors + // and options only touch its own patches. Final patcher input + // is the union across all bundles. + val finalPatches = mutableSetOf>() + loadedBundles.forEachIndexed { i, lb -> + val bundleArg = bundles[i] + val jsonEnabled = jsonEnabledByFile[lb.sourceFile] ?: emptySet() + val jsonDisabled = jsonDisabledByFile[lb.sourceFile] ?: emptySet() + val jsonOpts = jsonOptionsByFile[lb.sourceFile] ?: emptyMap() + + val patchesList = lb.patches.toList() + + // CLI options map scoped to this bundle. Name resolution looks + // up only this bundle's patches; --ei index is interpreted as + // an index INTO THIS BUNDLE'S patch list. + val cliOptionsMap = bundleArg.selections.filter { it.enabled != null }.associate { sel -> + val enabledSel = sel.enabled!! + val resolvedName = enabledSel.selector.name?.let { userInput -> + patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true) }?.name + ?: userInput + } ?: patchesList[enabledSel.selector.index!!].name!! + resolvedName to enabledSel.options } - (jsonOptionsMap.keys + cliOptionsMap.keys).associateWith { patchName -> - val jsonOpts = jsonOptionsMap[patchName] ?: emptyMap() - val cliOpts = cliOptionsMap[patchName] ?: emptyMap() + val filtered = lb.patches.filterPatchSelection( + packageName, + packageVersion, + bundleArg.selections, + jsonEnabled, + jsonDisabled, + ) - // Log when CLI options override JSON values - for ((key, cliValue) in cliOpts) { - val jsonValue = jsonOpts[key] + // Merge JSON + CLI options (CLI overrides JSON for same key) + // and apply to this bundle's filtered patches. + (jsonOpts.keys + cliOptionsMap.keys).associateWith { patchName -> + val js = jsonOpts[patchName] ?: emptyMap() + val cl = cliOptionsMap[patchName] ?: emptyMap() + for ((key, cliValue) in cl) { + val jsonValue = js[key] if (jsonValue != null && jsonValue != cliValue) { - logger.info("CLI option overrides JSON for \"$patchName\" -> \"$key\": $jsonValue -> $cliValue") + logger.info( + "CLI option overrides JSON for \"$patchName\" " + + "(${lb.sourceFile.name}) -> \"$key\": $jsonValue -> $cliValue" + ) } } + js + cl + }.let(filtered::setOptions) - jsonOpts + cliOpts // CLI entries override JSON entries for same key - }.let(filteredPatches::setOptions) - - patcher += filteredPatches - } // filteredPatches and patchesList go out of scope here - - // Execute patches. + finalPatches += filtered + } + patcher += finalPatches + + // Execute patches. Log lines match the engine's "Applying N + // patches…" → "Applied: " / "FAILED: " format so + // CLI and GUI output is consistent. CLI still appends the + // stacktrace on failure since there's no "View details" UI + // in a terminal. + logger.info("Applying ${finalPatches.size} patches...") patchingResult.addStepResult( PatchingStep.PATCHING, { runBlocking { patcher().collect { patchResult -> + val patchName = patchResult.patch.name ?: "Unknown" patchResult.exception?.let { exception -> StringWriter().use { writer -> exception.printStackTrace(PrintWriter(writer)) - logger.severe("\"${patchResult.patch}\" failed:\n$writer") + logger.severe("FAILED: $patchName\n$writer") patchingResult.failedPatches.add( FailedPatch( @@ -715,14 +799,14 @@ internal object PatchCommand : Callable { if (!continueOnError) { patchingResult.success = false throw PatchFailedException( - "\"${patchResult.patch}\" failed", + "FAILED: $patchName", exception ) } } - } ?: patchResult.patch.let { + } ?: run { patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) - logger.info("\"${patchResult.patch}\" succeeded") + logger.info("Applied: $patchName") } } } @@ -836,9 +920,9 @@ internal object PatchCommand : Callable { logger.info("Patching result saved to $outputFile") } - // Auto-update options JSON file using lightweight snapshot (no DEX references) - val snapshot = patchesSnapshot - if (optionsFilePath != null && updateOptions && snapshot != null) { + // Auto-update options JSON file using the per-bundle snapshots + // (no DEX references). One JSON entry per .mpp, matched by source. + if (optionsFilePath != null && updateOptions && patchesSnapshotForFinally.isNotEmpty()) { try { val existingBundles = optionsFilePath!!.let { file -> if (file.exists()) { @@ -846,9 +930,19 @@ internal object PatchCommand : Callable { catch (e: Exception) { emptyList() } } else emptyList() } - val existingBundle = existingBundles.findMatchingBundle(patchesFiles) - val updatedBundle = snapshot.mergeWith(existingBundle) - val updatedBundles = existingBundles.withUpdatedBundle(updatedBundle) + // Walk each bundle's snapshot, merge against its matching + // existing entry (by sha256 / source name), and splice the + // updated entry back into the list. Bundles without a prior + // entry get appended. + var updatedBundles = existingBundles + patchesSnapshotForFinally.forEach { snapshot -> + val sourceFile = snapshot.meta.source?.let { File(it) } + val existing = if (sourceFile != null) { + updatedBundles.findMatchingBundle(setOf(sourceFile)) + } else null + val updated = snapshot.mergeWith(existing) + updatedBundles = updatedBundles.withUpdatedBundle(updated) + } val json = Json { prettyPrint = true } optionsFilePath!!.writeText(json.encodeToString(updatedBundles)) logger.info("Updated options file ${optionsFilePath!!.path}") @@ -858,8 +952,14 @@ internal object PatchCommand : Callable { } if (purge) { - logger.info("Purging temporary files") - purge(temporaryFilesPath) + // Scope: only THIS session's tmp subfolder. Cached patches, + // logs, config, and other in-flight sessions (CLI or GUI) are + // never touched. + if (patcherTemporaryFilesPath.deleteRecursively()) { + logger.info("Purged this session's temp files: ${patcherTemporaryFilesPath.name}") + } else { + logger.warning("Failed to purge ${patcherTemporaryFilesPath.path}") + } } // Clean up merged apk if we created one from apkm, xapk or apks @@ -885,18 +985,19 @@ internal object PatchCommand : Callable { private fun Set>.filterPatchSelection( packageName: String, packageVersion: String, + bundleSelections: List, jsonEnabledPatches: Set = emptySet(), jsonDisabledPatches: Set = emptySet(), ): Set> = buildSet { // CLI flags (take precedence over JSON) val cliEnabledByName = - selection.mapNotNull { it.enabled?.selector?.name?.lowercase() }.toSet() + bundleSelections.mapNotNull { it.enabled?.selector?.name?.lowercase() }.toSet() val cliEnabledByIndex = - selection.mapNotNull { it.enabled?.selector?.index }.toSet() + bundleSelections.mapNotNull { it.enabled?.selector?.index }.toSet() val cliDisabledByName = - selection.mapNotNull { it.disable?.selector?.name?.lowercase() }.toSet() + bundleSelections.mapNotNull { it.disable?.selector?.name?.lowercase() }.toSet() val cliDisabledByIndex = - selection.mapNotNull { it.disable?.selector?.index }.toSet() + bundleSelections.mapNotNull { it.disable?.selector?.index }.toSet() this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) -> val patchName = patch.name!! @@ -906,56 +1007,67 @@ internal object PatchCommand : Callable { patch.compatiblePackages?.let { packages -> packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) -> if (versions?.isEmpty() == true) { - return@patchLoop logger.warning("\"$patchName\" incompatible with \"$packageName\"") + return@patchLoop logger.warning( + "Skipping \"$patchName\": incompatible with $packageName" + ) } val matchesVersion = force || versions?.let { it.any { version -> version == packageVersion } } ?: true if (!matchesVersion) { + val compatibilityHint = packages.joinToString("; ") { (pkg, vers) -> + pkg + " " + (vers ?: emptySet()).joinToString(", ") + } return@patchLoop logger.warning( - "\"$patchName\" incompatible with $packageName $packageVersion " + - "but compatible with " + - packages.joinToString("; ") { (packageName, versions) -> - packageName + " " + versions!!.joinToString(", ") - }, + "Skipping \"$patchName\": incompatible with $packageName $packageVersion " + + "(supported: $compatibilityHint)" ) } } ?: return@patchLoop logger.fine( - "\"$patchName\" incompatible with $packageName. " + - "It is only compatible with " + - packages.joinToString(", ") { (name, _) -> name }, + "Skipping \"$patchName\": incompatible with $packageName " + + "(only compatible with " + + packages.joinToString(", ") { (name, _) -> name } + ")" ) return@let } ?: logger.fine("\"$patchName\" has no package constraints") - // CLI flags take precedence over JSON, JSON takes precedence over defaults + // CLI flags take precedence over JSON, JSON takes precedence over defaults. + // Log strings match the GUI engine's "Skipping disabled: …" format so + // surfaces stay consistent. CLI-specific override hints are preserved + // as parentheticals. val isCliDisabled = patchNameLower in cliDisabledByName || i in cliDisabledByIndex if (isCliDisabled) { if (patchNameLower in jsonEnabledPatches) { - logger.info("\"$patchName\" disabled manually (overrides options file: enabled)") + logger.info("Skipping disabled: $patchName (overrides options file: enabled)") } else { - logger.info("\"$patchName\" disabled manually") + logger.info("Skipping disabled: $patchName") } return@patchLoop } val isCliEnabled = patchNameLower in cliEnabledByName || i in cliEnabledByIndex if (isCliEnabled && patchNameLower in jsonDisabledPatches) { - logger.info("\"$patchName\" enabled manually (overrides options file: disabled)") + logger.info("Enabling: $patchName (overrides options file: disabled)") } // JSON-sourced enable/disable (only applies if no CLI flag for this patch) val isJsonDisabled = !isCliEnabled && patchNameLower in jsonDisabledPatches - if (isJsonDisabled) return@patchLoop logger.info("\"$patchName\" disabled via options file") + if (isJsonDisabled) return@patchLoop logger.info( + "Skipping disabled: $patchName (from options file)" + ) val isJsonEnabled = patchNameLower in jsonEnabledPatches val isEnabled = !exclusive && patch.use if (!(isEnabled || isCliEnabled || isJsonEnabled)) { - return@patchLoop logger.info("\"$patchName\" disabled") + // Default-disabled patches (the patch ships with use=false and + // wasn't explicitly enabled). Log at info level — most CLI + // users want to see WHY each patch was skipped, even the + // ones that opted-out by default. + return@patchLoop logger.info("Skipping disabled: $patchName (default)") } add(patch) @@ -964,15 +1076,6 @@ internal object PatchCommand : Callable { } } - private fun purge(resourceCachePath: File) { - val result = - if (resourceCachePath.deleteRecursively()) { - "Purged resource cache directory" - } else { - "Failed to purge resource cache directory" - } - logger.info(result) - } } private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt index f7d48dc3..ece55d2e 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt @@ -1,10 +1,9 @@ package app.morphe.cli.command -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import app.morphe.engine.patches.RemotePatchSourceFactory +import app.morphe.engine.patches.findPatchAsset +import io.ktor.client.HttpClient +import kotlinx.coroutines.runBlocking import java.io.File import java.util.logging.Logger @@ -15,167 +14,94 @@ object PatchFileResolver { /** * Takes the user's provided Patch Files and resolves any URLs that might be present. * Returns a new Set with URLs replaced by downloaded/cached .mpp files. + * + * Provider detection (GitHub vs GitLab) + URL parsing + API talk lives in the engine. + * This function only owns the CLI's disk cache layout and the "which release do we pick" decision. */ - fun resolve( patchFiles: Set, prerelease: Boolean, - cacheDir: File + cacheDir: File, + httpClient: HttpClient ): Set { - // We try to download our patch file here if the user passed a link - if (patchFiles.any { - it.path.startsWith("http:/") || - it.path.startsWith("https:/") - }) { - try { - val urlEntry = patchFiles.first{ - it.path.startsWith("http:/") || it.path.startsWith("https:/") - } + val urlEntry = patchFiles.firstOrNull { + it.path.startsWith("http:/") || it.path.startsWith("https:/") + } ?: return patchFiles - val url = urlEntry.path + val url = urlEntry.path - val urlParts = url.split("/") - val owner = urlParts[2] - val repo = urlParts[3] + return runBlocking { + try { + // Parse the URL here, the engine handles github.com, gitlab.com, + // morphe.software/add-source links, bare owner/repo. - // Resolve the version and asset from the GitHub API, then use the helper to cache/download. - val version: String - val asset: JsonElement? + val parsed = RemotePatchSourceFactory.parse(url) + ?: throw IllegalArgumentException("Unrecognized patch URL: \$url") - if (url.contains("releases/tag/")){ - // We have the release version in this branch. - version = urlParts[6] // version part of the url + val source = parsed.instantiate(httpClient) - // First we hit the GitHub api for this specific release - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}" - ).toURL().openStream().bufferedReader().readText() + // List releases and decide which one to use. `releases/tag/` in the URL pins to + // that exact tag. - // Then we find where the .mpp file is from the stream above - val json = Json.parseToJsonElement(response).jsonObject - val assetArray = json["assets"]?.jsonArray + val pinnedTag = Regex("release/tag/([^/]+)").find(url)?.groupValues?.get(1) - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + val release = source.listReleases().getOrThrow() + val targetRelease = when { + pinnedTag != null -> release.firstOrNull { + it.tagName == pinnedTag } + ?: throw IllegalArgumentException("Version $pinnedTag not found in ${parsed.repoPath}") - } else if (!prerelease) { - // Here in this "only repo mentioned" branch, get the latest stable version. - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases/latest" - ).toURL().openStream().bufferedReader().readText() - - // Then we find where the .mpp file is from the stream above - val json = Json.parseToJsonElement(response).jsonObject - val assetArray = json["assets"]?.jsonArray - - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + prerelease -> release.firstOrNull { + it.isDevRelease() } + ?: throw IllegalArgumentException("Could not get dev release from ${parsed.repoPath}") - version = json["tag_name"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException( - "Could not determine version from ${owner}/${repo}" - ) - - } else { - // Get latest dev version here. - // Get latest dev version from GitHub immediately to check our local file. - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases" - ).toURL().openStream().bufferedReader().readText() - - val releases = Json.parseToJsonElement(response).jsonArray - val release = releases.firstOrNull { - it.jsonObject["prerelease"]?.jsonPrimitive?.content == "true" + else -> release.firstOrNull { + !it.isDevRelease() } - ?: throw IllegalArgumentException( - "Could not get dev release from ${owner}/${repo}" - ) + ?: throw IllegalArgumentException("Could not get stable release from ${parsed.repoPath}") + } - val assetArray = release.jsonObject["assets"]?.jsonArray + // Find the .mpp asset in that release. + val asset = targetRelease.findPatchAsset() + ?: throw IllegalArgumentException("No .mpp file found in release ${targetRelease.tagName}") - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true - } + // Disk-cache check (same layout as before: {cacheDir}/download/{owner}-{repo}/). + val versionNumber = targetRelease.tagName.removePrefix("v") + val repoCacheDir = + cacheDir.resolve("download").resolve(parsed.repoPath.replace("/", "-")) - version = release.jsonObject["tag_name"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException( - "Could not determine version from ${owner}/${repo}" - ) + val cachedFile = repoCacheDir.listFiles()?.find { + it.name.endsWith(".mpp") && it.name.contains(versionNumber) } - // Use the helper to check cache or download the .mpp file - val resolvedFile = fetchRemotePatchFile( - owner, - repo, - version, - asset, - cacheDir - ) - return patchFiles - urlEntry + resolvedFile - - } catch (e: Exception) { - throw IllegalArgumentException("Failed to download patches from URL: ${e.message}") - } - } - return patchFiles - } + val resolvedFile = if (cachedFile != null) { + val rel = cachedFile.relativeTo(cacheDir.parentFile).path + logger.info("Using cached patch file at $rel") - // This is the helper function that can be called to do the patch files downloading. - // The caller resolves the version and asset from the GitHub API before calling this. - private fun fetchRemotePatchFile( - owner: String, - repo: String, - version: String, - asset: JsonElement?, - cacheDir: File - ): File { - val versionNumber = version.removePrefix("v") - - val repoCacheDir = cacheDir.resolve("download").resolve("${owner}-${repo}") - - val cachedFile = repoCacheDir.listFiles()?.find { - it.name.endsWith(".mpp") && it.name.contains(versionNumber) - } + cachedFile + } else { + // Different version cached -> wipe it before downloading (matches the existing behavior) + repoCacheDir.listFiles() + ?.filter { it.name.endsWith(".mpp") } + ?.forEach{ it.delete() } + repoCacheDir.mkdirs() - if (cachedFile != null){ - val relativePath = cachedFile.relativeTo(cacheDir.parentFile).path - // If the user mentioned file with that version already exists, return that file location. - logger.info("Using cached patch file at $relativePath") - return cachedFile - } - else{ - // If it doesn't exist or some other version is present, then we come here. - // Either way we download our version and replace whatever else is present. - repoCacheDir.listFiles()?.filter { - it.name.endsWith(".mpp") - }?.forEach { it.delete() } - repoCacheDir.mkdirs() - - // Get the .mpp file ready here - val downloadUrl = asset?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.content - - // Also get the file name ready here - val assetName = asset?.jsonObject?.get("name")?.jsonPrimitive?.content - - if (downloadUrl == null || assetName == null){ - throw IllegalArgumentException("No .mpp file found in release $version") - } + val targetFile = File(repoCacheDir, asset.name) + logger.info("Downloading patches from ${parsed.repoPath} $versionNumber...") - // We finally download and set everything here. - logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}...") - val targetFile = File(repoCacheDir, assetName) - java.net.URI(downloadUrl).toURL().openStream().use { input -> - targetFile.outputStream().use { output -> - input.copyTo(output) - } - } + source.downloadAsset(asset, targetFile).getOrThrow() - val relativePath = targetFile.relativeTo(cacheDir.parentFile).path - logger.info("Patches mpp saved to $relativePath. This file will be used on your next run as long as it is not deleted!") + val rel = targetFile.relativeTo(cacheDir.parentFile).path + logger.info("Patches mpp saved to $rel. This file will be used on your next run as long as it is not deleted!") - return targetFile + targetFile + } + patchFiles - urlEntry + resolvedFile + } catch (e: Exception) { + throw IllegalArgumentException("Failed to download patches from URL: ${e.message}") + } } } } \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/engine/MorpheData.kt b/src/main/kotlin/app/morphe/engine/MorpheData.kt new file mode 100644 index 00000000..4e6ea42c --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/MorpheData.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import java.io.File +import java.util.logging.Logger + +/** + * Single source of truth for where Morphe stores its runtime data on disk. + * + * **Primary location**: a `morphe-data/` folder created **next to the running + * JAR**. Survives upgrades (multiple JAR versions side-by-side share one + * folder), trivially findable for users (just `cd` to where the JAR lives), + * portable across drives/USB sticks. + * + * **Fallback location**: `~/morphe/`. Used when the primary path is + * unreachable — most commonly when running from an IDE (`./gradlew run`) + * where the "JAR location" resolves to a `build/classes/` directory that + * would get wiped by `./gradlew clean`. Also covers the (rare) case of a + * read-only JAR install location. + * + * Layout once populated: + * ``` + * morphe-data/ + * patches/{owner}-{repo}/v1.5.0__patches.mpp # downloaded .mpp files + * logs/ # app logs + * config.json # GUI preferences + sources + * tmp/patching-{timestamp}/ # per-session patcher scratch + * morphe.keystore # shared default signing key + * ``` + * + * A single shared keystore is intentional: Android refuses updates whose + * signatures don't match the installed app, so per-app or per-output-APK + * keystores would break "re-patch and reinstall over the old version." A + * user who wants their own signing identity can point at a custom keystore + * in Settings, which overrides this default. + * + * All paths are computed lazily so the JVM is fully bootstrapped (classloader, + * security manager, etc.) before we probe for the JAR location. The + * resolution runs **at most once per JVM** — the lazy property caches. + */ +object MorpheData { + private val logger = Logger.getLogger(MorpheData::class.java.name) + + private val resolution: Resolution by lazy { resolveRoot() } + + /** Root: JAR-adjacent `morphe-data/`, with fallback to `~/morphe/`. */ + val root: File get() = resolution.root + + /** + * Anchor for portable relative paths in `config.json`. This is the + * **JAR's containing directory** — i.e. `root.parentFile` in the happy + * (JAR-adjacent) case. Null in fallback / IDE mode because there's no + * portable bundle then. + * + * Paths the user picks (output directory, keystore) that live under this + * anchor are stored in config as anchor-relative, so the whole bundle + * (JAR + `morphe-data/` + sibling folders) survives being moved. + */ + val bundleRoot: File? get() = resolution.bundleRoot + + private data class Resolution(val root: File, val bundleRoot: File?) + + /** Downloaded `.mpp` patch files, organized by source. */ + val patchesDir: File by lazy { File(root, "patches").also { it.mkdirs() } } + + /** App logs. */ + val logsDir: File by lazy { File(root, "logs").also { it.mkdirs() } } + + /** Patcher scratch space. Each patching session gets its own subfolder + * here (see Phase 6 of the unified-data-location plan). */ + val tmpDir: File by lazy { File(root, "tmp").also { it.mkdirs() } } + + /** GUI's persisted preferences (theme, enabled sources, etc.). */ + val configFile: File get() = File(root, "config.json") + + /** + * Default shared keystore. The patcher library creates it on first sign + * if missing; subsequent signs reuse the same identity so patched apps + * can be updated on-device without reinstalling. + */ + val defaultKeystoreFile: File get() = File(root, "morphe.keystore") + + /** + * Reason the primary (JAR-adjacent) location was rejected. Drives the + * fallback log message so a user reporting "where's my cache?" can + * tell from logs alone which branch ran. + */ + private enum class FallbackReason(val message: String) { + NO_JAR_LOCATION("Could not determine JAR location (running from IDE / classpath?)"), + NOT_A_JAR("Running source is not a JAR (likely IDE / `./gradlew run`)"), + NOT_WRITABLE("JAR directory is not writable"), + EXCEPTION("Exception while resolving JAR location"), + } + + private fun resolveRoot(): Resolution { + val (jarAdjacent, fallbackReason) = tryJarAdjacent() + if (jarAdjacent != null) { + logger.info("Morphe data root: ${jarAdjacent.absolutePath} (JAR-adjacent)") + jarAdjacent.mkdirs() + return Resolution(root = jarAdjacent, bundleRoot = jarAdjacent.parentFile) + } + val fallback = userHomeFallback() + // WARNING level — users debugging "I can't find my patches" or "config + // didn't persist" need to see this to know we fell back and why. + logger.warning( + "Morphe data root falling back to ${fallback.absolutePath} — " + + "primary (JAR-adjacent) unavailable: ${fallbackReason?.message ?: "unknown"}" + ) + fallback.mkdirs() + // No portable bundle concept in fallback mode — paths stay absolute. + return Resolution(root = fallback, bundleRoot = null) + } + + /** + * Returns (path, null) on success, (null, reason) on fallback. + * The reason gets surfaced in logs so users can tell WHY we fell back. + */ + private fun tryJarAdjacent(): Pair { + val location = try { + MorpheData::class.java.protectionDomain.codeSource?.location + ?: return null to FallbackReason.NO_JAR_LOCATION + } catch (e: Exception) { + return null to FallbackReason.EXCEPTION + } + + val jarFile = try { + // canonicalFile resolves symlinks — Homebrew/asdf-style installs + // often symlink the JAR; we want the cache next to the real file, + // not next to the symlink. + File(location.toURI()).canonicalFile + } catch (e: Exception) { + return null to FallbackReason.EXCEPTION + } + + // When running from IDE (`./gradlew run`), location is a classes + // directory, not a JAR. Detect and fall back so we don't pollute + // build outputs that `./gradlew clean` wipes. + if (jarFile.isDirectory || !jarFile.name.endsWith(".jar")) { + return null to FallbackReason.NOT_A_JAR + } + + val candidate = File(jarFile.parentFile, "morphe-data") + if (!isWritable(candidate)) { + return null to FallbackReason.NOT_WRITABLE + } + return candidate to null + } + + private fun userHomeFallback(): File { + val userHome = System.getProperty("user.home") + return File(userHome, "morphe") + } + + private fun isWritable(dir: File): Boolean { + if (dir.exists()) return dir.canWrite() + // Probe parent — if we can create the dir, we can write to it. + val parent = dir.parentFile ?: return false + return parent.canWrite() + } +} diff --git a/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt new file mode 100644 index 00000000..0058782f --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.patcher.patch.Patch +import app.morphe.patcher.patch.loadPatchesFromJar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.io.File +import java.util.logging.Logger + +/** + * Loads patches from one or more `.mpp` files in parallel and tags each loaded + * Patch instance with the source it came from. The union [Result.allPatches] + * is what [PatchEngine.patch] expects; the per-source breakdown is for + * GUI/CLI consumers that want to badge or filter by origin. + * + * Lives in the engine package so the CLI can consume the same multi-source + * loading path when it adopts multi-source. GUI-specific aggregation (display + * name resolution, icon color picking) lives in the GUI layer. + */ +object MultiSourceLoader { + + private val logger = Logger.getLogger(this::class.java.name) + + data class SourceInput( + val sourceId: String, + val sourceName: String, + val patchFile: File, + ) + + data class LoadedSource( + val sourceId: String, + val sourceName: String, + val patches: Set>, + val error: Throwable? = null, + ) { + val isSuccess: Boolean get() = error == null + } + + data class Result( + val perSource: List, + val allPatches: Set>, + // Map a deduped patch back to ALL source IDs that contain it. When the + // same patch (by Patch.equals, i.e. matching name + body + options) + // appears in multiple bundles, this set has every contributing source + // so the UI can render multi-source attribution. + val patchToSourceIds: Map, Set>, + ) { + val hasErrors: Boolean get() = perSource.any { !it.isSuccess } + } + + /** + * Load patches from each input in parallel. Each .mpp is copied to a temp file + * before loading to work around Windows URLClassLoader file-locking (mirrors + * the same workaround in PatchService.kt). + */ + suspend fun load(inputs: List): Result = coroutineScope { + val loaded = inputs.map { input -> + async(Dispatchers.IO) { loadOne(input) } + }.awaitAll() + + // Dedup intentional: identical patches across sources collapse into + // ONE entry — the UI then shows them as a single card with a multi- + // source attribution badge. Previously this used `.toMap()` for the + // source mapping which silently dropped all but the last source. + val allPatches = loaded.flatMap { it.patches }.toSet() + val patchToSourceIds: Map, Set> = loaded + .flatMap { src -> src.patches.map { it to src.sourceId } } + .groupBy({ it.first }, { it.second }) + .mapValues { it.value.toSet() } + + Result( + perSource = loaded, + allPatches = allPatches, + patchToSourceIds = patchToSourceIds, + ) + } + + private suspend fun loadOne(input: SourceInput): LoadedSource = withContext(Dispatchers.IO) { + val tempCopy = File.createTempFile("morphe-mp-${input.sourceId}-", ".mpp") + try { + input.patchFile.copyTo(tempCopy, overwrite = true) + val patches = loadPatchesFromJar(setOf(tempCopy)) + logger.info("MultiSourceLoader: loaded ${patches.size} patches from '${input.sourceName}'") + LoadedSource( + sourceId = input.sourceId, + sourceName = input.sourceName, + patches = patches, + ) + } catch (e: Exception) { + logger.warning("MultiSourceLoader: failed to load '${input.sourceName}': ${e.message}") + LoadedSource( + sourceId = input.sourceId, + sourceName = input.sourceName, + patches = emptySet(), + error = e, + ) + } finally { + tempCopy.deleteOnExit() + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/data/model/Release.kt b/src/main/kotlin/app/morphe/engine/model/Release.kt similarity index 56% rename from src/main/kotlin/app/morphe/gui/data/model/Release.kt rename to src/main/kotlin/app/morphe/engine/model/Release.kt index 50d54635..6b25a764 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Release.kt +++ b/src/main/kotlin/app/morphe/engine/model/Release.kt @@ -3,17 +3,25 @@ * https://github.com/MorpheApp/morphe-cli */ -package app.morphe.gui.data.model +package app.morphe.engine.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Represents a GitHub release (for CLI or Patches) + * Represents a release from a remote patch source (GitHub, GitLab, …). + * + * The on-the-wire JSON shape varies by provider. The engine's provider + * implementations are responsible for normalizing their response into this + * model so the rest of the codebase (GUI, CLI) doesn't need to know which + * provider produced a given release. */ @Serializable data class Release( - val id: Long, + // Default 0L: GitHub always returns a numeric release id, but GitLab's + // release list keys releases by tag_name instead — and we never read + // `id` anywhere, so a fallback keeps the model provider-agnostic. + val id: Long = 0L, @SerialName("tag_name") val tagName: String, val name: String? = null, @@ -35,7 +43,9 @@ data class Release( } /** - * Check if this is a dev/pre-release version + * Check if this is a dev/pre-release version. Providers that expose a + * `prerelease` flag (GitHub) set [isPrerelease] directly; providers that + * don't (GitLab) lean on the tag-name heuristic below. */ fun isDevRelease(): Boolean { return isPrerelease || tagName.contains("dev", ignoreCase = true) || @@ -46,13 +56,17 @@ data class Release( @Serializable data class ReleaseAsset( - val id: Long, + // Defaults: GitLab release links don't expose all of these consistently. + // None of these fields are required for selection / download — they're + // surfaced in UI ("12 MB · application/zip") at best, so a missing + // value just renders as "0 B" / "application/octet-stream". + val id: Long = 0L, val name: String, @SerialName("browser_download_url") val downloadUrl: String, - val size: Long, + val size: Long = 0L, @SerialName("content_type") - val contentType: String + val contentType: String = "application/octet-stream", ) { /** * Check if this is a patch file (.mpp) diff --git a/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt new file mode 100644 index 00000000..a4df1fbf --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.readRawBytes +import io.ktor.http.HttpHeaders +import io.ktor.http.isSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.logging.Logger + +/** + * GitHub provider. Hits api.github.com/repos/{owner}/{repo}/releases. + * + * GitHub's release JSON matches our [Release] model directly (the SerialName + * annotations align with GitHub's field names), so deserialization is a + * straight `response.body()` via Ktor content negotiation. + */ +class GitHubPatchSource( + private val httpClient: HttpClient, + override val repoPath: String, +) : RemotePatchSource { + + override val provider = PatchProvider.GITHUB + + private val logger = Logger.getLogger(GitHubPatchSource::class.java.name) + private val releasesEndpoint = "$API_BASE/repos/$repoPath/releases" + + override suspend fun listReleases(): Result> = withContext(Dispatchers.IO) { + try { + logger.info("GitHub: fetching releases from $releasesEndpoint") + val response: HttpResponse = httpClient.get(releasesEndpoint) { + headers { + append(HttpHeaders.Accept, "application/vnd.github+json") + append("X-GitHub-Api-Version", "2022-11-28") + } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("GitHub releases fetch failed: HTTP ${response.status}") + ) + } + val releases: List = response.body() + logger.info("GitHub: fetched ${releases.size} releases from $repoPath") + Result.success(releases) + } catch (e: Exception) { + logger.warning("GitHub releases fetch error for $repoPath: ${e.message}") + Result.failure(e) + } + } + + override suspend fun downloadAsset( + asset: ReleaseAsset, + targetFile: File, + ): Result = withContext(Dispatchers.IO) { + try { + logger.info("GitHub: downloading ${asset.name} from ${asset.downloadUrl}") + val response: HttpResponse = httpClient.get(asset.downloadUrl) { + headers { + append(HttpHeaders.Accept, "application/octet-stream") + } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("Download failed: HTTP ${response.status} from ${asset.downloadUrl}") + ) + } + val bytes = response.readRawBytes() + if (bytes.isEmpty()) { + return@withContext Result.failure( + Exception("Download returned 0 bytes from ${asset.downloadUrl}") + ) + } + targetFile.parentFile?.mkdirs() + targetFile.writeBytes(bytes) + logger.info("GitHub: wrote ${bytes.size} bytes to ${targetFile.absolutePath}") + Result.success(targetFile) + } catch (e: Exception) { + // Don't leave a partial file behind + if (targetFile.exists() && targetFile.length() == 0L) targetFile.delete() + Result.failure(e) + } + } + + companion object { + private const val API_BASE = "https://api.github.com" + } +} diff --git a/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt new file mode 100644 index 00000000..d1dfeff2 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.head +import io.ktor.client.request.headers +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.client.statement.readRawBytes +import io.ktor.http.HttpHeaders +import io.ktor.http.isSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.net.URLEncoder +import java.util.logging.Logger + +/** + * GitLab provider. Hits gitlab.com/api/v4/projects/{owner%2Frepo}/releases. + * + * GitLab's release JSON shape differs from GitHub's in several ways that + * require normalization rather than direct deserialization: + * - Assets live under `assets.links[]`, not `assets[]` + * - Each asset link uses `direct_asset_url` (or fallback `url`), not + * `browser_download_url` + * - No `prerelease` flag — dev detection falls back to the tag-name + * heuristic in [Release.isDevRelease] + * - No size or content_type in the release payload — we resolve sizes + * via parallel HEAD requests against the .mpp assets we care about, + * so the UI can show real megabytes + */ +class GitLabPatchSource( + private val httpClient: HttpClient, + override val repoPath: String, +) : RemotePatchSource { + + override val provider = PatchProvider.GITLAB + + private val logger = Logger.getLogger(GitLabPatchSource::class.java.name) + + // GitLab's projects API expects the path URL-encoded as `owner%2Frepo`. + private val releasesEndpoint: String = run { + val encoded = URLEncoder.encode(repoPath, "UTF-8") + "$API_BASE/projects/$encoded/releases" + } + + override suspend fun listReleases(): Result> = withContext(Dispatchers.IO) { + try { + logger.info("GitLab: fetching releases from $releasesEndpoint") + val response: HttpResponse = httpClient.get(releasesEndpoint) { + headers { + append(HttpHeaders.Accept, "application/json") + } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("GitLab releases fetch failed: HTTP ${response.status}") + ) + } + val raw = response.bodyAsText() + val releases = parseReleases(raw) + logger.info("GitLab: fetched ${releases.size} releases from $repoPath") + Result.success(releases) + } catch (e: Exception) { + logger.warning("GitLab releases fetch error for $repoPath: ${e.message}") + Result.failure(e) + } + } + + override suspend fun downloadAsset( + asset: ReleaseAsset, + targetFile: File, + ): Result = withContext(Dispatchers.IO) { + try { + logger.info("GitLab: downloading ${asset.name} from ${asset.downloadUrl}") + val response: HttpResponse = httpClient.get(asset.downloadUrl) { + headers { + append(HttpHeaders.Accept, "application/octet-stream") + } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("Download failed: HTTP ${response.status} from ${asset.downloadUrl}") + ) + } + val bytes = response.readRawBytes() + if (bytes.isEmpty()) { + return@withContext Result.failure( + Exception("Download returned 0 bytes from ${asset.downloadUrl}") + ) + } + targetFile.parentFile?.mkdirs() + targetFile.writeBytes(bytes) + logger.info("GitLab: wrote ${bytes.size} bytes to ${targetFile.absolutePath}") + Result.success(targetFile) + } catch (e: Exception) { + if (targetFile.exists() && targetFile.length() == 0L) targetFile.delete() + Result.failure(e) + } + } + + // ── Normalization ────────────────────────────────────────────────────── + + private suspend fun parseReleases(rawJson: String): List { + val root = Json.parseToJsonElement(rawJson) + val array = (root as? JsonArray) ?: return emptyList() + + // Pass 1: collect tag/name/etc + (assetName, downloadUrl) pairs, sizes + // still unknown. + data class RawRelease( + val tagName: String, + val name: String?, + val publishedAt: String?, + val description: String?, + val assets: List>, + ) + + val rawReleases: List = array.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + val tagName = obj["tag_name"]?.jsonPrimitive?.content ?: return@mapNotNull null + val name = obj["name"]?.jsonPrimitive?.content + val publishedAt = obj["released_at"]?.jsonPrimitive?.content + val description = obj["description"]?.jsonPrimitive?.content + val links = obj["assets"]?.jsonObject?.get("links")?.jsonArray ?: JsonArray(emptyList()) + val assetPairs = links.mapNotNull { linkEl -> + val link = linkEl as? JsonObject ?: return@mapNotNull null + val assetName = link["name"]?.jsonPrimitive?.content ?: return@mapNotNull null + val downloadUrl = link["direct_asset_url"]?.jsonPrimitive?.content + ?: link["url"]?.jsonPrimitive?.content + ?: return@mapNotNull null + assetName to downloadUrl + } + RawRelease(tagName, name, publishedAt, description, assetPairs) + } + + // Pass 1.5: resolve sizes for .mpp assets via parallel HEAD requests. + // GitLab's 2000 req/hr unauth limit means even ~50 HEADs per fetch + // is comfortably within budget; running them in parallel keeps total + // latency at one round-trip. + val mppUrls: Set = rawReleases + .flatMap { it.assets } + .filter { it.first.endsWith(".mpp", ignoreCase = true) } + .map { it.second } + .toSet() + + val sizesByUrl: Map = if (mppUrls.isEmpty()) { + emptyMap() + } else { + coroutineScope { + mppUrls.map { url -> + async { url to resolveContentLength(url) } + }.awaitAll().toMap() + } + } + + // Pass 2: build the model with resolved sizes spliced in. + return rawReleases.map { raw -> + val releaseAssets = raw.assets.map { (assetName, downloadUrl) -> + ReleaseAsset( + name = assetName, + downloadUrl = downloadUrl, + size = sizesByUrl[downloadUrl] ?: 0L, + ) + } + Release( + tagName = raw.tagName, + name = raw.name, + // GitLab has no prerelease flag — dev detection falls back to + // tag-name patterns inside Release.isDevRelease(). + isPrerelease = false, + publishedAt = raw.publishedAt, + assets = releaseAssets, + body = raw.description, + ) + } + } + + /** + * HEAD a URL and read Content-Length. Returns 0 on any failure — size is + * cosmetic, never blocks the release listing. + */ + private suspend fun resolveContentLength(url: String): Long { + return try { + val response: HttpResponse = httpClient.head(url) + if (!response.status.isSuccess()) { + logger.fine("HEAD $url returned ${response.status}") + return 0L + } + response.headers[HttpHeaders.ContentLength]?.toLongOrNull() ?: 0L + } catch (e: Exception) { + logger.fine("HEAD failed for $url: ${e.message}") + 0L + } + } + + companion object { + private const val API_BASE = "https://gitlab.com/api/v4" + } +} diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt b/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt new file mode 100644 index 00000000..2009e845 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import app.morphe.patcher.patch.Patch +import app.morphe.patcher.patch.loadPatchesFromJar +import java.io.File + +/** + * One .mpp file's worth of patches, paired with the file they came from. + * Used by callers (CLI selector scoping, GUI multi-source provenance) that + * need to tell which bundle a patch originated in. + */ +data class LoadedBundle( + val sourceFile: File, + val patches: Set>, +) + +/** + * Bundle-aware patch loader. Wraps morphe-patcher's flat + * [loadPatchesFromJar] so callers can keep per-source separation — + * critical for the CLI when the same patch name appears in multiple + * bundles and selectors (`-e`, `-d`, `-O`) need to be scoped per file. + * + * morphe-patcher itself is unchanged: we just call it once per file + * instead of once with the union. + */ +object PatchBundleLoader { + + /** + * Load each [.mpp] file separately. Returns a list in the same order + * as [files] so callers can pair up CLI argument order with results + * (positional scoping relies on this). + * + * If any file fails to load, the exception propagates — same behavior + * as a flat [loadPatchesFromJar] call. + */ + fun loadEach(files: Iterable): List = + files.map { file -> + LoadedBundle( + sourceFile = file, + patches = loadPatchesFromJar(setOf(file)).toSet(), + ) + } + + /** + * Convenience: load and flatten into a single set, matching the old + * [loadPatchesFromJar] shape. Use only when bundle provenance is + * genuinely irrelevant — e.g. when you've already done per-bundle + * selection and just need the final union to hand to the patcher. + */ + fun loadFlat(files: Iterable): Set> = + loadEach(files).flatMap { it.patches }.toSet() +} diff --git a/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt new file mode 100644 index 00000000..ef03d743 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import java.io.File + +/** + * Provider-agnostic interface for fetching patch releases from a remote + * source (GitHub, GitLab, …). Implementations own: + * - API endpoint construction + * - HTTP headers and response shape normalization + * - Asset download mechanics (redirects, byte streaming) + * + * Implementations do NOT own: + * - In-memory or disk caching policy — that's a caller concern + * - Multi-source orchestration + * - UI / progress reporting style + * + * This is the engine layer's heart for remote patches. Both the GUI's + * PatchRepository and the CLI's PatchFileResolver call into a + * RemotePatchSource to do the real work, while owning their own caching + * and surface-specific concerns on top. + */ +interface RemotePatchSource { + /** Which remote provider this source talks to. */ + val provider: PatchProvider + + /** The "owner/repo" path on the remote. */ + val repoPath: String + + /** + * Fetch all releases for [repoPath] from the remote API. Implementations + * normalize the provider's JSON shape into the shared [Release] model. + * + * Failures (network, HTTP non-2xx, parse errors) surface as a failed + * [Result] — never throw. Callers decide whether to retry, return stale + * data, or bubble the error to the user. + */ + suspend fun listReleases(): Result> + + /** + * Download [asset] to [targetFile]. Replaces any existing file at the + * target path. Returns the file on success. + * + * Implementations are responsible for: + * - Following any provider-specific redirects (GitLab's `direct_asset_url`) + * - Sending appropriate Accept / auth headers + * - Failing if the response body is empty (zero-byte downloads are + * never valid patch files) + * + * Implementations are NOT responsible for cache-hit checks — the caller + * should look at [targetFile] before calling this if it wants caching. + */ + suspend fun downloadAsset(asset: ReleaseAsset, targetFile: File): Result +} + +/** + * Remote providers the engine knows how to talk to. Add new entries here + * (Gitea, self-hosted GitLab, etc.) and they propagate to every caller. + */ +enum class PatchProvider { + GITHUB, + GITLAB, +} + +/** Convenience: find the .mpp asset in a release. */ +fun Release.findPatchAsset(): ReleaseAsset? = assets.firstOrNull { it.isPatchFile() } diff --git a/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt new file mode 100644 index 00000000..b82010dd --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import io.ktor.client.HttpClient + +/** + * Centralized URL parsing + provider detection for remote patch sources. + * + * Single source of truth for "given some user input, figure out the + * provider, owner, and repo." Both GUI (PatchSourceManager, + * PatchSourceDialogs) and CLI (PatchFileResolver) call into this — never + * roll their own URL parsing. + * + * Accepted inputs: + * - Full URL: `https://github.com/owner/repo[/…]`, `https://gitlab.com/owner/repo[/…]` + * - Bare host path: `github.com/owner/repo`, `gitlab.com/owner/repo` + * - Deep-link: `morphe.software/add-source?github=owner/repo` (or `?gitlab=owner/repo`) + * - Bare `owner/repo` — defaults to GitHub for backwards compatibility + * + * Anything that doesn't match → null. + */ +object RemotePatchSourceFactory { + + /** + * Parse a user-entered URL and return a [Parsed] descriptor on success, + * null when the input can't be classified. + * + * Use [instantiate] to turn the descriptor into a working [RemotePatchSource]. + * Splitting parse from instantiation lets callers validate URLs in + * dialogs without needing an HttpClient handy. + */ + fun parse(input: String): Parsed? { + val trimmed = input.trim() + if (trimmed.isBlank()) return null + + // Deep-link form + if (trimmed.contains("morphe.software/add-source")) { + Regex("[?&]github=([^&]+)").find(trimmed)?.let { match -> + return buildParsed(match.groupValues[1], PatchProvider.GITHUB) + } + Regex("[?&]gitlab=([^&]+)").find(trimmed)?.let { match -> + return buildParsed(match.groupValues[1], PatchProvider.GITLAB) + } + return null + } + + if (trimmed.contains("github.com/")) { + val match = Regex("github\\.com/([^/]+/[^/?#]+)").find(trimmed) ?: return null + return buildParsed(match.groupValues[1], PatchProvider.GITHUB) + } + + if (trimmed.contains("gitlab.com/")) { + val match = Regex("gitlab\\.com/([^/]+/[^/?#]+)").find(trimmed) ?: return null + return buildParsed(match.groupValues[1], PatchProvider.GITLAB) + } + + // Bare "owner/repo" — assume GitHub for backwards compatibility with + // the historical default behavior. + if (trimmed.matches(Regex("[\\w.-]+/[\\w.-]+"))) { + return buildParsed(trimmed, PatchProvider.GITHUB) + } + + return null + } + + /** + * Convenience: parse and instantiate in one shot. + */ + fun from(input: String, httpClient: HttpClient): RemotePatchSource? = + parse(input)?.instantiate(httpClient) + + /** + * Build a source for a known provider + repoPath, skipping URL parsing. + * Used by callers that already have the canonical pieces in hand (e.g. + * the GUI's PatchSourceManager loading a previously-saved source). + */ + fun build(provider: PatchProvider, repoPath: String, httpClient: HttpClient): RemotePatchSource = + Parsed(provider, repoPath).instantiate(httpClient) + + private fun buildParsed(rawPath: String, provider: PatchProvider): Parsed? { + val clean = rawPath.trimEnd('/').removeSuffix(".git") + if (!clean.contains('/') || clean.split('/').size != 2) return null + return Parsed(provider, clean) + } + + /** + * Result of parsing — provider + repoPath are all the engine needs to + * spin up a working source. The canonical URL is reconstructed from + * provider + repoPath via [canonicalUrl] for surface code that needs + * to persist or display it. + */ + data class Parsed( + val provider: PatchProvider, + val repoPath: String, + ) { + val canonicalUrl: String + get() = when (provider) { + PatchProvider.GITHUB -> "https://github.com/$repoPath" + PatchProvider.GITLAB -> "https://gitlab.com/$repoPath" + } + + fun instantiate(httpClient: HttpClient): RemotePatchSource = when (provider) { + PatchProvider.GITHUB -> GitHubPatchSource(httpClient, repoPath) + PatchProvider.GITLAB -> GitLabPatchSource(httpClient, repoPath) + } + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt b/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt new file mode 100644 index 00000000..a3e6a752 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import com.reandroid.arsc.chunk.xml.AndroidManifestBlock +import java.io.File +import java.util.logging.Logger +import java.util.zip.ZipFile + +/** + * Read structural metadata from an APK's `AndroidManifest.xml` using ARSCLib. + * + * This is the **only** manifest reader we use across the project. It replaces + * `net.dongliu:apk-parser`, which is unmaintained and crashes on split APKs + * whose base.apk references resources living in other splits (SoundCloud, + * Spotify, every large modular Play Store app). ARSCLib is the same library + * morphe-patcher uses internally — so anything the patcher can read, we can + * read. + * + * Only **direct string attributes** are exposed (packageName, versionName, + * minSdkVersion). The application label is included only when it's a literal + * string in the manifest — when it's a `@string/app_name` resource reference, + * resolving it would require the full resource table (and the split tables + * for split APKs), which we deliberately don't do. Callers that need a + * friendly display name should look it up against their supported-apps list + * by packageName. + * + * Caller is responsible for extracting `base.apk` from bundle formats + * (.apkm/.xapk/.apks) before passing to [read]. + */ +object ApkManifestReader { + private val logger = Logger.getLogger(ApkManifestReader::class.java.name) + + /** + * Read the manifest of an APK file. Returns null on failure (corrupt, + * not an APK, missing AndroidManifest.xml). + */ + fun read(apkFile: File): ApkManifest? { + return try { + ZipFile(apkFile).use { zip -> + val entry = zip.getEntry("AndroidManifest.xml") ?: run { + logger.warning("No AndroidManifest.xml in ${apkFile.name}") + return null + } + val block = zip.getInputStream(entry).use { input -> + AndroidManifestBlock.load(input) + } + val packageName = block.packageName ?: run { + logger.warning("Manifest has no package name in ${apkFile.name}") + return null + } + ApkManifest( + packageName = packageName, + versionName = block.versionName, + versionCode = block.versionCode, + minSdkVersion = block.minSdkVersion, + applicationLabel = block.applicationLabelString, + ) + } + } catch (e: Exception) { + logger.warning("Failed to read manifest from ${apkFile.name}: ${e.message}") + null + } + } +} + +/** + * Direct attributes from `AndroidManifest.xml`. None of these require resource + * resolution — they're plain strings/integers stored inline in the manifest. + * + * @property packageName always present (manifest is rejected without it) + * @property versionName may be null for APKs that omit it (rare) + * @property versionCode may be null for APKs that omit it (rare) + * @property minSdkVersion from `` + * @property applicationLabel the app's display name, only when stored as a + * literal string. Null when stored as a resource + * reference (`@string/app_name`) — callers should + * fall back to a supported-apps lookup by package. + */ +data class ApkManifest( + val packageName: String, + val versionName: String?, + val versionCode: Int?, + val minSdkVersion: Int?, + val applicationLabel: String?, +) diff --git a/src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt b/src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt new file mode 100644 index 00000000..bac028b4 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File + +/** + * Shared filename helpers + output-path computation for patched APKs. Used by + * both the GUI ([app.morphe.gui.ui.screens.patches.PatchSelectionViewModel]) + * and the CLI ([app.morphe.cli.command.PatchCommand]) so identical inputs + * produce identical output paths — no surprises when users switch between + * surfaces. + * + * Lives in `engine.util` because output naming is a pure data transformation + * with no UI or CLI dependencies, and consolidating it in the engine moves + * one more thing toward the long-term "engine is the heart" architecture. + */ +object ApkOutputNaming { + + private val patchesVersionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") + + /** + * Extract APK version from an APKMirror-style filename: + * `_-.apk` → returns ``. + * Also handles the same convention with .apkm/.xapk/.apks extensions — + * `_.apkm` → returns ``. Returns null for + * filenames that don't follow this convention. + */ + fun extractApkVersionFromFilename(fileName: String): String? = try { + // Strip the bundle extension first so it doesn't leak into the version. + // File.nameWithoutExtension handles single extensions cleanly; we list + // the .apk-family ones explicitly because filenames like + // `soundcloud_2026.04.27.apkm` have multiple "extensions" in a row + // (the version dots look like extensions to nameWithoutExtension). + val withoutExt = fileName + .removeSuffix(".apk") + .removeSuffix(".apkm") + .removeSuffix(".xapk") + .removeSuffix(".apks") + val afterPackage = withoutExt.substringAfter("_") + afterPackage.substringBefore("-").takeIf { it.isNotEmpty() } + } catch (e: Exception) { + null + } + + /** + * Extract patches version from a .mpp filename like + * `morphe-patches-1.13.0.mpp` or `morphe-patches-1.13.0-dev.5.mpp`. + * Returns the bare version string (`1.13.0` / `1.13.0-dev.5`) or null + * when no version-shaped token is present. + */ + fun extractPatchesVersion(patchesFileName: String): String? = + patchesVersionRegex.find(patchesFileName)?.groupValues?.get(1) + + /** + * Resolve the human-friendly app label from an APK file via ARSCLib + * (engine [ApkManifestReader]). Returns null when: + * - the manifest can't be read at all (corrupt APK) + * - the manifest has no label + * - the label is stored as a resource reference (`@string/app_name`) + * instead of a literal string — common for big apps. Callers should + * fall back to a supported-apps lookup or filename in that case. + */ + fun resolveAppDisplayName(apkFile: File): String? = + ApkManifestReader.read(apkFile)?.applicationLabel?.takeIf { it.isNotBlank() } + + /** + * Compute the unified output APK path. Layout: + * `//-Morphe-{apkVer}-patches-{patchesVer}.apk` + * + * - Per-app subfolder prevents collisions when patching different APK + * versions of the same package + * - Both versions encoded in the filename so the output is self-describing + * - `patchesFile` is optional; if null, no `-patches-{ver}` suffix is added + * + * @param inputApk the APK being patched. Its parent directory is the + * default base unless [baseOutputDir] is provided. + * @param patchesFile primary `.mpp` file. Used only for the suffix — + * in multi-source mode pass any one of the bundles. + * @param baseOutputDir override for the base directory (e.g. the GUI's + * configured default output directory). Defaults to + * `inputApk.parentFile`. + * @param appDisplayName Pre-resolved app label (e.g. "Youtube"). If null, + * falls back to the input APK's filename without + * extension. GUI callers pass the value from their + * apkInfo; the CLI can call [resolveAppDisplayName] + * to populate this. + */ + fun outputApkPath( + inputApk: File, + patchesFile: File? = null, + baseOutputDir: File? = null, + appDisplayName: String? = null, + ): File { + val appFolderName = (appDisplayName ?: inputApk.nameWithoutExtension) + .replace(" ", "-") + val base = baseOutputDir + ?: inputApk.absoluteFile.parentFile + ?: File("").absoluteFile + val outputDir = File(base, appFolderName).also { it.mkdirs() } + val version = extractApkVersionFromFilename(inputApk.name) ?: "patched" + val patchesVersion = patchesFile?.name?.let { extractPatchesVersion(it) } + val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" + return File(outputDir, "${appFolderName}-Morphe-${version}${patchesSuffix}.apk") + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/PortablePaths.kt b/src/main/kotlin/app/morphe/engine/util/PortablePaths.kt new file mode 100644 index 00000000..e3311f03 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/PortablePaths.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import app.morphe.engine.MorpheData +import java.io.File + +/** + * Two-way conversion between **stored** path strings (what lives in + * `config.json`) and **live** [File] instances (what the rest of the app + * works with). + * + * The goal: paths picked by the user that live **inside the bundle** (the + * JAR's containing directory and anything below it) are stored relative to + * [MorpheData.bundleRoot], so the entire bundle — JAR + `morphe-data/` + + * any sibling folders the user chose for output/keystore — can be moved to + * a different location (USB stick, Desktop → Documents, another machine) + * without breaking the config. Paths picked outside the bundle are stored + * absolute as before because there's no useful anchor for them. + * + * Read sites must go through [resolve]; write sites must go through + * [storableForm]. Bypassing either side breaks portability silently. + */ +object PortablePaths { + + /** + * Convert a user-picked absolute path to the form we want to persist in + * config. Returns the **anchor-relative** path string when [absolutePath] + * lives at or below [MorpheData.bundleRoot]; otherwise returns + * [absolutePath] unchanged. + * + * Falls through to the absolute form in fallback mode + * ([MorpheData.bundleRoot] == null) since there's no portable bundle to + * anchor against. + */ + fun storableForm(absolutePath: String): String { + val anchor = MorpheData.bundleRoot?.canonicalFile ?: return absolutePath + val target = try { + File(absolutePath).canonicalFile + } catch (e: Exception) { + return absolutePath + } + return if (target.startsWith(anchor)) { + // `relativeTo` is the inverse of File(anchor, x). Returns "" when + // target == anchor — fall back to absolute in that edge case to + // avoid storing an empty string that would round-trip to anchor. + val rel = target.relativeTo(anchor).path + if (rel.isEmpty()) absolutePath else rel + } else { + absolutePath + } + } + + /** + * Convert a stored path string back to a live [File]. Absolute paths + * pass through unchanged; relative paths are resolved against + * [MorpheData.bundleRoot] (NOT against the JVM's working directory, + * which would be unstable — the user can launch the JAR from anywhere). + */ + fun resolve(stored: String): File { + val f = File(stored) + if (f.isAbsolute) return f + val anchor = MorpheData.bundleRoot ?: return f.absoluteFile + return File(anchor, stored) + } +} diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index 4ccd9d25..1fba76c6 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -51,6 +51,21 @@ val LocalModeState = staticCompositionLocalOf { error("No ModeState provided") } +/** + * Auto-start ADB preference. Exposed as a composition local so the + * SettingsDialog (writer) and DeviceIndicator + install buttons (readers) + * can react without prop-drilling through Voyager screens. App-level + * lifecycle (start/stop the daemon when this flips) is handled in [App.kt]. + */ +data class AdbPreferenceState( + val enabled: Boolean, + val onChange: (Boolean) -> Unit, +) + +val LocalAdbPreference = staticCompositionLocalOf { + error("No AdbPreferenceState provided") +} + @Composable fun App( initialSimplifiedMode: Boolean = true @@ -76,6 +91,7 @@ private fun AppContent( var themePreference by remember { mutableStateOf(ThemePreference.SYSTEM) } var isSimplifiedMode by remember { mutableStateOf(initialSimplifiedMode) } + var autoStartAdb by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(true) } // Initialize PatchSourceManager and load config on startup @@ -84,6 +100,7 @@ private fun AppContent( val config = configRepository.loadConfig() themePreference = config.getThemePreference() isSimplifiedMode = config.useSimplifiedMode + autoStartAdb = config.autoStartAdb isLoading = false } @@ -105,6 +122,23 @@ private fun AppContent( } } + // Callback for the auto-start ADB toggle. Persists the preference AND + // applies the change immediately: ON spins up DeviceMonitor (which + // explicitly start-server's adb and records ownership); OFF cancels + // polling and kill-server's the daemon if Morphe owns it. + val onAutoStartAdbChange: (Boolean) -> Unit = { enabled -> + autoStartAdb = enabled + scope.launch { + configRepository.setAutoStartAdb(enabled) + if (enabled) { + DeviceMonitor.startMonitoring() + } else { + DeviceMonitor.stopMonitoringAndKillIfOwned() + } + Logger.info("Auto-start ADB ${if (enabled) "enabled" else "disabled"}") + } + } + val themeState = ThemeState( current = themePreference, onChange = onThemeChange @@ -115,9 +149,24 @@ private fun AppContent( onChange = onModeChange ) - // Start/stop DeviceMonitor with app lifecycle + val adbPreferenceState = AdbPreferenceState( + enabled = autoStartAdb, + onChange = onAutoStartAdbChange + ) + + // Initial DeviceMonitor start. Gated on autoStartAdb so users who left + // the toggle OFF don't spawn an unwanted adb daemon at launch. Runs once + // after config finishes loading. Subsequent live toggles go through + // [onAutoStartAdbChange], not this effect. + LaunchedEffect(isLoading, autoStartAdb) { + if (!isLoading && autoStartAdb) { + DeviceMonitor.startMonitoring() + } + } + // On Compose teardown (window close → exitApplication), cancel polling. + // The kill-if-owned half runs from the JVM shutdown hook in [GuiMain.kt] + // so it works even when the user quits via Cmd+Q without disposing. DisposableEffect(Unit) { - DeviceMonitor.startMonitoring() onDispose { DeviceMonitor.stopMonitoring() } @@ -126,7 +175,8 @@ private fun AppContent( MorpheTheme(themePreference = themePreference) { CompositionLocalProvider( LocalThemeState provides themeState, - LocalModeState provides modeState + LocalModeState provides modeState, + LocalAdbPreference provides adbPreferenceState ) { // Tint the OS title bar (Windows DWM caption color, macOS traffic // light contrast) to match the active theme's surface color. diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt index 002e2c9a..6e1b77cd 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import app.morphe.gui.data.model.AppConfig +import app.morphe.gui.util.DeviceMonitor +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import org.jetbrains.skia.Image import app.morphe.gui.util.FileUtils @@ -34,6 +36,18 @@ fun launchGui(args: Array) = application { else -> loadConfigSync().useSimplifiedMode } + // Belt-and-braces: on any JVM-normal exit path (window close, Cmd+Q, + // SIGTERM), kill the ADB daemon if Morphe spawned it. Compose's + // DisposableEffect already cancels polling; this hook covers shutdown + // routes where Compose teardown doesn't reach the suspend kill call. + remember { + Runtime.getRuntime().addShutdownHook(Thread { + runCatching { + runBlocking { DeviceMonitor.stopMonitoringAndKillIfOwned() } + } + }) + } + val windowState = rememberWindowState( size = DpSize(1024.dp, 768.dp), position = WindowPosition(Alignment.Center) 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 001ccf85..4ef8ed1a 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -7,9 +7,11 @@ package app.morphe.gui.data.model import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD +import app.morphe.engine.util.PortablePaths import app.morphe.gui.ui.theme.ThemePreference import app.morphe.gui.util.FileUtils.ANDROID_ARCHITECTURES import kotlinx.serialization.Serializable +import java.io.File /** * Application configuration stored in config.json @@ -27,7 +29,20 @@ val DEFAULT_PATCH_SOURCE = PatchSource( data class AppConfig( val themePreference: String = ThemePreference.SYSTEM.name, val lastCliVersion: String? = null, + /** + * LEGACY single-source version pin. Kept only for one-version migration into + * [lastPatchesVersionBySource] — read it on first load if the map is empty, + * then phase out. Do not read this directly anywhere new — go through + * [ConfigRepository.getLastPatchesVersionsBySource]. + */ val lastPatchesVersion: String? = null, + /** + * Per-source version pin: sourceId → release tag. Absence of a key means + * "no pin — use that source's latest stable". Replaces the legacy single + * [lastPatchesVersion] which silently contaminated other sources whose tag + * names happened to overlap. + */ + val lastPatchesVersionBySource: Map = emptyMap(), val preferredPatchChannel: String = PatchChannel.STABLE.name, val defaultOutputDirectory: String? = null, val autoCleanupTempFiles: Boolean = true, // Default ON @@ -57,6 +72,16 @@ data class AppConfig( // user who swaps from a stable build to a dev build sees the right default. // Once they pick one in Settings, this flips to true and we respect their choice. val userDidChooseUpdateChannel: Boolean = false, + // One-shot dismissal flag for the "multiple sources are now active" hint shown + // after upgrading to multi-source builds. Flips to true once the user dismisses + // the banner, never resets. + val multiSourceHintDismissed: Boolean = false, + // Whether Morphe should auto-start the ADB daemon at GUI launch to monitor + // connected devices. Default OFF — many users never push patched APKs to a + // device, so spawning a long-lived adb server unprompted is unwanted noise. + // 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, ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { @@ -82,6 +107,20 @@ data class AppConfig( PatchChannel.STABLE } } + + /** + * Resolved live [File] for [defaultOutputDirectory]. Goes through + * [PortablePaths.resolve] so a stored relative value is anchored to the + * bundle, not the JVM's CWD. Use this instead of `File(...)` at call sites. + */ + fun resolvedDefaultOutputDirectory(): File? = + defaultOutputDirectory?.let(PortablePaths::resolve) + + /** + * Resolved live [File] for [keystorePath]. See [resolvedDefaultOutputDirectory]. + */ + fun resolvedKeystorePath(): File? = + keystorePath?.let(PortablePaths::resolve) } @Serializable @@ -89,14 +128,19 @@ data class PatchSource ( val id: String, val name: String, val type: PatchSourceType, - val url: String? = null, // For DEFAULT (morphe) and GITHUB (other source) type + // For DEFAULT (morphe), GITHUB and GITLAB sources: the canonical + // "https://{host}/{owner}/{repo}" URL. + val url: String? = null, val filePath: String? = null, // For local files - val deletable: Boolean = true + val deletable: Boolean = true, + // Multi-source enablement. Default true so old configs migrate to "all enabled" + // on first load (per user choice — see project memory). + val enabled: Boolean = true, ) @Serializable enum class PatchSourceType{ - DEFAULT, GITHUB, LOCAL + DEFAULT, GITHUB, GITLAB, LOCAL } enum class PatchChannel { 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 87e83811..e2769a68 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -84,7 +84,8 @@ enum class PatchOptionType { data class PatchConfig( val inputApkPath: String, val outputApkPath: String, - val patchesFilePath: String, + /** One or more .mpp file paths. Multiple = union of patches across sources. */ + val patchesFilePaths: List, val enabledPatches: List = emptyList(), val disabledPatches: List = emptyList(), val patchOptions: Map = emptyMap(), diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt new file mode 100644 index 00000000..235e2706 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.data.repository + +import app.morphe.engine.MorpheData +import app.morphe.gui.util.Logger +import java.io.File +import java.nio.file.Paths + +/** + * One-time migration of GUI persisted state from the legacy per-OS app-data + * folder to the unified `morphe-data/` introduced by the data-location refactor. + * + * Migrates: + * - `config.json` — GUI preferences (theme, enabled patch sources, etc.) + * - `patch-preferences.json` — per-app/per-source saved patch selections + * (the "Your Defaults" data shown on the patches screen) + * + * Behavior (per file): + * 1. If the new file already exists → no-op (assume migrated / fresh install). + * 2. If there's no legacy file either → no-op (genuine fresh install). + * 3. If only the legacy file exists → COPY it (don't move) to the new + * location. The old file stays in place as a safety net; users can + * delete it manually once they've verified the new build works. + * + * Lives outside ConfigRepository so the migration logic is self-contained + * and easy to delete in a future release once enough users have upgraded. + */ +object ConfigMigration { + + private const val APP_NAME = "morphe-gui" + + /** + * Run the migration. Idempotent — safe to call on every app launch. + * Called from ConfigRepository.loadConfig before the existing read. + */ + fun runIfNeeded() { + // config.json → morphe-data/config.json + migrateFileIfNeeded( + legacyFileName = "config.json", + newFile = MorpheData.configFile, + ) + // patch-preferences.json → morphe-data/patch-preferences.json + // Owned by PatchPreferencesRepository — same legacy dir, same name. + // Without this, users lose all saved per-app patch selections on + // first launch after the upgrade. + migrateFileIfNeeded( + legacyFileName = "patch-preferences.json", + newFile = File(MorpheData.root, "patch-preferences.json"), + ) + } + + /** + * Generic per-file migration. Looks for [legacyFileName] inside the + * platform's legacy app-data folder; if found AND the new location is + * empty, copies the file across. + */ + private fun migrateFileIfNeeded(legacyFileName: String, newFile: File) { + if (newFile.exists()) return // already migrated or new install + + val legacyDir = legacyAppDataDir() ?: return + val legacyFile = File(legacyDir, legacyFileName) + if (!legacyFile.exists()) return // nothing to migrate + + try { + // Copy, NOT move — paranoid first release. If anything goes wrong + // with the new path, the user's old file is intact. We can + // tighten this to a move in a future release once stability is + // proven. + newFile.parentFile?.mkdirs() + legacyFile.copyTo(newFile, overwrite = false) + Logger.info( + "Migrated $legacyFileName from ${legacyFile.absolutePath} " + + "to ${newFile.absolutePath} (old file preserved as backup)" + ) + } catch (e: Exception) { + // Non-fatal: if migration fails, we proceed without the file + // and the user falls back to defaults / re-configures. Better + // than crashing on startup over a copy that didn't work. + Logger.warn( + "Could not migrate legacy $legacyFileName from ${legacyFile.absolutePath}: ${e.message}" + ) + } + } + + /** + * Where the GUI used to put its persisted files before the unified-data + * refactor. Returns null on unrecognized platforms (in which case there's + * nothing to migrate from). + */ + private fun legacyAppDataDir(): File? { + val osName = System.getProperty("os.name").lowercase() + val userHome = System.getProperty("user.home") + + return when { + osName.contains("win") -> { + val appData = System.getenv("APPDATA") + ?: Paths.get(userHome, "AppData", "Roaming").toString() + File(appData, APP_NAME) + } + osName.contains("mac") -> { + File(userHome, "Library/Application Support/$APP_NAME") + } + "linux" in osName || "nix" in osName || "nux" in osName -> { + File(userHome, ".config/$APP_NAME") + } + else -> null + } + } +} 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 5133a440..a0477ffa 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -5,6 +5,7 @@ package app.morphe.gui.data.repository +import app.morphe.engine.util.PortablePaths import app.morphe.gui.data.model.AppConfig import app.morphe.gui.data.model.DEFAULT_PATCH_SOURCE import app.morphe.gui.data.model.PatchChannel @@ -36,6 +37,10 @@ class ConfigRepository { suspend fun loadConfig(): AppConfig = withContext(Dispatchers.IO) { cachedConfig?.let { return@withContext it } + // One-time migration from the legacy per-OS app-data path to the + // unified morphe-data location. Runs once and is a no-op thereafter. + ConfigMigration.runIfNeeded() + val configFile = FileUtils.getConfigFile() try { @@ -97,13 +102,43 @@ class ConfigRepository { } /** - * Update last used patches version. + * LEGACY — kept so single-source callers don't break during the multi-source + * transition. New code should use [setLastPatchesVersionForSource]. */ + @Deprecated("Use setLastPatchesVersionForSource", ReplaceWith("setLastPatchesVersionForSource(sourceId, version)")) suspend fun setLastPatchesVersion(version: String) { val current = loadConfig() saveConfig(current.copy(lastPatchesVersion = version)) } + /** + * Pin a specific release tag for [sourceId]. Used by PatchesScreen when the + * user picks a version. Per-source = no cross-contamination across sources + * with overlapping tag names. + */ + suspend fun setLastPatchesVersionForSource(sourceId: String, version: String) { + val current = loadConfig() + val updated = current.lastPatchesVersionBySource + (sourceId to version) + saveConfig(current.copy(lastPatchesVersionBySource = updated)) + } + + /** + * Returns the per-source version pin map, with one-time migration from the + * legacy [AppConfig.lastPatchesVersion] field: if the map is empty and the + * legacy field is set, it's mapped to the default source. + */ + suspend fun getLastPatchesVersionsBySource(): Map { + val current = loadConfig() + if (current.lastPatchesVersionBySource.isNotEmpty()) { + return current.lastPatchesVersionBySource + } + val legacy = current.lastPatchesVersion ?: return emptyMap() + // Migrate: write the legacy pin onto the default source, return the new map. + val migrated = mapOf(DEFAULT_PATCH_SOURCE.id to legacy) + saveConfig(current.copy(lastPatchesVersionBySource = migrated)) + return migrated + } + /** * Mark the given CLI version as dismissed for the update banner. Pass null to * clear (so the banner reappears for whatever the next-available version is). @@ -158,7 +193,7 @@ class ConfigRepository { */ suspend fun setDefaultOutputDirectory(path: String?) { val current = loadConfig() - saveConfig(current.copy(defaultOutputDirectory = path)) + saveConfig(current.copy(defaultOutputDirectory = path?.let(PortablePaths::storableForm))) } /** @@ -200,7 +235,7 @@ class ConfigRepository { */ suspend fun setKeystorePath(path: String?) { val current = loadConfig() - saveConfig(current.copy(keystorePath = path)) + saveConfig(current.copy(keystorePath = path?.let(PortablePaths::storableForm))) } /** @@ -214,7 +249,7 @@ class ConfigRepository { ) { val current = loadConfig() saveConfig(current.copy( - keystorePath = path, + keystorePath = path?.let(PortablePaths::storableForm), keystorePassword = password, keystoreAlias = alias, keystoreEntryPassword = entryPassword @@ -261,6 +296,52 @@ class ConfigRepository { saveConfig(current.copy(patchSource = updatedSources)) } + /** + * Update whether Morphe auto-starts the ADB daemon at GUI launch. + */ + suspend fun setAutoStartAdb(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(autoStartAdb = enabled)) + } + + /** + * Mark the multi-source upgrade hint as dismissed. One-shot — never resets. + */ + suspend fun setMultiSourceHintDismissed() { + val current = loadConfig() + if (current.multiSourceHintDismissed) return + saveConfig(current.copy(multiSourceHintDismissed = true)) + } + + /** + * Toggle enablement of a patch source. Safety net: if disabling would leave zero + * enabled sources, the default source is force-enabled (mirrors morphe-manager + * SourceManagementSheet.kt:142-149 LaunchedEffect). + */ + suspend fun setPatchSourceEnabled(id: String, enabled: Boolean) { + val current = loadConfig() + val updatedSources = current.patchSource.map { + if (it.id == id) it.copy(enabled = enabled) else it + } + val anyEnabled = updatedSources.any { it.enabled } + val finalSources = if (!anyEnabled) { + // Safety net: force-enable the default + updatedSources.map { + if (it.id == DEFAULT_PATCH_SOURCE.id) it.copy(enabled = true) else it + } + } else { + updatedSources + } + saveConfig(current.copy(patchSource = finalSources)) + } + + /** + * Get the list of currently enabled patch sources (in config order). + */ + suspend fun getEnabledPatchSources(): List { + return loadConfig().patchSource.filter { it.enabled } + } + /** * Remove a patch source by ID. Cannot remove non-deletable sources. */ diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt index fcd03087..fcfc1fa4 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -5,180 +5,155 @@ package app.morphe.gui.data.repository -import app.morphe.gui.data.model.Release -import app.morphe.gui.data.model.ReleaseAsset -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import app.morphe.engine.patches.RemotePatchSource +import app.morphe.engine.patches.findPatchAsset import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File /** - * Repository for fetching patches from GitHub releases. - * @param repoPath GitHub repo in "owner/repo" format (e.g. "MorpheApp/morphe-patches") + * GUI-side wrapper around an engine [RemotePatchSource]. Adds: + * - 5-minute in-memory TTL on the release listing (so repeated UI calls + * don't re-hit the API every time) + * - Disk cache for downloaded .mpp files keyed by source's repoPath + * - Filter helpers (stable/dev) and cache lookup helpers tailored to the + * GUI's needs + * + * The remote provider logic itself (URL construction, HTTP, JSON shape) is + * NOT here — it lives in the engine. This class is purely a caching + + * convenience layer. */ class PatchRepository( - private val httpClient: HttpClient, - private val repoPath: String = DEFAULT_REPO + private val remoteSource: RemotePatchSource, ) { + val repoPath: String get() = remoteSource.repoPath + companion object { - private const val GITHUB_API_BASE = "https://api.github.com" - private const val DEFAULT_REPO = "MorpheApp/morphe-patches" private const val CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes - } - private val releasesEndpoint = "$GITHUB_API_BASE/repos/$repoPath/releases" + /** + * Per-release filename used in the disk cache. + * + * Many patch source maintainers (including MorpheApp/morphe-patches) + * name their `.mpp` release asset the SAME string across versions, + * e.g. `morphe-patches.mpp`. Storing them by their bare asset name + * means each new download overwrites the previous version — only ONE + * file ever lives in the cache. Worse, the size-match check made + * `checkCachedPatches` return a "hit" for the latest version (whose + * size happened to match the on-disk file) while older versions + * correctly returned a miss — so the patches-screen UI showed + * SELECT for the latest and DOWNLOAD for everything else, even + * right after a Clear Cache. + * + * Prepending the release tag (`v1.5.0__morphe-patches.mpp`) gives + * each version its own file. Cache hits are now per-version exactly. + * The double-underscore is a deliberate visual delimiter — easier + * to eyeball when grepping the cache directory than a single dash. + */ + fun cachedFileName(release: Release, asset: ReleaseAsset): String = + "${release.tagName}__${asset.name}" + } - // In-memory cache so multiple callers (both modes) don't re-fetch from GitHub + // In-memory cache so multiple callers don't re-fetch from the remote API private var cachedReleases: List? = null private var cacheTimestamp: Long = 0L /** - * Fetch all releases from GitHub. Returns cached result if still fresh. + * Fetch all releases. Returns cached result if still fresh. * @param forceRefresh bypass the in-memory cache */ - suspend fun fetchReleases(forceRefresh: Boolean = false): Result> = withContext(Dispatchers.IO) { - // Return cached releases if still fresh - val cached = cachedReleases - if (!forceRefresh && cached != null && (System.currentTimeMillis() - cacheTimestamp) < CACHE_TTL_MS) { - Logger.info("Using cached releases (${cached.size} releases, age=${(System.currentTimeMillis() - cacheTimestamp) / 1000}s)") - return@withContext Result.success(cached) - } - - try { - Logger.info("Fetching releases from $releasesEndpoint") - val response: HttpResponse = httpClient.get(releasesEndpoint) { - headers { - append(HttpHeaders.Accept, "application/vnd.github+json") - append("X-GitHub-Api-Version", "2022-11-28") - } + suspend fun fetchReleases(forceRefresh: Boolean = false): Result> = + withContext(Dispatchers.IO) { + val cached = cachedReleases + if (!forceRefresh && cached != null && + (System.currentTimeMillis() - cacheTimestamp) < CACHE_TTL_MS + ) { + Logger.info("Using cached releases (${cached.size} releases, age=${(System.currentTimeMillis() - cacheTimestamp) / 1000}s)") + return@withContext Result.success(cached) } - if (response.status.isSuccess()) { - val releases: List = response.body() - Logger.info("Fetched ${releases.size} releases from $releasesEndpoint") - cachedReleases = releases + val result = remoteSource.listReleases() + result.onSuccess { fresh -> + cachedReleases = fresh cacheTimestamp = System.currentTimeMillis() - Result.success(releases) - } else { - val error = "Failed to fetch releases: ${response.status}" - Logger.error(error) - Result.failure(Exception(error)) } - } catch (e: Exception) { - Logger.error("Error fetching releases", e) - // If we have stale cached data, return it rather than failing - val stale = cachedReleases - if (stale != null) { - Logger.info("Returning stale cached releases after fetch failure") - Result.success(stale) - } else { - Result.failure(e) + // If fetch failed but we still have stale data, prefer the stale + // data over a hard error. Matches the previous behavior — keeps + // offline / flaky-network sessions usable. + if (result.isFailure) { + val stale = cachedReleases + if (stale != null) { + Logger.info("Returning stale cached releases after fetch failure") + return@withContext Result.success(stale) + } } + result } - } - /** - * Get stable releases only (non-prerelease). - */ - suspend fun fetchStableReleases(): Result> { - return fetchReleases().map { releases -> - releases.filter { !it.isDevRelease() } - } - } + /** Stable releases only (non-prerelease). */ + suspend fun fetchStableReleases(): Result> = + fetchReleases().map { releases -> releases.filter { !it.isDevRelease() } } - /** - * Get dev/prerelease versions only. - */ - suspend fun fetchDevReleases(): Result> { - return fetchReleases().map { releases -> - releases.filter { it.isDevRelease() } - } - } + /** Dev / prerelease versions only. */ + suspend fun fetchDevReleases(): Result> = + fetchReleases().map { releases -> releases.filter { it.isDevRelease() } } - /** - * Get the latest stable release. - */ - suspend fun getLatestStableRelease(): Result { - return fetchStableReleases().map { it.firstOrNull() } - } + suspend fun getLatestStableRelease(): Result = + fetchStableReleases().map { it.firstOrNull() } - /** - * Get the latest dev release. - */ - suspend fun getLatestDevRelease(): Result { - return fetchDevReleases().map { it.firstOrNull() } - } + suspend fun getLatestDevRelease(): Result = + fetchDevReleases().map { it.firstOrNull() } - /** - * Find the patch .mpp asset in a release. - */ - fun findPatchAsset(release: Release): ReleaseAsset? { - return release.assets.find { it.isPatchFile() } - } + /** Find the patch .mpp asset in a release. */ + fun findPatchAsset(release: Release): ReleaseAsset? = release.findPatchAsset() /** - * Download the patch .mpp file from a release. - * Returns the path to the downloaded file. + * Download the patch .mpp file from a release. Handles the disk cache — + * if a matching file is already present, skips the network call entirely. */ - suspend fun downloadPatches(release: Release, onProgress: (Float) -> Unit = {}): Result = withContext(Dispatchers.IO) { - val asset = findPatchAsset(release) - if (asset == null) { - val error = "No .mpp patch files found in release ${release.tagName}" - Logger.error(error) - return@withContext Result.failure(Exception(error)) - } + suspend fun downloadPatches( + release: Release, + onProgress: (Float) -> Unit = {}, + ): Result = withContext(Dispatchers.IO) { + val asset = release.findPatchAsset() + ?: return@withContext Result.failure( + Exception("No .mpp patch files found in release ${release.tagName}") + ) val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) patchesDir.mkdirs() - val targetFile = File(patchesDir, asset.name) - - // Check if already cached - if (targetFile.exists() && targetFile.length() == asset.size) { - Logger.info("Using cached patches: ${targetFile.absolutePath}") + val targetFile = File(patchesDir, cachedFileName(release, asset)) + + // Cache hit rules: + // - If we know the asset's expected size (GitHub provides it), + // the cached file must match exactly. + // - If size is unknown (some GitLab cases), fall back to "file + // exists and is non-empty". A zero-byte file is always treated + // as a miss so a previously-failed download doesn't masquerade + // as a cache hit. + val isCached = when { + !targetFile.exists() -> false + targetFile.length() == 0L -> false + asset.size > 0L -> targetFile.length() == asset.size + else -> true + } + if (isCached) { + Logger.info("Using cached patches: ${targetFile.absolutePath} (${targetFile.length()} bytes)") onProgress(1f) return@withContext Result.success(targetFile) } - try { - Logger.info("Downloading patches from ${asset.downloadUrl}") - - val response: HttpResponse = httpClient.get(asset.downloadUrl) { - headers { - append(HttpHeaders.Accept, "application/octet-stream") - } - } - - if (!response.status.isSuccess()) { - val error = "Failed to download patches: ${response.status}" - Logger.error(error) - return@withContext Result.failure(Exception(error)) - } - - val bytes = response.readRawBytes() - targetFile.writeBytes(bytes) - onProgress(1f) - - Logger.info("Patches downloaded to ${targetFile.absolutePath}") - Result.success(targetFile) - } catch (e: Exception) { - Logger.error("Error downloading patches", e) - // Clean up partial download - if (targetFile.exists()) { - targetFile.delete() - } - Result.failure(e) - } + // Delegate the actual network IO to the engine source. + val result = remoteSource.downloadAsset(asset, targetFile) + if (result.isSuccess) onProgress(1f) + result } - /** - * Get cached patch file for a specific version. - */ + /** Get cached patch file for a specific version. */ fun getCachedPatches(version: String): File? { val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) return patchesDir.listFiles()?.find { @@ -186,30 +161,23 @@ class PatchRepository( } } - private fun isPatchFileName(name: String): Boolean { - return name.endsWith(".mpp", ignoreCase = true) - } + private fun isPatchFileName(name: String): Boolean = + name.endsWith(".mpp", ignoreCase = true) - /** - * List all cached patch versions. - */ + /** List all cached patch versions. */ fun listCachedPatches(): List { val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) return patchesDir.listFiles()?.filter { isPatchFileName(it.name) } ?: emptyList() } - /** - * Get the per-source cache directory for this repository. - */ + /** Get the per-source cache directory for this repository. */ fun getCacheDir(): File { val dir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) dir.mkdirs() return dir } - /** - * Delete cached patches. - */ + /** Delete cached patches (both in-memory release list and on-disk files). */ fun clearCache(): Boolean { cachedReleases = null cacheTimestamp = 0L 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 0a540b03..64e2e5d5 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt @@ -5,6 +5,8 @@ package app.morphe.gui.data.repository +import app.morphe.engine.patches.PatchProvider +import app.morphe.engine.patches.RemotePatchSourceFactory import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.util.Logger @@ -28,10 +30,23 @@ class PatchSourceManager( private var cachedActiveRepo: PatchRepository? = null private var cachedActiveSource: PatchSource? = null - // Incremented on every source switch so Compose can key on it + // Snapshot of currently-enabled sources for sync access. Updated on initialize() + // and whenever setSourceEnabled / addSource / removeSource fires. + private var cachedEnabledSources: List = emptyList() + + // Incremented on every source switch / enable change so Compose can key on it private val _sourceVersion = MutableStateFlow(0) val sourceVersion: StateFlow = _sourceVersion.asStateFlow() + // Observable list of enabled sources for UI + private val _enabledSources = MutableStateFlow>(emptyList()) + val enabledSources: StateFlow> = _enabledSources.asStateFlow() + + // Observable list of ALL sources (enabled + disabled) — drives the + // SourceManagementSheet which needs to render every source with a toggle. + private val _allSources = MutableStateFlow>(emptyList()) + val allSources: StateFlow> = _allSources.asStateFlow() + /** * Load the active source from config and cache its PatchRepository. * Call once at app startup (from a LaunchedEffect). @@ -40,7 +55,9 @@ class PatchSourceManager( val source = configRepository.getActivePatchSource() cachedActiveSource = source cachedActiveRepo = getRepositoryForSource(source) + refreshEnabledSources() Logger.info("PatchSourceManager initialized with source '${source.name}' (type=${source.type})") + Logger.info("Enabled sources: ${cachedEnabledSources.joinToString { it.name }}") } /** @@ -90,11 +107,25 @@ class PatchSourceManager( * Falls back to default repo if not yet initialized and source is not LOCAL. */ fun getActiveRepositorySync(): PatchRepository { - return cachedActiveRepo ?: PatchRepository(httpClient).also { + return cachedActiveRepo ?: defaultMorpheRepository().also { if (!isLocalSource()) cachedActiveRepo = it } } + /** + * Build the fallback PatchRepository pointed at the built-in Morphe + * repo (`MorpheApp/morphe-patches` on GitHub). Used when the active + * source isn't yet known. + */ + private fun defaultMorpheRepository(): PatchRepository { + val remote = RemotePatchSourceFactory.build( + PatchProvider.GITHUB, + "MorpheApp/morphe-patches", + httpClient, + ) + return PatchRepository(remote) + } + /** * Get the PatchRepository for the currently active source (suspend version). * For LOCAL sources, returns null (caller should use the file path directly). @@ -106,15 +137,22 @@ class PatchSourceManager( /** * Get the PatchRepository for a specific source. - * Returns null for LOCAL sources (no GitHub API needed). + * Returns null for LOCAL sources (no remote API needed). */ fun getRepositoryForSource(source: PatchSource): PatchRepository? { if (source.type == PatchSourceType.LOCAL) return null return repositories.getOrPut(source.id) { val repoPath = extractRepoPath(source) - Logger.info("Creating PatchRepository for source '${source.name}' (repo=$repoPath)") - PatchRepository(httpClient, repoPath) + // Map the GUI's persisted source type to the engine's provider + // enum. DEFAULT inherits GitHub (Morphe Patches lives there). + val provider = when (source.type) { + PatchSourceType.GITLAB -> PatchProvider.GITLAB + else -> PatchProvider.GITHUB + } + Logger.info("Creating PatchRepository for source '${source.name}' (repo=$repoPath, provider=$provider)") + val remote = RemotePatchSourceFactory.build(provider, repoPath, httpClient) + PatchRepository(remote) } } @@ -126,14 +164,17 @@ class PatchSourceManager( } /** - * Extract "owner/repo" from a PatchSource's URL. - * e.g. "https://github.com/MorpheApp/morphe-patches" -> "MorpheApp/morphe-patches" + * Extract "owner/repo" from a PatchSource's URL. Works for both GitHub + * and GitLab hosts. Falls back to the built-in default repo when no URL + * is configured (e.g. for the DEFAULT source on first launch). */ private fun extractRepoPath(source: PatchSource): String { val url = source.url ?: return "MorpheApp/morphe-patches" return url .removePrefix("https://github.com/") .removePrefix("http://github.com/") + .removePrefix("https://gitlab.com/") + .removePrefix("http://gitlab.com/") .removeSuffix("/") .removeSuffix(".git") } @@ -153,4 +194,69 @@ class PatchSourceManager( cachedActiveRepo?.clearCache() _sourceVersion.value++ } + + // ── Multi-source API ────────────────────────────────────────────────────── + + /** + * Snapshot of currently-enabled sources, in config order. Synchronous. + */ + fun getEnabledSourcesSync(): List = cachedEnabledSources + + /** + * Pair each enabled source with its [PatchRepository]. The repo is null for LOCAL + * sources — callers should use [PatchSource.filePath] directly in that case. + */ + fun getEnabledRepositories(): List> = + cachedEnabledSources.map { it to getRepositoryForSource(it) } + + /** + * Toggle enablement of a source. Persists, refreshes the cached snapshot, and + * bumps [sourceVersion] so consumers reload. Default-source safety net is + * applied at the [ConfigRepository] layer. + */ + suspend fun setSourceEnabled(id: String, enabled: Boolean) { + configRepository.setPatchSourceEnabled(id, enabled) + refreshEnabledSources() + _sourceVersion.value++ + Logger.info("Source '$id' enabled=$enabled. Enabled now: ${cachedEnabledSources.joinToString { it.name }}") + } + + /** + * Add a new source. Persists and refreshes the cached snapshot. + */ + suspend fun addSource(source: PatchSource) { + configRepository.addPatchSource(source) + refreshEnabledSources() + _sourceVersion.value++ + } + + /** + * Remove a source by id. Refuses non-deletable (default) sources. Drops the + * cached repo for that id so a re-add doesn't reuse stale state. + */ + suspend fun removeSource(id: String) { + configRepository.removePatchSource(id) + repositories.remove(id) + refreshEnabledSources() + _sourceVersion.value++ + } + + /** + * Update an existing source (e.g. rename). Refuses non-deletable sources. + */ + suspend fun updateSource(updated: PatchSource) { + configRepository.updatePatchSource(updated) + // Drop the cached repo so the new url/name is picked up on next access. + repositories.remove(updated.id) + refreshEnabledSources() + _sourceVersion.value++ + } + + private suspend fun refreshEnabledSources() { + val all = configRepository.loadConfig().patchSource + val enabled = all.filter { it.enabled } + cachedEnabledSources = enabled + _enabledSources.value = enabled + _allSources.value = all + } } diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index ce47921d..9c2a68f5 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -93,7 +93,9 @@ val appModule = module { get(), get(), psm.getActiveSourceName(), - psm.getLocalFilePath() + psm.getLocalFilePath(), + params.get(), + params.get(), ) } factory { params -> diff --git a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt index 1b3c0bd5..a4498f97 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.UsbOff import androidx.compose.material3.* import androidx.compose.runtime.* @@ -31,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheCorners @@ -42,8 +44,10 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current + val adbPreference = LocalAdbPreference.current val monitorState by DeviceMonitor.state.collectAsState() + val isAdbDisabledByUser = !adbPreference.enabled val isAdbAvailable = monitorState.isAdbAvailable val readyDevices = monitorState.devices.filter { it.isReady } val unauthorizedDevices = monitorState.devices.filter { it.status == DeviceStatus.UNAUTHORIZED } @@ -55,6 +59,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { val isHovered by hoverInteraction.collectIsHoveredAsState() val dotColor = when { + isAdbDisabledByUser -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) selectedDevice != null && selectedDevice.isReady -> accents.secondary unauthorizedDevices.isNotEmpty() -> accents.warning @@ -94,6 +99,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { ) val displayText = when { + isAdbDisabledByUser -> "ADB OFF" isAdbAvailable == null -> "Checking…" isAdbAvailable == false -> "No ADB" selectedDevice != null -> { @@ -110,6 +116,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { fontWeight = FontWeight.Medium, fontFamily = mono, color = when { + isAdbDisabledByUser -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) selectedDevice != null -> MaterialTheme.colorScheme.onSurface unauthorizedDevices.isNotEmpty() -> accents.warning @@ -138,6 +145,67 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) ) { when { + isAdbDisabledByUser -> { + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Column { + Text( + text = "ADB is off", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Morphe is not monitoring connected devices", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + }, + onClick = { showPopup = false } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = accents.primary + ) + Text( + text = "Enable ADB", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + fontFamily = mono, + color = accents.primary + ) + } + }, + onClick = { + adbPreference.onChange(true) + showPopup = false + } + ) + } + isAdbAvailable == false -> { DropdownMenuItem( text = { diff --git a/src/main/kotlin/app/morphe/gui/ui/components/MorpheErrorBar.kt b/src/main/kotlin/app/morphe/gui/ui/components/MorpheErrorBar.kt new file mode 100644 index 00000000..2a97d2bd --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/MorpheErrorBar.kt @@ -0,0 +1,134 @@ +/* + * 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.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.Arrangement +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont + +/** + * Cyberdeck-aesthetic error/warning bar — single-line message with an accent + * stripe and a DISMISS action. Shared across screens so error feedback looks + * identical everywhere. + * + * Intentionally uses raw Compose primitives (Box/Row/Text) instead of + * Material3 [androidx.compose.material3.SnackbarHost], because the latter + * reaches `SnackbarKt` through Compose-generated invocation paths that the + * shadow `minimize` reachability analyzer can't trace — it gets stripped and + * the GUI crashes with NoClassDefFoundError at runtime. Keeping this custom + * lets us drop the material3 minimize exclude and shrink the shadow jar. + * + * Callers control positioning via [modifier] (usually `Modifier.align(...)` + * inside a Box, plus padding). + */ +@Composable +fun MorpheErrorBar( + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + isWarning: Boolean = false, +) { + val accents = LocalMorpheAccents.current + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + + val accentColor = if (isWarning) accents.warning else MaterialTheme.colorScheme.error + val borderCol = accentColor.copy(alpha = 0.4f) + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderCol, RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .drawBehind { + drawRect( + color = accentColor, + size = Size(3.dp.toPx(), size.height) + ) + } + .padding(start = 3.dp) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(accentColor, RoundedCornerShape(1.dp)) + ) + Spacer(Modifier.width(12.dp)) + Text( + text = message, + fontFamily = mono, + fontSize = 12.sp, + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(12.dp)) + + val dismissHover = remember { MutableInteractionSource() } + val isDismissHovered by dismissHover.collectIsHoveredAsState() + val dismissBg by animateColorAsState( + if (isDismissHovered) accentColor.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .height(28.dp) + .hoverable(dismissHover) + .clip(RoundedCornerShape(corners.small)) + .background(dismissBg) + .clickable { onDismiss() } + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "DISMISS", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (isDismissHovered) accentColor + else accentColor.copy(alpha = 0.7f), + letterSpacing = 1.sp + ) + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt b/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt new file mode 100644 index 00000000..d7c295a6 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt @@ -0,0 +1,498 @@ +/* + * 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.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.LocalMorpheFont +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.util.UUID + +@Composable +internal fun AddPatchSourceDialog( + onDismiss: () -> Unit, + onAdd: (PatchSource) -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + var name by remember { mutableStateOf("") } + var sourceType by remember { mutableStateOf(PatchSourceType.GITHUB) } + var url by remember { mutableStateOf("") } + var filePath by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "ADD SOURCE", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.widthIn(min = 300.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf(PatchSourceType.GITHUB, PatchSourceType.LOCAL).forEach { type -> + val isSelected = sourceType == type + Box( + modifier = Modifier + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (isSelected) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + RoundedCornerShape(corners.small) + ) + .background( + if (isSelected) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + ) + .clickable { sourceType = type } + .padding(horizontal = 14.dp, vertical = 7.dp) + ) { + Text( + text = when (type) { + // The "REMOTE" tab covers both GitHub and + // GitLab — the resolver picks the right + // provider from the URL the user pastes. + PatchSourceType.GITHUB -> "REMOTE" + PatchSourceType.LOCAL -> "LOCAL FILE" + else -> "" + }, + fontSize = 10.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = if (isSelected) accents.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + LabeledField(label = "NAME", mono = mono) { + SlimTextField( + value = name, + onValueChange = { name = it; error = null }, + placeholder = "My Custom Patches", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + + when (sourceType) { + PatchSourceType.GITHUB -> { + LabeledField(label = "REPOSITORY URL", mono = mono) { + SlimTextField( + value = url, + onValueChange = { newUrl -> + url = newUrl + error = null + // Auto-suggest the name from the repo basename as soon as the URL + // parses cleanly exactly like the LOCAL file case which derives the name + // from the .mpp filename. It tires its best :) + if (name.isBlank()) { + suggestNameFromUrl(newUrl)?.let { name = it } + } + }, + placeholder = "github.com/owner/repo or gitlab.com/owner/repo", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "Accepts GitHub, GitLab, or morphe.software/add-source link", + fontFamily = mono, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 0.3.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + PatchSourceType.LOCAL -> { + LabeledField(label = ".MPP FILE", mono = mono) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SlimTextField( + value = filePath, + onValueChange = { filePath = it; error = null }, + placeholder = "Path to .mpp", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.weight(1f), + readOnly = true, + ) + DialogActionButton( + label = "BROWSE", + mono = mono, + corners = corners, + onClick = { + val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { + setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + filePath = File(dialog.directory, dialog.file).absolutePath + if (name.isBlank()) name = dialog.file.removeSuffix(".mpp") + error = null + } + }, + ) + } + } + } + else -> {} + } + + error?.let { + Text( + text = it, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + val dimens = LocalMorpheDimens.current + Button( + onClick = { + if (name.isBlank()) { error = "Name is required"; return@Button } + when (sourceType) { + PatchSourceType.GITHUB -> { + // sourceType is the UI's "REMOTE" mode placeholder; + // the actual provider (GITHUB vs GITLAB) is decided + // by the resolver based on the URL the user pasted. + val resolved = resolveRemoteSourceUrl(url.trim()) + if (resolved == null) { + error = "Enter a valid GitHub or GitLab URL"; return@Button + } + onAdd(PatchSource( + id = UUID.randomUUID().toString(), + name = name.trim(), + type = resolved.provider, + url = resolved.canonicalUrl, + deletable = true + )) + return@Button + } + PatchSourceType.LOCAL -> { + if (filePath.isBlank() || !File(filePath).exists()) { + error = "Select a valid .mpp file"; return@Button + } + } + else -> {} + } + onAdd(PatchSource( + id = UUID.randomUUID().toString(), + name = name.trim(), + type = sourceType, + url = null, + filePath = if (sourceType == PatchSourceType.LOCAL) filePath.trim() else null, + deletable = true + )) + }, + colors = ButtonDefaults.buttonColors(containerColor = accents.primary), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "ADD", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + }, + dismissButton = { + val dimens = LocalMorpheDimens.current + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) +} + +@Composable +internal fun EditPatchSourceDialog( + source: PatchSource, + onDismiss: () -> Unit, + onSave: (PatchSource) -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + var name by remember { mutableStateOf(source.name) } + var url by remember { mutableStateOf(source.url ?: "") } + var filePath by remember { mutableStateOf(source.filePath ?: "") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "EDIT SOURCE", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.widthIn(min = 300.dp) + ) { + Text( + text = when (source.type) { + PatchSourceType.GITHUB -> "GITHUB REPOSITORY" + PatchSourceType.LOCAL -> "LOCAL FILE" + else -> "" + }, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 1.sp + ) + + LabeledField(label = "NAME", mono = mono) { + SlimTextField( + value = name, + onValueChange = { name = it; error = null }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + + when (source.type) { + PatchSourceType.GITHUB, PatchSourceType.GITLAB -> { + LabeledField(label = "REPOSITORY URL", mono = mono) { + SlimTextField( + value = url, + onValueChange = { url = it; error = null }, + placeholder = "github.com/owner/repo or gitlab.com/owner/repo", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + } + PatchSourceType.LOCAL -> { + LabeledField(label = ".MPP FILE", mono = mono) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SlimTextField( + value = filePath, + onValueChange = { filePath = it; error = null }, + placeholder = "Path to .mpp", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.weight(1f), + readOnly = true, + ) + DialogActionButton( + label = "BROWSE", + mono = mono, + corners = corners, + onClick = { + val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { + setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + filePath = File(dialog.directory, dialog.file).absolutePath + error = null + } + }, + ) + } + } + } + else -> {} + } + + error?.let { + Text(text = it, fontSize = 11.sp, fontFamily = mono, color = MaterialTheme.colorScheme.error) + } + } + }, + confirmButton = { + val dimens = LocalMorpheDimens.current + Button( + onClick = { + if (name.isBlank()) { error = "Name is required"; return@Button } + when (source.type) { + PatchSourceType.GITHUB, PatchSourceType.GITLAB -> { + // Re-resolve on save so the user can switch hosts + // by editing the URL (e.g. github → gitlab). The + // provider type updates with the detected host. + val resolved = resolveRemoteSourceUrl(url.trim()) + if (resolved == null) { + error = "Enter a valid GitHub or GitLab URL"; return@Button + } + onSave(source.copy( + name = name.trim(), + type = resolved.provider, + url = resolved.canonicalUrl, + )) + return@Button + } + PatchSourceType.LOCAL -> { + if (filePath.isBlank() || !File(filePath).exists()) { + error = "Select a valid .mpp file"; return@Button + } + } + else -> {} + } + onSave(source.copy( + name = name.trim(), + filePath = if (source.type == PatchSourceType.LOCAL) filePath.trim() else source.filePath + )) + }, + colors = ButtonDefaults.buttonColors(containerColor = accents.primary), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "SAVE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + }, + dismissButton = { + val dimens = LocalMorpheDimens.current + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) +} + +/** + * Result of parsing a user-entered remote source URL. The detected + * [provider] is the GUI-side persisted type that will be stored on the + * [PatchSource] config (GITHUB or GITLAB only — never DEFAULT or LOCAL). + */ +internal data class ResolvedRemoteSource( + val canonicalUrl: String, + val provider: PatchSourceType, // GITHUB or GITLAB only +) + +/** + * Thin GUI-side wrapper around the engine's [RemotePatchSourceFactory.parse]. + * Returns `null` if the engine can't classify the input. The engine owns + * the actual URL-parsing logic — this function only translates the engine's + * [app.morphe.engine.patches.PatchProvider] back to the GUI's persisted + * [PatchSourceType] (which carries DEFAULT/LOCAL too). + */ +internal fun resolveRemoteSourceUrl(input: String): ResolvedRemoteSource? { + val parsed = app.morphe.engine.patches.RemotePatchSourceFactory.parse(input) ?: return null + val type = when (parsed.provider) { + app.morphe.engine.patches.PatchProvider.GITHUB -> PatchSourceType.GITHUB + app.morphe.engine.patches.PatchProvider.GITLAB -> PatchSourceType.GITLAB + } + return ResolvedRemoteSource(canonicalUrl = parsed.canonicalUrl, provider = type) +} + +/** + * Suggest a friendly source name from a typed/pasted URL — used to populate + * the NAME field while the user is filling in REPOSITORY URL, so they don't + * have to think one up themselves. Returns `/` so two sources + * with similarly-named repos (e.g. forks of `morphe-patches`) stay + * distinguishable. Returns null when the URL doesn't parse cleanly yet + * (partial typing, invalid host, etc.). + */ +private fun suggestNameFromUrl(input: String): String? { + val parsed = app.morphe.engine.patches.RemotePatchSourceFactory.parse(input) ?: return null + return parsed.repoPath.takeIf { it.isNotBlank() } +} + +// LabeledField, SlimTextField, DialogActionButton moved to SlimInputs.kt for +// reuse across the codebase (SettingsDialog uses them too). 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 042515b9..daa15b8f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -5,6 +5,7 @@ package app.morphe.gui.ui.components +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.LocalModeState import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -60,6 +61,7 @@ fun SettingsButton( val corners = LocalMorpheCorners.current val themeState = LocalThemeState.current val modeState = LocalModeState.current + val adbPreference = LocalAdbPreference.current val configRepository: ConfigRepository = koinInject() val patchSourceManager: PatchSourceManager = koinInject() val updateCheckRepository: UpdateCheckRepository = koinInject() @@ -68,8 +70,6 @@ fun SettingsButton( var showSettingsDialog by remember { mutableStateOf(false) } var autoCleanupTempFiles by remember { mutableStateOf(true) } var defaultOutputDirectory by remember { mutableStateOf(null) } - var patchSources by remember { mutableStateOf>(emptyList()) } - var activePatchSourceId by remember { mutableStateOf("") } var keystorePath by remember { mutableStateOf(null) } var keystorePassword by remember { mutableStateOf(null) } var keystoreAlias by remember { mutableStateOf(DEFAULT_KEYSTORE_ALIAS) } @@ -82,10 +82,11 @@ fun SettingsButton( if (showSettingsDialog) { val config = configRepository.loadConfig() autoCleanupTempFiles = config.autoCleanupTempFiles - defaultOutputDirectory = config.defaultOutputDirectory - patchSources = config.patchSource - activePatchSourceId = config.activePatchSourceId - keystorePath = config.keystorePath + // Display the resolved absolute form even though storage may be + // bundle-relative — users expect to see a real filesystem path in + // the field, not a cryptic basename. + defaultOutputDirectory = config.resolvedDefaultOutputDirectory()?.absolutePath + keystorePath = config.resolvedKeystorePath()?.absolutePath keystorePassword = config.keystorePassword keystoreAlias = config.keystoreAlias keystoreEntryPassword = config.keystoreEntryPassword @@ -151,43 +152,6 @@ fun SettingsButton( }, allowCacheClear = allowCacheClear, isPatching = isPatching, - patchSources = patchSources, - activePatchSourceId = activePatchSourceId, - onActivePatchSourceChange = { id -> - if (id != activePatchSourceId) { - activePatchSourceId = id - scope.launch { - withContext(NonCancellable) { - patchSourceManager.switchSource(id) - } - } - } - }, - onAddPatchSource = { source -> - patchSources = patchSources + source - scope.launch { - configRepository.addPatchSource(source) - } - }, - onEditPatchSource = { updated -> - patchSources = patchSources.map { if (it.id == updated.id) updated else it } - scope.launch { - configRepository.updatePatchSource(updated) - if (updated.id == activePatchSourceId) { - patchSourceManager.clearAll() - patchSourceManager.switchSource(updated.id) - } - } - }, - onRemovePatchSource = { id -> - patchSources = patchSources.filter { it.id != id } - if (activePatchSourceId == id) { - activePatchSourceId = "morphe-default" - } - scope.launch { - configRepository.removePatchSource(id) - } - }, onCacheCleared = { patchSourceManager.notifyCacheCleared() }, @@ -232,6 +196,8 @@ fun SettingsButton( } } }, + autoStartAdb = adbPreference.enabled, + onAutoStartAdbChange = { adbPreference.onChange(it) }, collapsibleSectionStates = collapsibleSectionStates, onCollapsibleSectionToggle = { id, expanded -> collapsibleSectionStates = collapsibleSectionStates + (id to expanded) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index 3124b946..db4e5f10 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -30,18 +30,23 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.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.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.ui.theme.ThemePreference +import app.morphe.gui.util.AdbManager +import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger +import kotlinx.coroutines.launch import app.morphe.patcher.apk.ApkSigner import java.awt.Desktop import java.awt.FileDialog @@ -91,12 +96,6 @@ fun SettingsDialog( onDismiss: () -> Unit, allowCacheClear: Boolean = true, isPatching: Boolean = false, - patchSources: List = emptyList(), - activePatchSourceId: String = "", - onActivePatchSourceChange: (String) -> Unit = {}, - onAddPatchSource: (PatchSource) -> Unit = {}, - onEditPatchSource: (PatchSource) -> Unit = {}, - onRemovePatchSource: (String) -> Unit = {}, onCacheCleared: () -> Unit = {}, keystorePath: String? = null, keystorePassword: String? = null, @@ -108,6 +107,8 @@ fun SettingsDialog( onKeepArchitecturesChange: (Set) -> Unit = {}, updateChannelPreference: app.morphe.gui.data.model.UpdateChannelPreference = app.morphe.gui.data.model.UpdateChannelPreference.STABLE, onUpdateChannelChange: (app.morphe.gui.data.model.UpdateChannelPreference) -> Unit = {}, + autoStartAdb: Boolean = false, + onAutoStartAdbChange: (Boolean) -> Unit = {}, collapsibleSectionStates: Map = emptyMap(), onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> } ) { @@ -120,8 +121,6 @@ fun SettingsDialog( var showLicensesDialog by remember { mutableStateOf(false) } var cacheCleared by remember { mutableStateOf(false) } var cacheClearFailed by remember { mutableStateOf(false) } - var showAddSourceDialog by remember { mutableStateOf(false) } - var editingSource by remember { mutableStateOf(null) } AlertDialog( onDismissRequest = onDismiss, @@ -280,23 +279,28 @@ fun SettingsDialog( SettingsDivider(borderColor) - // ── Patch Sources ── - PatchSourcesSection( - sources = patchSources, - activeSourceId = activePatchSourceId, - onActiveChange = { id -> - onActivePatchSourceChange(id) - onDismiss() - }, - onRemove = onRemovePatchSource, - onEdit = { source -> editingSource = source }, - onAddClick = { showAddSourceDialog = true }, + // ── Auto-start ADB ── + SettingToggleRow( + label = "Auto-start ADB", + description = "Spawn the ADB daemon on launch so connected devices are monitored. " + + "When off, Morphe never starts the server, and install/push features are disabled.", + checked = autoStartAdb, + onCheckedChange = onAutoStartAdbChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) + + SettingsDivider(borderColor) + + // ── Patched App Runtime Logs ── + PatchedAppRuntimeLogsSection( mono = mono, accentColor = accents.primary, borderColor = borderColor, enabled = !isPatching, - expanded = collapsibleSectionStates["PATCH SOURCES"] == true, - onExpandedChange = { onCollapsibleSectionToggle("PATCH SOURCES", it) } + expanded = collapsibleSectionStates["RUNTIME LOGS"] == true, + onExpandedChange = { onCollapsibleSectionToggle("RUNTIME LOGS", it) } ) SettingsDivider(borderColor) @@ -475,30 +479,9 @@ fun SettingsDialog( ) } - if (showAddSourceDialog) { - AddPatchSourceDialog( - onDismiss = { showAddSourceDialog = false }, - onAdd = { source -> - onAddPatchSource(source) - showAddSourceDialog = false - } - ) - } - if (showLicensesDialog) { LicensesDialog(onDismiss = { showLicensesDialog = false }) } - - editingSource?.let { source -> - EditPatchSourceDialog( - source = source, - onDismiss = { editingSource = null }, - onSave = { updated -> - onEditPatchSource(updated) - editingSource = null - } - ) - } } @Composable @@ -1492,6 +1475,7 @@ private fun OutputFolderSection( enabled: Boolean = true ) { val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current val alpha = if (enabled) 1f else 0.4f val outputDir = defaultOutputDirectory?.let { File(it) } val outputDirExists = outputDir?.isDirectory == true @@ -1511,7 +1495,7 @@ private fun OutputFolderSection( Spacer(Modifier.height(8.dp)) Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { @@ -1590,15 +1574,30 @@ private fun OutputFolderSection( ) } + // Stored form first (mirrors config.json), absolute resolution second. + // Hides the second line entirely when storage IS absolute, repeating + // the same path twice would make no sense now, innit. if (defaultOutputDirectory != null) { + val stored = app.morphe.engine.util.PortablePaths.storableForm(defaultOutputDirectory) + val isBundleRelative = stored != defaultOutputDirectory Text( - text = defaultOutputDirectory, + text = stored, fontSize = 9.sp, fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), maxLines = 1, overflow = TextOverflow.Ellipsis ) + if (isBundleRelative) { + Text( + text = "Resolves to: $defaultOutputDirectory", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } } @@ -1650,545 +1649,6 @@ private fun ActionButton( } } -// ── Patch Sources Section ── - -@Composable -private fun PatchSourcesSection( - sources: List, - activeSourceId: String, - onActiveChange: (String) -> Unit, - onRemove: (String) -> Unit, - onEdit: (PatchSource) -> Unit, - onAddClick: () -> Unit, - mono: androidx.compose.ui.text.font.FontFamily, - accentColor: Color, - borderColor: Color, - enabled: Boolean = true, - expanded: Boolean = false, - onExpandedChange: (Boolean) -> Unit = {} -) { - val corners = LocalMorpheCorners.current - val alpha = if (enabled) 1f else 0.4f - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - CollapsibleSection( - title = "PATCH SOURCES", - mono = mono, - expanded = expanded, - onExpandedChange = onExpandedChange - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = if (!enabled) "Disabled while patching" else "Select where patches are loaded from", - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - sources.forEach { source -> - val isActive = source.id == activeSourceId - val hoverInteraction = remember(source.id) { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(corners.medium)) - .border( - 1.dp, - when { - isActive -> accentColor.copy(alpha = 0.4f) - isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - else -> borderColor - }, - RoundedCornerShape(corners.medium) - ) - .background( - if (isActive) accentColor.copy(alpha = 0.08f) - else Color.Transparent - ) - .hoverable(hoverInteraction) - .then(if (enabled) Modifier.clickable { onActiveChange(source.id) } else Modifier) - .padding(horizontal = 12.dp, vertical = 10.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - // Active indicator dot - Box( - modifier = Modifier - .size(6.dp) - .background( - if (isActive) accentColor - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f), - RoundedCornerShape(1.dp) - ) - ) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = source.name, - fontSize = 12.sp, - fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = when (source.type) { - PatchSourceType.DEFAULT -> "Default" - PatchSourceType.GITHUB -> source.url?.removePrefix("https://github.com/") ?: "GitHub" - PatchSourceType.LOCAL -> source.filePath?.let { File(it).name } ?: "Local file" - }, - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (source.deletable && enabled) { - IconButton( - onClick = { onEdit(source) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = "Edit", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.size(14.dp) - ) - } - Spacer(Modifier.width(2.dp)) - IconButton( - onClick = { onRemove(source.id) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.size(14.dp) - ) - } - } - } - } - Spacer(modifier = Modifier.height(4.dp)) - } - - // Add source - OutlinedButton( - onClick = onAddClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small), - border = BorderStroke(1.dp, borderColor), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(14.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "ADD SOURCE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } // inner Column - } // CollapsibleSection - } -} - -// ── Add / Edit Source Dialogs ── - -@Composable -private fun AddPatchSourceDialog( - onDismiss: () -> Unit, - onAdd: (PatchSource) -> Unit -) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - var name by remember { mutableStateOf("") } - var sourceType by remember { mutableStateOf(PatchSourceType.GITHUB) } - var url by remember { mutableStateOf("") } - var filePath by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - shape = RoundedCornerShape(corners.medium), - containerColor = MaterialTheme.colorScheme.surface, - title = { - Text( - "ADD SOURCE", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.sp - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.widthIn(min = 300.dp) - ) { - // Type toggle - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - listOf(PatchSourceType.GITHUB, PatchSourceType.LOCAL).forEach { type -> - val isSelected = sourceType == type - Box( - modifier = Modifier - .clip(RoundedCornerShape(corners.small)) - .border( - 1.dp, - if (isSelected) accents.primary.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), - RoundedCornerShape(corners.small) - ) - .background( - if (isSelected) accents.primary.copy(alpha = 0.08f) - else Color.Transparent - ) - .clickable { sourceType = type } - .padding(horizontal = 14.dp, vertical = 7.dp) - ) { - Text( - text = when (type) { - PatchSourceType.GITHUB -> "GITHUB" - PatchSourceType.LOCAL -> "LOCAL FILE" - else -> "" - }, - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, - fontFamily = mono, - letterSpacing = 0.5.sp, - color = if (isSelected) accents.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - OutlinedTextField( - value = name, - onValueChange = { name = it; error = null }, - label = { Text("Name", fontFamily = mono, fontSize = 11.sp) }, - placeholder = { Text("My Custom Patches", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - when (sourceType) { - PatchSourceType.GITHUB -> { - OutlinedTextField( - value = url, - onValueChange = { url = it; error = null }, - label = { Text("Repository URL", fontFamily = mono, fontSize = 11.sp) }, - placeholder = { Text("github.com/owner/repo", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - Text( - "Accepts GitHub URL or morphe.software/add-source link", - fontFamily = mono, - fontSize = 9.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - letterSpacing = 0.3.sp - ) - } - PatchSourceType.LOCAL -> { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = filePath, - onValueChange = { filePath = it; error = null }, - label = { Text(".mpp file", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(corners.small), - readOnly = true - ) - OutlinedButton( - onClick = { - val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { - setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } - isVisible = true - } - if (dialog.directory != null && dialog.file != null) { - filePath = File(dialog.directory, dialog.file).absolutePath - if (name.isBlank()) name = dialog.file.removeSuffix(".mpp") - error = null - } - }, - shape = RoundedCornerShape(corners.small) - ) { - Text( - "BROWSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } - } - else -> {} - } - - error?.let { - Text( - text = it, - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.error - ) - } - } - }, - confirmButton = { - Button( - onClick = { - if (name.isBlank()) { error = "Name is required"; return@Button } - when (sourceType) { - PatchSourceType.GITHUB -> { - val trimmedUrl = url.trim() - val resolvedUrl = resolveGitHubUrl(trimmedUrl) - if (resolvedUrl == null) { - error = "Enter a valid GitHub URL or Morphe source link"; return@Button - } - onAdd(PatchSource( - id = UUID.randomUUID().toString(), - name = name.trim(), - type = sourceType, - url = resolvedUrl, - deletable = true - )) - return@Button - } - PatchSourceType.LOCAL -> { - if (filePath.isBlank() || !File(filePath).exists()) { - error = "Select a valid .mpp file"; return@Button - } - } - else -> {} - } - onAdd(PatchSource( - id = UUID.randomUUID().toString(), - name = name.trim(), - type = sourceType, - url = null, - filePath = if (sourceType == PatchSourceType.LOCAL) filePath.trim() else null, - deletable = true - )) - }, - colors = ButtonDefaults.buttonColors(containerColor = accents.primary), - shape = RoundedCornerShape(corners.small) - ) { - Text( - "ADD", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - "CANCEL", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - } - ) -} - -@Composable -private fun EditPatchSourceDialog( - source: PatchSource, - onDismiss: () -> Unit, - onSave: (PatchSource) -> Unit -) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - var name by remember { mutableStateOf(source.name) } - var url by remember { mutableStateOf(source.url ?: "") } - var filePath by remember { mutableStateOf(source.filePath ?: "") } - var error by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - shape = RoundedCornerShape(corners.medium), - containerColor = MaterialTheme.colorScheme.surface, - title = { - Text( - "EDIT SOURCE", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.sp - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.widthIn(min = 300.dp) - ) { - // Type indicator - Text( - text = when (source.type) { - PatchSourceType.GITHUB -> "GITHUB REPOSITORY" - PatchSourceType.LOCAL -> "LOCAL FILE" - else -> "" - }, - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accents.primary, - letterSpacing = 1.sp - ) - - OutlinedTextField( - value = name, - onValueChange = { name = it; error = null }, - label = { Text("Name", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - when (source.type) { - PatchSourceType.GITHUB -> { - OutlinedTextField( - value = url, - onValueChange = { url = it; error = null }, - label = { Text("Repository URL", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - } - PatchSourceType.LOCAL -> { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = filePath, - onValueChange = { filePath = it; error = null }, - label = { Text(".mpp file", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(corners.small), - readOnly = true - ) - OutlinedButton( - onClick = { - val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { - setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } - isVisible = true - } - if (dialog.directory != null && dialog.file != null) { - filePath = File(dialog.directory, dialog.file).absolutePath - error = null - } - }, - shape = RoundedCornerShape(corners.small) - ) { - Text( - "BROWSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } - } - else -> {} - } - - error?.let { - Text(text = it, fontSize = 11.sp, fontFamily = mono, color = MaterialTheme.colorScheme.error) - } - } - }, - confirmButton = { - Button( - onClick = { - if (name.isBlank()) { error = "Name is required"; return@Button } - when (source.type) { - PatchSourceType.GITHUB -> { - val resolvedUrl = resolveGitHubUrl(url.trim()) - if (resolvedUrl == null) { - error = "Enter a valid GitHub URL or Morphe source link"; return@Button - } - onSave(source.copy( - name = name.trim(), - url = resolvedUrl - )) - return@Button - } - PatchSourceType.LOCAL -> { - if (filePath.isBlank() || !File(filePath).exists()) { - error = "Select a valid .mpp file"; return@Button - } - } - else -> {} - } - onSave(source.copy( - name = name.trim(), - filePath = if (source.type == PatchSourceType.LOCAL) filePath.trim() else source.filePath - )) - }, - colors = ButtonDefaults.buttonColors(containerColor = accents.primary), - shape = RoundedCornerShape(corners.small) - ) { - Text( - "SAVE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - "CANCEL", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - } - ) -} // ── Strip Libs Section ── @@ -2267,6 +1727,8 @@ private fun SigningSection( onExpandedChange: (Boolean) -> Unit = {} ) { val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current + val accents = LocalMorpheAccents.current val alpha = if (enabled) 1f else 0.4f var localPassword by remember(keystorePassword) { mutableStateOf(keystorePassword ?: "") } @@ -2300,7 +1762,7 @@ private fun SigningSection( // Keystore path row Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { @@ -2382,10 +1844,12 @@ private fun SigningSection( } } - // Warning if keystore path set but file doesn't exist + // Warning if keystore path set but file doesn't exist. Patching will + // refuse to start with this configured (see PatchingViewModel) — user + // must restore the file, pick another, or reset to use Morphe's default. if (keystorePath != null && !keystoreExists) { Text( - text = "Keystore not found — will be created on next patch", + text = "Keystore not found — patching will fail until you restore it, pick another, or reset", fontSize = 10.sp, fontFamily = mono, color = Color(0xFFE0A030) @@ -2402,98 +1866,144 @@ private fun SigningSection( ) } - // Full path tooltip + // Either: stored form (relative when inside the bundle, absolute otherwise) + // with a "Resolves to: ..." subtitle when relative. Mirrors config.json + // so users can see which paths follow the bundle vs which are pinned. + // Or: "using default" hint when no user-configured path is set. if (keystorePath != null) { + val stored = app.morphe.engine.util.PortablePaths.storableForm(keystorePath) + val isBundleRelative = stored != keystorePath Text( - text = keystorePath, + text = stored, fontSize = 9.sp, fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), maxLines = 1, overflow = TextOverflow.Ellipsis ) + if (isBundleRelative) { + Text( + text = "Resolves to: $keystorePath", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + // Mirror the storage form treatment used for user-configured paths above. + // The default keystore lives in the bundle (`morphe-data/`) in the happy case, + // so the storable form will be relative. + // Verb is conditional on file existence. Patcher creates the file on first sign, + // so on a fresh install the hint accurately says "Will create..." + // instead of making up claims like "Using..." an absent file. + val defaultAbs = MorpheData.defaultKeystoreFile.absolutePath + val defaultStored = app.morphe.engine.util.PortablePaths.storableForm(defaultAbs) + val isBundleRelative = defaultStored != defaultAbs + val verb = if (MorpheData.defaultKeystoreFile.exists()) "Using" + else "Will create" + Text( + text = "$verb Morphe's default keystore at $defaultStored", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (isBundleRelative) { + Text( + text = "Resolves to: $defaultAbs", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - Spacer(Modifier.height(4.dp)) - - // Keystore password - OutlinedTextField( - value = localPassword, - onValueChange = { - localPassword = it - onCredentialsChange(it.ifEmpty { null }, localAlias, localEntryPassword) - }, - label = { Text("Keystore password", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - visualTransformation = if (showPassword) androidx.compose.ui.text.input.VisualTransformation.None - else androidx.compose.ui.text.input.PasswordVisualTransformation(), - trailingIcon = { - IconButton( - onClick = { showPassword = !showPassword }, - modifier = Modifier.size(20.dp) - ) { - Icon( - imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showPassword) "Hide" else "Show", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - }, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(8.dp)) - // Key alias - OutlinedTextField( - value = localAlias, - onValueChange = { - localAlias = it - onCredentialsChange(localPassword.ifEmpty { null }, it, localEntryPassword) - }, - label = { Text("Key alias", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + LabeledField(label = "KEYSTORE PASSWORD", mono = mono) { + SlimTextField( + value = localPassword, + onValueChange = { + localPassword = it + onCredentialsChange(it.ifEmpty { null }, localAlias, localEntryPassword) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + visualTransformation = if (showPassword) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + trailing = { + IconButton( + onClick = { showPassword = !showPassword }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showPassword) "Hide" else "Show", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + }, + ) + } - Spacer(Modifier.height(4.dp)) + LabeledField(label = "KEY ALIAS", mono = mono) { + SlimTextField( + value = localAlias, + onValueChange = { + localAlias = it + onCredentialsChange(localPassword.ifEmpty { null }, it, localEntryPassword) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) + } - // Key entry password - OutlinedTextField( - value = localEntryPassword, - onValueChange = { - localEntryPassword = it - onCredentialsChange(localPassword.ifEmpty { null }, localAlias, it) - }, - label = { Text("Key password", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - visualTransformation = if (showEntryPassword) androidx.compose.ui.text.input.VisualTransformation.None - else androidx.compose.ui.text.input.PasswordVisualTransformation(), - trailingIcon = { - IconButton( - onClick = { showEntryPassword = !showEntryPassword }, - modifier = Modifier.size(20.dp) - ) { - Icon( - imageVector = if (showEntryPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showEntryPassword) "Hide" else "Show", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - }, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) + LabeledField(label = "KEY PASSWORD", mono = mono) { + SlimTextField( + value = localEntryPassword, + onValueChange = { + localEntryPassword = it + onCredentialsChange(localPassword.ifEmpty { null }, localAlias, it) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + visualTransformation = if (showEntryPassword) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + trailing = { + IconButton( + onClick = { showEntryPassword = !showEntryPassword }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = if (showEntryPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showEntryPassword) "Hide" else "Show", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + }, + ) + } + } // Verify credentials button var verifyResult by remember { mutableStateOf(null) } @@ -2524,7 +2034,7 @@ private fun SigningSection( } }, enabled = enabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), shape = RoundedCornerShape(corners.small), border = BorderStroke( 1.dp, @@ -2534,7 +2044,7 @@ private fun SigningSection( else -> borderColor } ), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), ) { Icon( imageVector = Icons.Default.Check, @@ -2620,14 +2130,14 @@ private fun SigningSection( } }, enabled = enabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), shape = RoundedCornerShape(corners.small), border = BorderStroke( 1.dp, if (generateSuccess) MorpheColors.Teal.copy(alpha = 0.4f) else accentColor.copy(alpha = 0.3f) ), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), ) { Icon( imageVector = Icons.Default.Add, @@ -3012,6 +2522,7 @@ private fun ThemePreference.toDisplayName(): String { ThemePreference.CATPPUCCIN -> "Catppuccin" ThemePreference.SAKURA -> "Sakura" ThemePreference.MATCHA -> "Matcha" + ThemePreference.DEEPSPACE -> "Deepspace" ThemePreference.SYSTEM -> "System" } } @@ -3025,6 +2536,7 @@ private fun ThemePreference.iconSymbol(): String { ThemePreference.CATPPUCCIN -> "🐱" ThemePreference.SAKURA -> "🌸" ThemePreference.MATCHA -> "🍵" + ThemePreference.DEEPSPACE -> "✦" ThemePreference.SYSTEM -> "⚙" } } @@ -3038,6 +2550,7 @@ private fun ThemePreference.accentColor(): Color { ThemePreference.CATPPUCCIN -> Color(0xFFCBA6F7) ThemePreference.SAKURA -> Color(0xFFB43A67) ThemePreference.MATCHA -> Color(0xFF4C7A35) + ThemePreference.DEEPSPACE -> Color(0xFF00D9FF) ThemePreference.SYSTEM -> MorpheColors.Blue } } @@ -3080,41 +2593,160 @@ private fun clearAllCache(): Boolean { } } -/** - * Resolves a URL to a GitHub repository URL. - * Supports: - * - Direct GitHub URLs: https://github.com/owner/repo - * - Morphe source links: https://morphe.software/add-source?github=owner/repo - * - Short form: owner/repo (assumed GitHub) - * Returns a normalized https://github.com/owner/repo URL, or null if invalid. - */ -private fun resolveGitHubUrl(input: String): String? { - val trimmed = input.trim() - if (trimmed.isBlank()) return null - - // Morphe source link: morphe.software/add-source?github=owner/repo - if (trimmed.contains("morphe.software/add-source")) { - val match = Regex("[?&]github=([^&]+)").find(trimmed) - val repoPath = match?.groupValues?.get(1) ?: return null - val clean = repoPath.trimEnd('/') - return if (clean.contains('/') && clean.split('/').size == 2) { - "https://github.com/$clean" - } else null - } - // Direct GitHub URL: https://github.com/owner/repo - if (trimmed.contains("github.com/")) { - // Extract owner/repo from full URL - val match = Regex("github\\.com/([^/]+/[^/]+)").find(trimmed) - return if (match != null) { - "https://github.com/${match.groupValues[1].trimEnd('/')}" - } else null - } +// ── Patched App Runtime Logs Section ── - // Short form: owner/repo - if (trimmed.matches(Regex("[\\w.-]+/[\\w.-]+"))) { - return "https://github.com/$trimmed" - } +private sealed interface RuntimeLogsStatus { + data object Idle : RuntimeLogsStatus + data object Clearing : RuntimeLogsStatus + data object Saving : RuntimeLogsStatus + data object Cleared : RuntimeLogsStatus + data class Saved(val file: File, val lineCount: Int) : RuntimeLogsStatus + data class Error(val message: String) : RuntimeLogsStatus +} - return null +@Composable +private fun PatchedAppRuntimeLogsSection( + mono: androidx.compose.ui.text.font.FontFamily, + accentColor: Color, + borderColor: Color, + enabled: Boolean = true, + expanded: Boolean = false, + onExpandedChange: (Boolean) -> Unit = {} +) { + val monitorState by DeviceMonitor.state.collectAsState() + val selectedDevice = monitorState.selectedDevice + val scope = rememberCoroutineScope() + val adbManager = remember { AdbManager() } + var status by remember { mutableStateOf(RuntimeLogsStatus.Idle) } + + val isWorking = status is RuntimeLogsStatus.Clearing || status is RuntimeLogsStatus.Saving + val deviceReady = selectedDevice?.isReady == true + val canAct = enabled && deviceReady && !isWorking + + CollapsibleSection( + title = "PATCHED APP RUNTIME LOGS", + mono = mono, + expanded = expanded, + onExpandedChange = onExpandedChange + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = "Capture logs from your phone after a patched app crashes or misbehaves. Clear before reproducing the bug, then save the filtered output to attach to a bug report.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + + // Device row + if (deviceReady) { + Text( + text = "Device: ${selectedDevice.displayName}${selectedDevice.architecture?.let { " ($it)" } ?: ""}", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } else { + Text( + text = "No device connected. Plug in your phone with USB debugging enabled.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + + ActionButton( + label = if (status is RuntimeLogsStatus.Clearing) "CLEARING…" else "CLEAR DEVICE LOGS", + icon = Icons.Default.DeleteSweep, + mono = mono, + borderColor = borderColor, + enabled = canAct, + onClick = { + val device = selectedDevice ?: return@ActionButton + status = RuntimeLogsStatus.Clearing + scope.launch { + val result = adbManager.clearLogcat(device.id) + status = result.fold( + onSuccess = { RuntimeLogsStatus.Cleared }, + onFailure = { RuntimeLogsStatus.Error(it.message ?: "Failed to clear logs") } + ) + } + } + ) + + ActionButton( + label = if (status is RuntimeLogsStatus.Saving) "SAVING…" else "SAVE DEVICE LOGS", + icon = Icons.Default.Save, + mono = mono, + borderColor = borderColor, + contentColor = accentColor, + enabled = canAct, + onClick = { + val device = selectedDevice ?: return@ActionButton + status = RuntimeLogsStatus.Saving + scope.launch { + val timestamp = SimpleDateFormat("yyyy-MM-dd-HHmmss", java.util.Locale.US).format(java.util.Date()) + val outFile = File(FileUtils.getLogsDir(), "device-logcat-$timestamp.txt") + val result = adbManager.captureLogcat(device.id, outFile) + status = result.fold( + onSuccess = { count -> RuntimeLogsStatus.Saved(outFile, count) }, + onFailure = { RuntimeLogsStatus.Error(it.message ?: "Failed to save logs") } + ) + } + } + ) + + // Status line + when (val s = status) { + RuntimeLogsStatus.Idle, RuntimeLogsStatus.Clearing, RuntimeLogsStatus.Saving -> Unit + RuntimeLogsStatus.Cleared -> Text( + text = "Logs cleared on device.", + fontSize = 11.sp, + fontFamily = mono, + color = accentColor.copy(alpha = 0.85f) + ) + is RuntimeLogsStatus.Saved -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = if (s.lineCount == 0) + "Nothing captured yet. Run the patched app on your phone, then save again." + else + "Saved ${s.lineCount} line(s) to ${s.file.name}", + fontSize = 11.sp, + fontFamily = mono, + color = if (s.lineCount == 0) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + else accentColor.copy(alpha = 0.85f) + ) + if (s.lineCount > 0) { + val cornersLocal = LocalMorpheCorners.current + Text( + text = "OPEN LOGS", + fontSize = 10.sp, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + color = accentColor, + modifier = Modifier + .clip(RoundedCornerShape(cornersLocal.small)) + .clickable { + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(s.file.parentFile) + } + } catch (e: Exception) { + Logger.error("Failed to reveal logs folder", e) + } + } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) + } + } + is RuntimeLogsStatus.Error -> Text( + text = s.message, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error + ) + } + } + } } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt b/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt new file mode 100644 index 00000000..bab56a1a --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt @@ -0,0 +1,161 @@ +/* + * 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.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.MorpheAccentColors +import app.morphe.gui.ui.theme.MorpheCornerStyle + +/** + * Label-and-input group rendered as a tight Column. Use inside a parent Column + * that has its own `verticalArrangement = spacedBy(...)` for between-group + * spacing — this composable's internal label↔field gap stays a fixed 4dp. + */ +@Composable +internal fun LabeledField( + label: String, + mono: FontFamily, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = label, + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + letterSpacing = 1.2.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + content() + } +} + +/** + * Slim text input matching the cyberdeck aesthetic across the app — pinned to + * [LocalMorpheDimens.controlHeight] so it lines up with the project's standard + * button height. Uses [BasicTextField] with a custom decoration so we get full + * control of the height (Material 3's [androidx.compose.material3.OutlinedTextField] + * has a 56dp minimum that's too chunky for this app). + * + * Optional [trailing] slot for things like password-visibility toggles. + */ +@Composable +internal fun SlimTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + mono: FontFamily, + accents: MorpheAccentColors, + corners: MorpheCornerStyle, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + enabled: Boolean = true, + visualTransformation: VisualTransformation = VisualTransformation.None, + trailing: (@Composable () -> Unit)? = null, +) { + val dimens = LocalMorpheDimens.current + val muted = MaterialTheme.colorScheme.onSurfaceVariant + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor by animateColorAsState( + if (isFocused) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.18f), + animationSpec = tween(150), + label = "slimFieldBorder", + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + readOnly = readOnly, + enabled = enabled, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(accents.primary), + modifier = modifier + .height(dimens.controlHeight) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 10.dp, end = if (trailing != null) 4.dp else 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + if (value.isEmpty() && placeholder.isNotEmpty()) { + Text( + text = placeholder, + fontSize = 11.sp, + fontFamily = mono, + color = muted.copy(alpha = 0.4f), + ) + } + innerTextField() + } + if (trailing != null) trailing() + } + }, + ) +} + +/** + * Compact OutlinedButton pinned to [LocalMorpheDimens.controlHeight]. Used for + * BROWSE / RESET / similar inline action buttons next to a [SlimTextField]. + */ +@Composable +internal fun DialogActionButton( + label: String, + mono: FontFamily, + corners: MorpheCornerStyle, + onClick: () -> Unit, +) { + val dimens = LocalMorpheDimens.current + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + label, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp, + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt new file mode 100644 index 00000000..2e144e17 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt @@ -0,0 +1,468 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.BorderStroke +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +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.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.animation.animateColorAsState +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.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +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 + +/** + * Multi-source management sheet, summoned from the home header `+` button. + * Lists every configured patch source with an enable toggle. Default source + * cannot be deleted or renamed (mirrors morphe-manager rules); other sources + * can be edited or removed. + * + * Caller wires actions to [PatchSourceManager] / [ConfigRepository] equivalents. + */ +/** + * How rows in the management sheet behave: + * - [MULTI_TOGGLE]: each source has an enable Switch. Used by Expert mode where + * patches from all enabled sources are unioned. + * - [SINGLE_SELECT]: each row is a radio. Used by Quick Patch mode where exactly + * one source is "active" at a time. + */ +enum class SourceSheetMode { MULTI_TOGGLE, SINGLE_SELECT } + +@Composable +fun SourceManagementSheet( + sources: List, + onToggleEnabled: (id: String, enabled: Boolean) -> Unit, + onAdd: (PatchSource) -> Unit, + onEdit: (PatchSource) -> Unit, + onRemove: (id: String) -> Unit, + onOpenPatches: (sourceId: String) -> Unit, + onDismiss: () -> Unit, + enabled: Boolean = true, + /** sourceId → resolved version label (e.g. "v1.27.0-dev.2"). Empty when not loaded. */ + sourceVersions: Map = emptyMap(), + /** sourceId → channel classification of the resolved release. Drives the badge. */ + sourceChannels: Map = emptyMap(), + /** True while patches are being (re)loaded. Drives the per-row spinner shown + * in place of the version/badge for enabled sources whose data isn't yet + * in [sourceVersions]. */ + isLoading: Boolean = false, + /** Selection semantics. Defaults to multi-toggle (Expert mode). */ + mode: SourceSheetMode = SourceSheetMode.MULTI_TOGGLE, + /** sourceId of the currently picked source — only used when [mode] is SINGLE_SELECT. */ + activeSourceId: String? = null, + /** Called when the user picks a source — only used when [mode] is SINGLE_SELECT. */ + onSelectSingle: (sourceId: String) -> Unit = {}, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + + var showAddDialog by remember { mutableStateOf(false) } + var editingSource by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "PATCH SOURCES", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 2.sp, + ) + }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .widthIn(min = 360.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = when { + !enabled -> "Disabled while patching" + mode == SourceSheetMode.SINGLE_SELECT -> + "Pick which source Quick Patch uses. Multi-source is available in Expert mode." + else -> "Enable/Disable any combination. Patches from all enabled sources are unioned." + }, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + ) + + Spacer(Modifier.height(4.dp)) + + sources.forEach { source -> + SourceRow( + source = source, + version = sourceVersions[source.id], + channel = sourceChannels[source.id], + isLoading = isLoading, + accentColor = accents.primary, + borderColor = borderColor, + mono = mono, + enabled = enabled, + mode = mode, + isActiveSelection = source.id == activeSourceId, + onSelectSingle = { onSelectSingle(source.id) }, + onToggleEnabled = { newVal -> onToggleEnabled(source.id, newVal) }, + onEdit = { editingSource = source }, + onRemove = { onRemove(source.id) }, + onOpenPatches = { onOpenPatches(source.id) }, + ) + } + + Spacer(Modifier.height(2.dp)) + + OutlinedButton( + onClick = { showAddDialog = true }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + "ADD SOURCE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp + ) + } + } + }, + confirmButton = { + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + ) { + Text( + "DONE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + ) + } + } + ) + + if (showAddDialog) { + AddPatchSourceDialog( + onDismiss = { showAddDialog = false }, + onAdd = { + onAdd(it) + showAddDialog = false + } + ) + } + + editingSource?.let { src -> + EditPatchSourceDialog( + source = src, + onDismiss = { editingSource = null }, + onSave = { + onEdit(it) + editingSource = null + } + ) + } +} + +@Composable +private fun SourceRow( + source: PatchSource, + version: String?, + channel: app.morphe.gui.util.EnabledSourcesLoader.Channel?, + isLoading: Boolean, + accentColor: Color, + borderColor: Color, + mono: androidx.compose.ui.text.font.FontFamily, + enabled: Boolean, + onToggleEnabled: (Boolean) -> Unit, + onEdit: () -> Unit, + onRemove: () -> Unit, + onOpenPatches: () -> Unit, + mode: SourceSheetMode, + isActiveSelection: Boolean, + onSelectSingle: () -> Unit, +) { + val corners = LocalMorpheCorners.current + val hoverInteraction = remember(source.id) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val isEnabled = source.enabled + val isDefault = !source.deletable + // Card click works regardless of enable state. In MULTI_TOGGLE mode it opens + // patches for the source (PatchesScreen). In SINGLE_SELECT mode it picks the + // source as the active one for Quick Patch. Disabled only while patching. + val canInteract = enabled + // For visual highlight: in MULTI mode highlight when source is enabled; in + // SINGLE_SELECT highlight when this row is the picked one. + val isHighlighted = if (mode == SourceSheetMode.SINGLE_SELECT) isActiveSelection else isEnabled + + val animatedBorder by animateColorAsState( + targetValue = when { + isHovered && canInteract -> accentColor.copy(alpha = if (isHighlighted) 0.7f else 0.45f) + isHighlighted -> accentColor.copy(alpha = 0.35f) + else -> borderColor + }, + animationSpec = tween(150) + ) + val animatedBg by animateColorAsState( + targetValue = when { + isHovered && canInteract -> accentColor.copy(alpha = if (isHighlighted) 0.12f else 0.05f) + isHighlighted -> accentColor.copy(alpha = 0.06f) + else -> Color.Transparent + }, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, animatedBorder, RoundedCornerShape(corners.medium)) + .background(animatedBg) + .hoverable(hoverInteraction) + .then( + if (canInteract) Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = if (mode == SourceSheetMode.SINGLE_SELECT) onSelectSingle else onOpenPatches) + else Modifier + ) + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // LED indicator — glows when enabled (MULTI) or selected (SINGLE). + LedIndicator(isOn = isHighlighted, isHot = isHovered && canInteract, accentColor = accentColor) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = source.name, + fontSize = 12.sp, + fontWeight = if (isEnabled) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (isDefault) { + Text( + "DEFAULT", + fontSize = 8.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = when (source.type) { + PatchSourceType.DEFAULT -> source.url?.removePrefix("https://github.com/") ?: "Built-in" + PatchSourceType.GITHUB -> source.url?.removePrefix("https://github.com/") ?: "GitHub" + PatchSourceType.GITLAB -> source.url?.removePrefix("https://gitlab.com/") ?: "GitLab" + PatchSourceType.LOCAL -> source.filePath?.let { File(it).name } ?: "Local file" + }, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (isEnabled && version != null) { + Text( + text = "·", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + Text( + text = version, + fontSize = 10.sp, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + color = accentColor.copy(alpha = 0.9f) + ) + ChannelBadge(channel = channel, mono = mono) + } else if (isEnabled && isLoading) { + Text( + text = "·", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 1.5.dp, + color = accentColor, + ) + Spacer(Modifier.width(2.dp)) + Text( + text = "RESOLVING...", + fontSize = 9.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + color = accentColor.copy(alpha = 0.8f), + letterSpacing = 1.sp, + ) + } + } + } + + // Edit + delete are hidden for default; toggle is always shown + if (!isDefault && enabled) { + IconButton(onClick = onEdit, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.size(14.dp) + ) + } + IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.size(14.dp) + ) + } + Spacer(Modifier.width(4.dp)) + } + when (mode) { + SourceSheetMode.MULTI_TOGGLE -> Switch( + checked = isEnabled, + onCheckedChange = onToggleEnabled, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedTrackColor = accentColor.copy(alpha = 0.5f), + checkedThumbColor = accentColor, + ), + modifier = Modifier.scale(0.8f) + ) + SourceSheetMode.SINGLE_SELECT -> RadioButton( + selected = isActiveSelection, + onClick = onSelectSingle, + enabled = enabled, + colors = RadioButtonDefaults.colors( + selectedColor = accentColor, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ), + ) + } + } + } +} + + +@Composable +private fun ChannelBadge( + channel: app.morphe.gui.util.EnabledSourcesLoader.Channel?, + mono: androidx.compose.ui.text.font.FontFamily, +) { + val corners = LocalMorpheCorners.current + val accents = LocalMorpheAccents.current + val (label, color) = when (channel) { + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_LATEST -> "STABLE LATEST" to accents.secondary + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_OLDER -> "STABLE OLDER" to accents.warning + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_LATEST -> "DEV LATEST" to androidx.compose.ui.graphics.Color(0xFFFFD43B) + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_OLDER -> "DEV OLDER" to accents.warning + else -> "STABLE LATEST" to accents.secondary + } + Box( + modifier = Modifier + .border(1.dp, color.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) + .background(color.copy(alpha = 0.08f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = label, + fontSize = 8.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = color, + ) + } +} + +/** + * Tiny status LED on the left of each source row. Solid glow when the source is + * enabled; dim ring when off. Brightens on hover for the click-to-open affordance. + */ +@Composable +private fun LedIndicator(isOn: Boolean, isHot: Boolean, accentColor: Color) { + val color by animateColorAsState( + targetValue = when { + isOn && isHot -> accentColor + isOn -> accentColor.copy(alpha = 0.85f) + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + }, + animationSpec = tween(200) + ) + val haloAlpha by animateColorAsState( + targetValue = if (isOn) accentColor.copy(alpha = if (isHot) 0.35f else 0.18f) else Color.Transparent, + animationSpec = tween(200) + ) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(14.dp)) { + // Soft halo ring + Box( + modifier = Modifier + .size(12.dp) + .background(haloAlpha, shape = androidx.compose.foundation.shape.CircleShape) + ) + // Core dot + Box( + modifier = Modifier + .size(7.dp) + .background(color, shape = androidx.compose.foundation.shape.CircleShape) + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt new file mode 100644 index 00000000..7eb200e7 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt @@ -0,0 +1,144 @@ +/* + * 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.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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.MorpheAccentColors +import app.morphe.gui.util.EnabledSourcesLoader + +/** Per-source LED state surfaced in [SourcesCountPill]. */ +enum class SourceLedState { DISABLED, STABLE_LATEST, OLDER, DEV } + +/** + * Header pill showing source count + per-source channel LEDs + trailing "+". + * Used in expert mode (clickable, opens [SourceManagementSheet]) and in Quick + * Patch mode (purely informational — pass `onClick = null`). + */ +@Composable +fun SourcesCountPill( + sourceStates: List, + onClick: (() -> Unit)? = null, +) { + val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val interactive = onClick != null + val borderColor by animateColorAsState( + if (isHovered && interactive) accents.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.10f), + animationSpec = tween(200) + ) + val tint = if (isHovered && interactive) accents.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f) + val count = sourceStates.size.coerceAtLeast(1) + val label = if (count == 1) "1 SOURCE" else "$count SOURCES" + Row( + modifier = Modifier + .height(dimens.controlHeight) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surface) + .then( + if (interactive) Modifier + .hoverable(hoverInteraction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + else Modifier + ) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.5.sp, + color = tint, + ) + if (sourceStates.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + sourceStates.forEach { state -> SourceLed(state = state, accents = accents) } + } + } + if (interactive) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Manage patch sources", + tint = tint, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Composable +private fun SourceLed(state: SourceLedState, accents: MorpheAccentColors) { + val color = when (state) { + SourceLedState.DISABLED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + SourceLedState.STABLE_LATEST -> accents.primary + SourceLedState.OLDER -> accents.warning + SourceLedState.DEV -> Color(0xFFFFD43B) + } + Box( + modifier = Modifier + .size(6.dp) + .background(color, shape = CircleShape) + ) +} + +/** Map a [PatchSource] + its resolved channel to a UI LED state. */ +fun sourceLedState( + source: PatchSource, + channel: EnabledSourcesLoader.Channel?, +): SourceLedState { + if (!source.enabled) return SourceLedState.DISABLED + return when (channel) { + EnabledSourcesLoader.Channel.STABLE_LATEST -> SourceLedState.STABLE_LATEST + EnabledSourcesLoader.Channel.STABLE_OLDER -> SourceLedState.OLDER + EnabledSourcesLoader.Channel.DEV_LATEST, + EnabledSourcesLoader.Channel.DEV_OLDER -> SourceLedState.DEV + // No load yet — assume latest until we know otherwise. + null, EnabledSourcesLoader.Channel.UNKNOWN -> SourceLedState.STABLE_LATEST + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 5f06b25b..6a56799d 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 @@ -7,7 +7,11 @@ package app.morphe.gui.ui.screens.home import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -22,6 +26,8 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.* import androidx.compose.foundation.HorizontalScrollbar import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.ui.text.style.TextOverflow @@ -30,12 +36,14 @@ 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.Add import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Warning import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,6 +51,8 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -55,6 +65,7 @@ import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalThemeState @@ -65,10 +76,19 @@ import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.SupportedApp +import app.morphe.gui.data.repository.PatchSourceManager +import app.morphe.gui.ui.components.SourceLedState +import app.morphe.gui.ui.components.SourceManagementSheet +import app.morphe.gui.ui.components.SourcesCountPill +import app.morphe.gui.ui.components.sourceLedState 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.gui.ui.screens.home.components.ApkInfoCard import app.morphe.gui.ui.screens.home.components.FullScreenDropZone +import app.morphe.gui.ui.screens.home.components.SupportedAppListRow +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 @@ -98,20 +118,80 @@ fun HomeScreenContent( val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() + val patchSourceManager: PatchSourceManager = koinInject() + val allSources by patchSourceManager.allSources.collectAsState() + val coroutineScope = rememberCoroutineScope() + // Two-flag pattern for smooth navigation in/out of the sheet: + // - showSourceManagementSheet: actually visible right now + // - pendingReopenSheet: user navigated away from the sheet via a row click; + // we should reopen it once they pop back AND the screen transition settles. + // rememberSaveable on both so they survive Voyager's push/pop teardown. + var showSourceManagementSheet by rememberSaveable { mutableStateOf(false) } + var pendingReopenSheet by rememberSaveable { mutableStateOf(false) } + + // Re-show the sheet after the pop animation finishes, NOT immediately on + // re-entry. Without the delay the sheet flashes in mid-transition. + LaunchedEffect(Unit) { + if (pendingReopenSheet) { + kotlinx.coroutines.delay(220) + showSourceManagementSheet = true + pendingReopenSheet = false + } + } + val navStackSize = navigator.items.size LaunchedEffect(navStackSize) { viewModel.refreshPatchesIfNeeded() } - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(uiState.error) { - uiState.error?.let { error -> - snackbarHostState.showSnackbar( - message = error, - duration = SnackbarDuration.Short - ) - viewModel.clearError() - } + if (showSourceManagementSheet) { + val snapshot = viewModel.getResolvedSourcesSnapshot() + val versions: Map = snapshot + ?.resolved + ?.associate { it.source.id to it.resolvedVersion } + ?: emptyMap() + val channels: Map = snapshot + ?.resolved + ?.associate { it.source.id to it.channel } + ?: emptyMap() + SourceManagementSheet( + sources = allSources, + sourceVersions = versions, + sourceChannels = channels, + isLoading = uiState.isLoadingPatches, + onToggleEnabled = { id, enabled -> + coroutineScope.launch { + patchSourceManager.setSourceEnabled(id, enabled) + // Re-resolve releases + reload patches so badges, versions, + // and the union app list reflect the new enabled set. + viewModel.retryLoadPatches() + } + }, + onAdd = { source -> + coroutineScope.launch { patchSourceManager.addSource(source) } + }, + onEdit = { updated -> + coroutineScope.launch { patchSourceManager.updateSource(updated) } + }, + onRemove = { id -> + coroutineScope.launch { patchSourceManager.removeSource(id) } + }, + onOpenPatches = { sourceId -> + // Hide sheet immediately so it doesn't ride the push animation. + // Mark it as pending-reopen so it returns smoothly after pop. + showSourceManagementSheet = false + pendingReopenSheet = true + coroutineScope.launch { + patchSourceManager.switchSource(sourceId) + navigator.push(PatchesScreen( + apkPath = uiState.apkInfo?.filePath ?: "", + apkName = uiState.apkInfo?.appName ?: "Select APK first" + )) + } + }, + onDismiss = { showSourceManagementSheet = false }, + enabled = !uiState.isAnalyzing, + ) } // Full screen drop zone wrapper @@ -125,7 +205,17 @@ fun HomeScreenContent( modifier = Modifier .fillMaxSize() ) { - val useSplitLayout = maxWidth >= 720.dp + // 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 val isSmall = maxHeight < 600.dp val padding = if (isCompact) 16.dp else 24.dp @@ -148,7 +238,9 @@ fun HomeScreenContent( apkName = uiState.apkInfo!!.appName, patchesFilePath = patchesFile.absolutePath, packageName = uiState.apkInfo!!.packageName, - apkArchitectures = uiState.apkInfo!!.architectures + apkArchitectures = uiState.apkInfo!!.architectures, + patchesFilePaths = viewModel.getAllResolvedPatchFiles().map { it.absolutePath }, + patchSourceNames = viewModel.getAllResolvedPatchSourceNames(), )) } }, @@ -178,6 +270,50 @@ fun HomeScreenContent( } } + val resolvedSnapshot = viewModel.getResolvedSourcesSnapshot() + val versionsBySource: Map = resolvedSnapshot + ?.resolved + ?.associate { it.source.id to it.resolvedVersion } + ?: emptyMap() + val channelsBySource: Map = + resolvedSnapshot + ?.resolved + ?.associate { it.source.id to it.channel } + ?: emptyMap() + // Source names whose patches target the currently-selected APK's package. + // Used by ApkInfoCard's "FROM" row to surface multi-source provenance. + val patchSourcesForSelectedApk: List = uiState.apkInfo?.let { info -> + val snapshot = resolvedSnapshot ?: return@let null + snapshot.guiPatchesBySource.entries + .filter { (_, patches) -> + patches.any { p -> p.compatiblePackages.any { it.name == info.packageName } } + } + .mapNotNull { (sourceId, _) -> + allSources.firstOrNull { it.id == sourceId }?.name + } + } ?: emptyList() + + // Per-package source attribution map used by the supported-apps cards. + // Built once per recomposition so each card just looks up its own list. + val sourceNamesByPackage: Map> = if (resolvedSnapshot == null) { + emptyMap() + } else { + val sourceIdToName = allSources.associate { it.id to it.name } + val accum = mutableMapOf>() + resolvedSnapshot.guiPatchesBySource.forEach { (sourceId, patches) -> + val name = sourceIdToName[sourceId] ?: return@forEach + val packages = patches.flatMap { it.compatiblePackages.map { p -> p.name } } + .filter { it.isNotBlank() } + .toSet() + packages.forEach { pkg -> + accum.getOrPut(pkg) { mutableListOf() }.add(name) + } + } + accum + } + val sourceStates: List = allSources.map { src -> + sourceLedState(src, channelsBySource[src.id]) + } val headerContent: @Composable ColumnScope.() -> Unit = { if (useHorizontalHeader) { HeaderBar( @@ -186,6 +322,8 @@ fun HomeScreenContent( onChangePatchesClick = onChangePatchesClick, onRetry = onRetry, onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, + onManageSourcesClick = { showSourceManagementSheet = true }, + sourceStates = sourceStates, ) } else { Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) @@ -237,7 +375,8 @@ fun HomeScreenContent( patchesLoaded = patchesLoaded, onClearClick = onClearClick, onChangeClick = onChangeClick, - onContinueClick = onContinueClick + onContinueClick = onContinueClick, + patchSourceNames = patchSourcesForSelectedApk, ) } } @@ -258,7 +397,8 @@ fun HomeScreenContent( isDefaultSource = uiState.isDefaultSource, supportedApps = uiState.supportedApps, loadError = uiState.patchLoadError, - onRetry = onRetry + onRetry = onRetry, + sourceNamesByPackage = sourceNamesByPackage, ) } } @@ -273,6 +413,8 @@ fun HomeScreenContent( onChangePatchesClick = onChangePatchesClick, onRetry = onRetry, onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, + onManageSourcesClick = { showSourceManagementSheet = true }, + sourceStates = sourceStates, ) } else { Column( @@ -316,22 +458,12 @@ fun HomeScreenContent( } } - // ── Scrollable body ── - 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 - ) { + // ── 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()) { if (uiState.showUpdateBanner) { UpdateBanner( info = uiState.updateInfo!!, @@ -339,57 +471,156 @@ fun HomeScreenContent( onDismissForVersion = { viewModel.dismissUpdateForVersion() }, modifier = Modifier .fillMaxWidth() - .padding(start = padding, end = padding, top = 8.dp) + .padding(start = padding, end = padding, top = 8.dp), ) } - - // ── Main workspace area ── - Box( + if (uiState.showMultiSourceHint) { + MultiSourceHintBanner( + onDismiss = { viewModel.dismissMultiSourceHint() }, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, end = padding, top = 8.dp), + ) + } + Row( modifier = Modifier + .weight(1f) .fillMaxWidth() - .padding(padding), - contentAlignment = Alignment.Center + // Small cute padding for small cute space + // between the HeaderBar's bottom + // divider and the actual body section. + .padding( + start = if (isCompact) 12.dp else 10.dp, + end = padding, + top = 4.dp, + bottom = padding, + ), + horizontalArrangement = Arrangement.spacedBy(padding), ) { - MiddleContent( - uiState = uiState, + // Left: browse/discover supported apps (wizard step 1). + SupportedAppsListPane( + supportedApps = uiState.supportedApps, + sourceNamesByPackage = sourceNamesByPackage, + isLoading = uiState.isLoadingPatches, + loadError = uiState.patchLoadError, + onRetry = onRetry, isCompact = isCompact, - patchesLoaded = patchesLoaded, - onClearClick = onClearClick, - onChangeClick = onChangeClick, - onContinueClick = onContinueClick + modifier = Modifier + .weight(1.2f) + .fillMaxHeight(), ) + // Right: APK info / drop zone (wizard step 2 — pick the + // APK you want patched). Content centers vertically when + // it fits, scrolls when it doesn't, so the CONTINUE + // button is never clipped off the bottom. + BoxWithConstraints( + modifier = Modifier.weight(1f).fillMaxHeight(), + ) { + val viewport = this.maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = viewport), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = patchesLoaded, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick, + patchSourceNames = patchSourcesForSelectedApk, + ) + } + } } - - // ── Supported apps ── + } + } 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, - modifier = Modifier.padding( - start = padding, - end = padding, - bottom = if (isSmall) 8.dp else 16.dp - ) + verticalArrangement = if (pinSupportedAppsToBottom) Arrangement.SpaceBetween else Arrangement.Top, ) { - SupportedAppsSection( - isCompact = isCompact, - maxWidth = outerMaxWidth, - isLoading = uiState.isLoadingPatches, - isDefaultSource = uiState.isDefaultSource, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = onRetry - ) + 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, + ) + } } - } - // Show scrollbar only when content overflows - if (scrollState.maxValue > 0) { - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter(scrollState), - style = morpheScrollbarStyle() - ) + if (scrollState.maxValue > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle(), + ) + } } } } @@ -405,11 +636,19 @@ fun HomeScreenContent( ) } - // Snackbar host - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter) - ) + // Error/warning bar — custom Morphe-styled, avoids Material3 + // SnackbarHost (whose internal SnackbarKt invocation path the + // shadow `minimize` analyzer can't trace, causing runtime + // NoClassDefFoundError in the packaged jar). + uiState.error?.let { error -> + MorpheErrorBar( + message = error, + onDismiss = { viewModel.clearError() }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 24.dp, vertical = 20.dp) + ) + } // Drag overlay if (uiState.isDragHovering) { @@ -437,7 +676,9 @@ private fun handleContinue( apkName = info.appName, patchesFilePath = patchesFile.absolutePath, packageName = info.packageName, - apkArchitectures = info.architectures + apkArchitectures = info.architectures, + patchesFilePaths = viewModel.getAllResolvedPatchFiles().map { it.absolutePath }, + patchSourceNames = viewModel.getAllResolvedPatchSourceNames(), )) } } @@ -454,6 +695,8 @@ private fun HeaderBar( onChangePatchesClick: () -> Unit, onRetry: () -> Unit, onUpdateChannelChanged: () -> Unit = {}, + onManageSourcesClick: () -> Unit = {}, + sourceStates: List = emptyList(), ) { val mono = LocalMorpheFont.current val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) @@ -495,20 +738,12 @@ private fun HeaderBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - PatchesVersionInline( - patchesVersion = uiState.patchesVersion!!, - latestLabel = uiState.latestPatchesLabel, - onChangePatchesClick = onChangePatchesClick, - patchSourceName = uiState.patchSourceName - ) - } else if (uiState.isLoadingPatches) { + if (uiState.isLoadingPatches) { PatchesLoadingIndicator() - } else if (uiState.patchLoadError != null) { - PatchesVersionInline( - patchesVersion = "NOT LOADED", - latestLabel = null, - onChangePatchesClick = onChangePatchesClick + } else { + SourcesCountPill( + sourceStates = sourceStates, + onClick = onManageSourcesClick, ) } @@ -612,6 +847,48 @@ private fun PatchesVersionInline( } } +/** One-time intro banner shown when the user first sees multi-source mode. + * Persists dismissal in ConfigRepository so it never reappears once dismissed. */ +@Composable +private fun MultiSourceHintBanner( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + Row( + modifier = modifier + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accents.primary.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) + .background(accents.primary.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "MULTIPLE SOURCES ACTIVE — patches from every enabled source are unioned. Manage from the SOURCES button above.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + letterSpacing = 0.2.sp, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Dismiss", + tint = accents.primary, + modifier = Modifier.size(14.dp), + ) + } + } +} + +// SourcesCountPill, SourceLed, SourceLedState, sourceLedState moved to +// gui/ui/components/SourcesPill.kt for reuse across modes (Quick Patch uses +// a non-clickable variant). + @Composable private fun PatchesLoadingIndicator() { val mono = LocalMorpheFont.current @@ -680,7 +957,8 @@ private fun MiddleContent( patchesLoaded: Boolean, onClearClick: () -> Unit, onChangeClick: () -> Unit, - onContinueClick: () -> Unit + onContinueClick: () -> Unit, + patchSourceNames: List = emptyList(), ) { when { uiState.isAnalyzing -> { @@ -693,7 +971,8 @@ private fun MiddleContent( isCompact = isCompact, onClearClick = onClearClick, onChangeClick = onChangeClick, - onContinueClick = onContinueClick + onContinueClick = onContinueClick, + patchSourceNames = patchSourceNames, ) } else -> { @@ -817,7 +1096,8 @@ private fun ApkSelectedSection( isCompact: Boolean, onClearClick: () -> Unit, onChangeClick: () -> Unit, - onContinueClick: () -> Unit + onContinueClick: () -> Unit, + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -834,7 +1114,8 @@ private fun ApkSelectedSection( ApkInfoCard( apkInfo = apkInfo, onClearClick = onClearClick, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + patchSourceNames = patchSourceNames, ) Spacer(modifier = Modifier.height(if (isCompact) 16.dp else 20.dp)) @@ -989,8 +1270,219 @@ private fun AnalyzingSection(isCompact: Boolean = false) { // ════════════════════════════════════════════════════════════════════ /** - * Bottom section — horizontal scrolling cards. + * Vertical-list variant of the supported-apps display used in the side-by-side + * layout. Search field at top, scrollable LazyColumn of [SupportedAppListRow] + * below. Single-expand semantics — clicking a row expands it and collapses any + * previously-expanded one. */ +@Composable +private fun SupportedAppsListPane( + supportedApps: List, + sourceNamesByPackage: Map>, + isLoading: Boolean, + loadError: String?, + onRetry: () -> Unit, + isCompact: Boolean, + modifier: Modifier = Modifier, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + var searchQuery by remember { mutableStateOf("") } + var expandedPackage by remember { mutableStateOf(null) } + + val filtered = if (searchQuery.isBlank()) supportedApps + else supportedApps.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + } + + // Collapse if the currently expanded app filters out. + LaunchedEffect(searchQuery, filtered) { + if (expandedPackage != null && filtered.none { it.packageName == expandedPackage }) { + expandedPackage = null + } + } + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val paneMaxHeight = maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .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), + ) + } + } + + // ── Search field ── + if (supportedApps.size > 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. + Box(modifier = Modifier.fillMaxWidth().padding(end = 12.dp)) { + SlimSearchField( + value = searchQuery, + onValueChange = { searchQuery = it }, + mono = mono, + corners = corners, + accents = accents, + maxWidth = Dp.Unspecified, + ) + } + Spacer(modifier = Modifier.height(10.dp)) + } + + when { + isLoading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(4) { idx -> + SkeletonAppRow( + corners = corners, + // Slight stagger: each row pulses 120ms after the previous + // so the skeleton list feels alive instead of lock-step. + staggerOffsetMs = idx * 120, + ) + } + } + } + loadError != null -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + ) { + Text( + text = "LOAD FAILED", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + letterSpacing = 1.sp, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = loadError, + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.6f), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + OutlinedButton( + onClick = onRetry, + shape = RoundedCornerShape(corners.small), + ) { + Text( + "RETRY", + fontFamily = mono, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + ) + } + } + } + filtered.isEmpty() -> { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (searchQuery.isBlank()) "No supported apps" + else "No apps match \"$searchQuery\"", + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f), + ) + } + } + else -> { + val listState = rememberLazyListState() + // Cap the list at the pane's available height (minus a header + // + optional search allowance) so it scrolls when there are + // many apps but wraps tight + lets the Column center when few. + // Tight estimate: header ~22dp; search field (only shown when + // >4 apps) ~46dp. Anything over-budgeted leaves dead space + // above the list when content fills, so be precise. + val headerSearchAllowance = + if (supportedApps.size > 4) 68.dp else 22.dp + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn( + max = (paneMaxHeight - headerSearchAllowance) + .coerceAtLeast(120.dp) + ), + ) { + androidx.compose.foundation.lazy.LazyColumn( + state = listState, + // Scrollbar is 6dp wide and sits at the Box's right edge. + // 6 (scrollbar width) + 6 (visible gap) = 12dp keeps content + // fully clear of the scrollbar with breathing room. + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(items = filtered, key = { it.packageName }) { app -> + SupportedAppListRow( + app = app, + isExpanded = expandedPackage == app.packageName, + onClick = { + expandedPackage = if (expandedPackage == app.packageName) null + else app.packageName + }, + patchSourceNames = sourceNamesByPackage[app.packageName] ?: emptyList(), + ) + } + } + // Wrap the scrollbar in a matchParentSize Box so it + // tracks the LazyColumn's wrapped height WITHOUT forcing + // the outer Box to fill its heightIn(max=…) cap. Then + // align CenterEnd + wrap width to keep it pinned at the + // right edge at its natural 6dp thickness. + Box( + modifier = Modifier.matchParentSize(), + contentAlignment = Alignment.CenterEnd, + ) { + VerticalScrollbar( + modifier = Modifier.fillMaxHeight(), + adapter = rememberScrollbarAdapter(listState), + style = morpheScrollbarStyle(), + ) + } + } + } + } + } + } +} + @Composable private fun SupportedAppsSection( isCompact: Boolean = false, @@ -999,7 +1491,10 @@ private fun SupportedAppsSection( isDefaultSource: Boolean = true, supportedApps: List = emptyList(), loadError: String? = null, - onRetry: () -> Unit = {} + onRetry: () -> Unit = {}, + /** packageName → source display names contributing patches. Used to badge + * cards with their source attribution in multi-source mode. */ + sourceNamesByPackage: Map> = emptyMap(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -1114,65 +1609,13 @@ private fun SupportedAppsSection( } if (supportedApps.size > 4) { - if (isDefaultSource) { - // Default search field for Morphe-source patches. - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - placeholder = { - Text( - "Filter apps…", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.4f) - ) - }, - leadingIcon = { - Icon( - Icons.Default.Search, - contentDescription = null, - tint = homeMutedTextColor(0.6f), - modifier = Modifier.size(16.dp) - ) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { searchQuery = "" }) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = homeMutedTextColor(0.5f), - modifier = Modifier.size(14.dp) - ) - } - } - }, - singleLine = true, - textStyle = MaterialTheme.typography.bodySmall.copy( - fontFamily = mono, - fontSize = 11.sp - ), - shape = RoundedCornerShape(corners.small), - modifier = Modifier - .widthIn(max = 260.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), - cursorColor = accents.primary - ) - ) - } else { - // Slim, elongated search field for third-party patches. - // Uses BasicTextField + a custom decoration so we can break - // out of OutlinedTextField's 56dp minimum height. - SlimSearchField( - value = searchQuery, - onValueChange = { searchQuery = it }, - mono = mono, - corners = corners, - accents = accents - ) - } + SlimSearchField( + value = searchQuery, + onValueChange = { searchQuery = it }, + mono = mono, + corners = corners, + accents = accents + ) Spacer(modifier = Modifier.height(12.dp)) } @@ -1208,6 +1651,7 @@ private fun SupportedAppsSection( onClose = { selectedApp = null }, isDefaultSource = isDefaultSource, useVerticalLayout = useVerticalLayout, + sourceNamesByPackage = sourceNamesByPackage, modifier = Modifier .fillMaxWidth() .padding(horizontal = if (isCompact) 8.dp else 16.dp) @@ -1424,7 +1868,8 @@ private fun SupportedAppsMasterDetail( onClose: () -> Unit, isDefaultSource: Boolean, useVerticalLayout: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + sourceNamesByPackage: Map> = emptyMap(), ) { val cardSpacing = 10.dp @@ -1446,7 +1891,8 @@ private fun SupportedAppsMasterDetail( app = app, isSelected = app.packageName == selectedApp?.packageName, onClick = { onSelect(app) }, - isDefaultSource = isDefaultSource + isDefaultSource = isDefaultSource, + patchSourceNames = sourceNamesByPackage[app.packageName] ?: emptyList(), ) } } @@ -1481,7 +1927,8 @@ private fun SupportedAppVerticalCard( isSelected: Boolean, onClick: () -> Unit, isDefaultSource: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -1490,7 +1937,9 @@ private fun SupportedAppVerticalCard( // ── Dimensions ── val collapsedWidth = 188.dp val expandedExtraWidth = 320.dp - val cardHeight = if (isDefaultSource) 250.dp else 190.dp + // Uniform height across all cards — every card shows the EXPERIMENTAL row + // (with "—" when none) so they line up visually in the row. + val cardHeight = 250.dp // ── Animations ── val animatedExtraWidth by animateDpAsState( @@ -1600,19 +2049,17 @@ private fun SupportedAppVerticalCard( nullLabel = "Any version" ) - // Experimental row only for default (Morphe) patch sources. - // Third-party patches don't get experimental support here. - if (isDefaultSource) { - Spacer(modifier = Modifier.height(12.dp)) - VersionWithDownload( - channelLabel = "EXPERIMENTAL LATEST", - channelColor = accents.warning, - version = latestExperimental, - downloadUrl = if (hasExperimental) app.experimentalDownloadUrl else null, - mono = mono, - corners = corners - ) - } + // Always show the EXPERIMENTAL row — when the app has no experimental + // version, VersionWithDownload renders "—" via its nullLabel default. + Spacer(modifier = Modifier.height(12.dp)) + VersionWithDownload( + channelLabel = "EXPERIMENTAL LATEST", + channelColor = accents.warning, + version = if (hasExperimental) latestExperimental else null, + downloadUrl = if (hasExperimental) app.experimentalDownloadUrl else null, + mono = mono, + corners = corners + ) } // ════════════════════════════════════════════════ @@ -1627,11 +2074,20 @@ private fun SupportedAppVerticalCard( .background(borderColor) ) - Column( + // Right-panel content can overflow the fixed cardHeight when an app + // has lots of versions or sources. Wrap in a Box with a scrollable + // Column + vertical scrollbar so users can reach everything. + val rightPanelScroll = rememberScrollState() + Box( modifier = Modifier .width((animatedExtraWidth - 1.dp).coerceAtLeast(0.dp)) .fillMaxHeight() - .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rightPanelScroll) + .padding(start = 16.dp, end = 22.dp, top = 16.dp, bottom = 16.dp) ) { // ── Package name + close ── Row( @@ -1686,6 +2142,61 @@ private fun SupportedAppVerticalCard( Spacer(modifier = Modifier.height(12.dp)) + // ── PATCHES FROM (sources contributing patches for this app) ── + // Always shown for visual consistency. Renders "—" if no source + // attribution data is available for this app. + Text( + text = "PATCHES FROM", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary.copy(alpha = 0.85f), + letterSpacing = 1.2.sp + ) + Spacer(modifier = Modifier.height(6.dp)) + if (patchSourceNames.isNotEmpty()) { + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + patchSourceNames.forEach { name -> + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small), + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small), + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = name, + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 0.3.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + } + } else { + Text( + text = "—", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.35f) + ) + } + Spacer(modifier = Modifier.height(14.dp)) + // ── ALSO STABLE tags ── Text( text = "ALSO STABLE", @@ -1730,54 +2241,65 @@ private fun SupportedAppVerticalCard( ) } - if (isDefaultSource) { - Spacer(modifier = Modifier.height(14.dp)) - - // ── EXPERIMENTAL tags (Morphe-source patches only) ── + // ── EXPERIMENTAL tags ── + // Always shown for visual consistency across cards. Renders "—" + // when this app has no experimental versions in the loaded patches. + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = "EXPERIMENTAL", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.warning.copy(alpha = 0.85f), + letterSpacing = 1.2.sp + ) + Spacer(modifier = Modifier.height(6.dp)) + if (app.experimentalVersions.isNotEmpty()) { + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + app.experimentalVersions.take(8).forEach { version -> + VersionPill( + version = version, + color = accents.warning, + mono = mono, + corners = corners + ) + } + if (app.experimentalVersions.size > 8) { + Text( + text = "+${app.experimentalVersions.size - 8}", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) + ) + } + } + } else { Text( - text = "EXPERIMENTAL", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, + text = "—", + fontSize = 10.sp, fontFamily = mono, - color = accents.warning.copy(alpha = 0.85f), - letterSpacing = 1.2.sp + color = homeMutedTextColor(0.35f) ) - Spacer(modifier = Modifier.height(6.dp)) - if (app.experimentalVersions.isNotEmpty()) { - @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - app.experimentalVersions.take(8).forEach { version -> - VersionPill( - version = version, - color = accents.warning, - mono = mono, - corners = corners - ) - } - if (app.experimentalVersions.size > 8) { - Text( - text = "+${app.experimentalVersions.size - 8}", - fontSize = 10.sp, - fontFamily = mono, - color = homeMutedTextColor(0.5f), - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - ) - } - } - } else { - Text( - text = "none", - fontSize = 10.sp, - fontFamily = mono, - color = homeMutedTextColor(0.35f) - ) - } } } + // Vertical scrollbar — only shows when content overflows. + if (rightPanelScroll.maxValue > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 6.dp), + adapter = rememberScrollbarAdapter(rightPanelScroll), + style = morpheScrollbarStyle() + ) + } + } } } } @@ -1886,8 +2408,10 @@ private fun SlimSearchField( onValueChange: (String) -> Unit, mono: androidx.compose.ui.text.font.FontFamily, corners: app.morphe.gui.ui.theme.MorpheCornerStyle, - accents: app.morphe.gui.ui.theme.MorpheAccentColors + accents: app.morphe.gui.ui.theme.MorpheAccentColors, + maxWidth: Dp = 340.dp, ) { + val dimens = LocalMorpheDimens.current val muted = MaterialTheme.colorScheme.onSurfaceVariant val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -1910,9 +2434,9 @@ private fun SlimSearchField( ), cursorBrush = SolidColor(accents.primary), modifier = Modifier - .widthIn(max = 360.dp) + .widthIn(max = maxWidth) .fillMaxWidth() - .height(34.dp) + .height(dimens.controlHeight) .clip(RoundedCornerShape(corners.small)) .border(1.dp, borderColor, RoundedCornerShape(corners.small)), decorationBox = { innerTextField -> @@ -2059,3 +2583,81 @@ private fun openFilePicker(): File? { null } } + +// ════════════════════════════════════════════════════════════════════ +// LOADING SKELETON — ghost row that mimics SupportedAppListRow's shape +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun SkeletonAppRow( + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + staggerOffsetMs: Int, +) { + val infinite = rememberInfiniteTransition(label = "skeletonPulse") + val alpha by infinite.animateFloat( + initialValue = 0.06f, + targetValue = 0.16f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, delayMillis = staggerOffsetMs), + repeatMode = RepeatMode.Reverse, + ), + label = "skeletonAlpha", + ) + val baseColor = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) + val cardBg = MaterialTheme.colorScheme.surface.copy(alpha = 0.4f) + val outline = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .background(cardBg) + .border(1.dp, outline, RoundedCornerShape(corners.medium)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Row 1: avatar + name/package bars + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Spacer(Modifier.width(10.dp)) + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Box( + modifier = Modifier + .height(10.dp) + .width(140.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Box( + modifier = Modifier + .height(8.dp) + .width(180.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor.copy(alpha = alpha * 0.6f)), + ) + } + } + // Row 2: chip placeholders + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .height(20.dp) + .width(110.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Box( + modifier = Modifier + .height(20.dp) + .width(130.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor.copy(alpha = alpha * 0.7f)), + ) + } + } +} 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 f06766f3..864a8269 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 @@ -22,7 +22,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import net.dongliu.apk.parser.ApkFile +import app.morphe.engine.util.ApkManifestReader +import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService @@ -47,19 +48,40 @@ class HomeViewModel( // Cached patches and supported apps private var cachedPatches: List = emptyList() private var cachedPatchesFile: File? = null + /** All resolved patch files across enabled sources. Single-element in + * single-source mode. Exposed via [getAllResolvedPatchFiles] for screens + * that navigate downstream and need to pass the full set. */ + private var cachedAllPatchFiles: List = emptyList() private var loadJob: Job? = null + fun getAllResolvedPatchFiles(): List = + cachedAllPatchFiles.takeIf { it.isNotEmpty() } + ?: listOfNotNull(cachedPatchesFile) + + /** Display names for each entry in [getAllResolvedPatchFiles], in the same + * order. Used by PatchSelectionScreen to badge patches with their source. */ + fun getAllResolvedPatchSourceNames(): List = + cachedSourcesResult + ?.resolved + ?.filter { it.patchFile != null } + ?.map { it.source.name } + ?: emptyList() + init { // Auto-fetch patches on startup loadPatchesAndSupportedApps() // Background CLI update check — non-blocking, banner only. screenModelScope.launch { + val config = configRepository.loadConfig() val info = updateCheckRepository.getUpdateInfo() - val dismissed = configRepository.loadConfig().dismissedUpdateVersion + val dismissed = config.dismissedUpdateVersion + val multiSourceShouldShow = !config.multiSourceHintDismissed && + patchSourceManager.getEnabledSourcesSync().size > 1 _uiState.value = _uiState.value.copy( updateInfo = info, dismissedUpdateVersion = dismissed, + showMultiSourceHint = multiSourceShouldShow, ) } @@ -114,6 +136,16 @@ class HomeViewModel( _uiState.value = _uiState.value.copy(updateBannerSessionDismissed = true) } + /** + * Dismiss the multi-source intro hint persistently. One-shot. + */ + fun dismissMultiSourceHint() { + _uiState.value = _uiState.value.copy(showMultiSourceHint = false) + screenModelScope.launch { + configRepository.setMultiSourceHintDismissed() + } + } + /** * Hide the update banner persistently for the current available version. * The banner will reappear automatically when an even newer version becomes @@ -129,108 +161,45 @@ class HomeViewModel( // Track the last loaded version to avoid reloading unnecessarily private var lastLoadedVersion: String? = null + // Snapshot of per-source pinned versions used in the last load — drives + // refreshPatchesIfNeeded so we reload when ANY source's pin changes. + private var lastLoadedVersionsBySource: Map = emptyMap() /** - * Load patches from GitHub and extract supported apps. - * If a saved version exists in config, load that version instead of latest. + * Load patches from all enabled sources via [EnabledSourcesLoader] and build + * the union supported-apps list. Single-enabled-source case produces output + * equivalent to the pre-multi-source flow. */ private fun loadPatchesAndSupportedApps(forceRefresh: Boolean = false) { loadJob?.cancel() loadJob = screenModelScope.launch { _uiState.value = _uiState.value.copy(isLoadingPatches = true, patchLoadError = null) - // LOCAL source: skip GitHub entirely, load directly from the .mpp file - if (localPatchFilePath != null) { - val localFile = File(localPatchFilePath) - if (localFile.exists()) { - loadPatchesFromFile(localFile, localFile.nameWithoutExtension, latestVersion = null, isOffline = false) - } else { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Local patch file not found: ${localFile.name}" - ) - } - return@launch - } - try { - // Check if there's a saved patches version in config - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion - - // 1. Fetch all releases to find the right one - val releasesResult = patchRepository.fetchReleases() - val releases = releasesResult.getOrNull() - - if (releases.isNullOrEmpty()) { - // Try to fall back to cached .mpp file when offline - val offlinePatchFile = findCachedPatchFile(savedVersion) - if (offlinePatchFile != null) { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile), latestVersion = null) - return@launch - } - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Could not fetch patches: ${releasesResult.exceptionOrNull()?.message}" - ) - return@launch - } - - // Find the latest stable release for reference - val latestStable = releases.firstOrNull { !it.isDevRelease() } - val latestVersion = latestStable?.tagName - val latestDevVersion = releases.firstOrNull { it.isDevRelease() }?.tagName - - // 2. Find the release to use - prefer saved version, fallback to latest stable - val release = if (savedVersion != null) { - releases.find { it.tagName == savedVersion } - ?: latestStable // Fallback to latest stable - } else { - latestStable // Latest stable - } - - if (release == null) { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "No suitable release found" - ) - return@launch - } - - // Skip reload if we've already loaded this version (unless forced) - if (!forceRefresh && lastLoadedVersion == release.tagName && cachedPatchesFile?.exists() == true) { - Logger.info("Skipping reload - already loaded version ${release.tagName}") - _uiState.value = _uiState.value.copy(isLoadingPatches = false) - return@launch - } - - Logger.info("Loading patches version: ${release.tagName} (saved=$savedVersion)") - - // 3. Download patches - val patchFileResult = patchRepository.downloadPatches(release) - val patchFile = patchFileResult.getOrNull() - - if (patchFile == null) { + val enabled = patchSourceManager.getEnabledRepositories() + if (enabled.isEmpty()) { _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = "Could not download patches: ${patchFileResult.exceptionOrNull()?.message}" + patchLoadError = "No patch sources enabled. Add or enable a source from the home screen." ) return@launch } - cachedPatchesFile = patchFile - lastLoadedVersion = release.tagName - - // 3. Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches == null || patches.isEmpty()) { - val rawError = patchesResult.exceptionOrNull()?.message ?: "Unknown error" - val friendlyError = if (rawError.contains("zip", ignoreCase = true) || rawError.contains("END header", ignoreCase = true)) { + // Per-source pinned versions (with one-time migration from legacy + // single-source field). Each source's resolver looks up its own pin; + // no cross-source contamination. + val preferredVersions = configRepository.getLastPatchesVersionsBySource() + lastLoadedVersionsBySource = preferredVersions + val result = EnabledSourcesLoader.loadAll(enabled, patchService, preferredVersions) + + if (!result.anyLoaded) { + val firstError = result.resolved.firstNotNullOfOrNull { it.error } + ?: result.loaded.perSource.firstNotNullOfOrNull { it.error?.message } + ?: "Could not load any patches" + val friendlyError = if (firstError.contains("zip", ignoreCase = true) || firstError.contains("END header", ignoreCase = true)) { "Patch file is missing or corrupted. Clear cache and re-download." } else { - "Could not load patches: $rawError" + firstError } _uiState.value = _uiState.value.copy( isLoadingPatches = false, @@ -239,36 +208,50 @@ class HomeViewModel( return@launch } - cachedPatches = patches + cachedPatches = result.unionGuiPatches + // Preserve existing single-file API for downstream navigation. In + // multi-source mode this points at the first resolved source; the + // full list is exposed via [getAllResolvedPatchFiles] and the + // per-source data via [getResolvedSourcesSnapshot]. + val firstResolved = result.resolved.firstOrNull { it.patchFile != null } + cachedPatchesFile = firstResolved?.patchFile + cachedAllPatchFiles = result.resolved.mapNotNull { it.patchFile } + lastLoadedVersion = firstResolved?.resolvedVersion + cachedSourcesResult = result + + val supportedApps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + Logger.info( + "Loaded ${supportedApps.size} supported apps from " + + "${result.resolved.count { it.patchFile != null }} source(s): " + + supportedApps.map { it.displayName } + ) - // 5. Extract supported apps - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - Logger.info("Loaded ${supportedApps.size} supported apps from patches: ${supportedApps.map { "${it.displayName} (${it.recommendedVersion})" }}") + // Only flag the whole UI as offline when EVERY successfully-resolved + // source had to fall back to its cache. One source being offline + // while others are online shouldn't make the whole screen scream + // "offline" — that's a per-source state, surfaced in the sheet. + val resolvedSources = result.resolved.filter { it.patchFile != null } + val isOffline = resolvedSources.isNotEmpty() && resolvedSources.all { it.isOffline } + val displayVersion = firstResolved?.resolvedVersion + val sourceName = if (result.resolved.size == 1) { + firstResolved?.source?.name ?: patchSourceManager.getActiveSourceName() + } else { + "${result.resolved.count { it.patchFile != null }} sources" + } _uiState.value = _uiState.value.copy( isLoadingPatches = false, - isOffline = false, + isOffline = isOffline, supportedApps = supportedApps, - patchesVersion = release.tagName, - latestPatchesVersion = latestVersion, - latestDevPatchesVersion = latestDevVersion, - patchSourceName = patchSourceManager.getActiveSourceName(), + patchesVersion = displayVersion, + latestPatchesVersion = displayVersion, + latestDevPatchesVersion = null, + patchSourceName = sourceName, patchLoadError = null ) reanalyzeSelectedApk() } catch (e: Exception) { Logger.error("Failed to load patches and supported apps", e) - // Try to fall back to cached .mpp file - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - try { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile), latestVersion = null) - return@launch - } catch (inner: Exception) { - Logger.error("Failed to load cached patches fallback", inner) - } - } _uiState.value = _uiState.value.copy( isLoadingPatches = false, patchLoadError = e.message ?: "Unknown error" @@ -278,79 +261,11 @@ class HomeViewModel( } /** - * Find any cached .mpp file when offline. - * Prefers the file matching savedVersion from config. - * Searches the per-source cache directory. - */ - private fun findCachedPatchFile(savedVersion: String?): File? { - val patchesDir = patchRepository.getCacheDir() - val patchFiles = patchesDir.listFiles { file -> - val ext = file.extension.lowercase() - ext == "mpp" || ext == "jar" - }?.filter { it.length() > 0 } ?: return null - - if (patchFiles.isEmpty()) return null - - return if (savedVersion != null) { - // Strip "v" prefix — savedVersion is "v1.13.0" but filenames are "patches-1.13.0.mpp" - val versionNumber = savedVersion.removePrefix("v") - patchFiles.firstOrNull { it.name.contains(versionNumber, ignoreCase = true) } - ?: patchFiles.maxByOrNull { it.lastModified() } - } else { - patchFiles.maxByOrNull { it.lastModified() } - } - } - - /** - * Extract a version string from an .mpp filename (e.g. "morphe-patches-1.3.0.mpp" -> "v1.3.0"). + * Snapshot of the most recent multi-source load. Used by 9d's + * PatchSelectionViewModel migration to render badged per-source patches. */ - private fun versionFromFilename(file: File): String { - val name = file.nameWithoutExtension - // Try to find a version pattern like 1.2.3 or v1.2.3 - val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(name) - return match?.value ?: name - } - - /** - * Load patches from a local .mpp file and update UI state. - * Used as fallback when offline with cached patches. - */ - private suspend fun loadPatchesFromFile(patchFile: File, version: String, latestVersion: String?, isOffline: Boolean = true) { - cachedPatchesFile = patchFile - lastLoadedVersion = version - - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches == null || patches.isEmpty()) { - val rawError = patchesResult.exceptionOrNull()?.message ?: "Unknown error" - val friendlyError = if (rawError.contains("zip", ignoreCase = true) || rawError.contains("END header", ignoreCase = true)) { - "Patch file is missing or corrupted. Clear cache and re-download." - } else { - "Could not load patches: $rawError" - } - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = friendlyError - ) - return - } - - cachedPatches = patches - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - Logger.info("Loaded ${supportedApps.size} supported apps from ${if (isOffline) "cached" else "local"} patches: ${patchFile.name}") - - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - isOffline = isOffline, - supportedApps = supportedApps, - patchesVersion = version, - latestPatchesVersion = latestVersion, - patchSourceName = patchSourceManager.getActiveSourceName(), - patchLoadError = null - ) - reanalyzeSelectedApk() - } + fun getResolvedSourcesSnapshot(): EnabledSourcesLoader.Result? = cachedSourcesResult + private var cachedSourcesResult: EnabledSourcesLoader.Result? = null /** * Re-runs APK analysis against the freshly-loaded `supportedApps` so the info @@ -371,17 +286,14 @@ class HomeViewModel( } /** - * Refresh patches if a different version was selected. - * Called when returning to HomeScreen from PatchesScreen. + * Refresh patches if any source's pinned version was changed (e.g. via + * PatchesScreen). Called when returning to HomeScreen from another screen. */ fun refreshPatchesIfNeeded() { screenModelScope.launch { - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion - - // If saved version differs from currently loaded version, reload - if (savedVersion != null && savedVersion != lastLoadedVersion) { - Logger.info("Patches version changed: $lastLoadedVersion -> $savedVersion, reloading...") + val saved = configRepository.getLastPatchesVersionsBySource() + if (saved != lastLoadedVersionsBySource) { + Logger.info("Patches versions changed across sources: $lastLoadedVersionsBySource -> $saved, reloading...") loadPatchesAndSupportedApps(forceRefresh = true) } } @@ -508,72 +420,206 @@ class HomeViewModel( } return try { - ApkFile(apkToParse).use { apk -> - val meta = apk.apkMeta - - val packageName = meta.packageName - val versionName = meta.versionName ?: "Unknown" - val minSdk = meta.minSdkVersion?.toIntOrNull() - - // Check if package is supported - first check dynamic, then fallback to hardcoded - val dynamicSupportedApp = _uiState.value.supportedApps.find { it.packageName == packageName } - val isSupported = dynamicSupportedApp != null || - packageName in listOf( - app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME, - app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME - ) + // ARSCLib reader (in engine) — same library morphe-patcher uses. + // Handles split APKs cleanly because we only read direct string + // attributes (no resource resolution that crashes apk-parser on + // cross-split references). + val manifest = ApkManifestReader.read(apkToParse) + ?: throw IllegalStateException("ARSCLib couldn't read manifest") + + val packageName = manifest.packageName + val versionName = manifest.versionName ?: "Unknown" + val minSdk = manifest.minSdkVersion + + // Check if package is supported — first check dynamic, then fall back to hardcoded. + val dynamicSupportedApp = _uiState.value.supportedApps.find { it.packageName == packageName } + val isSupported = dynamicSupportedApp != null || + packageName in listOf( + app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME, + app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME + ) - if (!isSupported) { - Logger.warn("Unsupported package: $packageName — no compatible patches found") - } + if (!isSupported) { + Logger.warn("Unsupported package: $packageName — no compatible patches found") + } - // Get app display name - prefer dynamic, fallback to hardcoded, then package name - val appName = dynamicSupportedApp?.displayName - ?: SupportedApp.resolveDisplayName(packageName, meta.label) + // Display name: prefer supported app's name. Fall back to ARSCLib's + // literal label (null for resource-referenced labels like SoundCloud's + // `@string/app_name`). Last resort: derived from package. + val appName = dynamicSupportedApp?.displayName + ?: SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) - // Resolve the version against the supported app's stable + - // experimental version lists. - val versionResolution = if (dynamicSupportedApp != null) { - app.morphe.gui.util.resolveVersionStatus(versionName, dynamicSupportedApp) - } else { - app.morphe.gui.util.VersionResolution(VersionStatus.UNKNOWN, null) - } - val suggestedVersion = versionResolution.suggestedVersion - val versionStatus = versionResolution.status - - // Get supported architectures from native libraries - // For split bundles, scan the original bundle (splits contain the native libs, not base.apk) - val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) - - // TODO: Re-enable when checksums are provided via .mpp files - val checksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured - - Logger.info("Parsed APK: $packageName v$versionName (recommended=$suggestedVersion, minSdk=$minSdk, archs=$architectures)") - - ApkInfo( - fileName = file.name, - filePath = file.absolutePath, - fileSize = file.length(), - formattedSize = formatFileSize(file.length()), - appName = appName, - packageName = packageName, - versionName = versionName, - architectures = architectures, - minSdk = minSdk, - suggestedVersion = suggestedVersion, - versionStatus = versionStatus, - checksumStatus = checksumStatus, - isUnsupportedApp = !isSupported - ) + val versionResolution = if (dynamicSupportedApp != null) { + app.morphe.gui.util.resolveVersionStatus(versionName, dynamicSupportedApp) + } else { + app.morphe.gui.util.VersionResolution(VersionStatus.UNKNOWN, null) } + val suggestedVersion = versionResolution.suggestedVersion + val versionStatus = versionResolution.status + + // Get supported architectures from native libraries. + // For split bundles, scan the original bundle (splits hold native libs, not base.apk). + val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) + + // TODO: Re-enable when checksums are provided via .mpp files + val checksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured + + Logger.info("Parsed APK: $packageName v$versionName (recommended=$suggestedVersion, minSdk=$minSdk, archs=$architectures)") + + ApkInfo( + fileName = file.name, + filePath = file.absolutePath, + fileSize = file.length(), + formattedSize = formatFileSize(file.length()), + appName = appName, + packageName = packageName, + versionName = versionName, + architectures = architectures, + minSdk = minSdk, + suggestedVersion = suggestedVersion, + versionStatus = versionStatus, + checksumStatus = checksumStatus, + isUnsupportedApp = !isSupported + ) } catch (e: Exception) { - Logger.error("Failed to parse APK manifest", e) - null + // apk-parser commonly chokes on split-APK base.apks whose resource + // references point into other splits (SoundCloud and similar). The + // base.apk is structurally valid — Android installs it fine, the + // patcher merges + patches it fine — but apk-parser can't resolve + // cross-split references from an isolated file. + // + // Fall back to a "limited info" parse: extract package/version from + // the filename (APKMirror naming convention), fuzzy-match supported + // apps by display name, and let the user proceed to patching + // regardless. ApkInfo.hasLimitedInfo=true so the UI can warn that + // card details may be approximate. + Logger.warn( + "Full APK manifest parse failed for ${file.name}: ${e.message}. " + + "Falling back to limited-info mode (filename heuristics + fuzzy match)." + ) + parseApkManifestMinimal(file, isBundleFormat) } finally { if (isBundleFormat) apkToParse.delete() } } + /** + * Fallback parser when full manifest parsing fails (typically split APKs with + * cross-split resource references). Recovers what it can from the filename and + * the bundle's native libs, fuzzy-matches against the supported-apps list, and + * sets [ApkInfo.hasLimitedInfo] = true so the UI can warn the user. + * + * Patching still works regardless — the patcher merges splits first and reads + * the manifest from the merged APK via its own (working) reader. + */ + private fun parseApkManifestMinimal(file: File, isBundleFormat: Boolean): ApkInfo? { + val (packageFromName, versionFromName) = parseFromApkMirrorFilename(file.name) + val supportedApps = _uiState.value.supportedApps + + // Match against supported apps: by exact package first, then fuzzy name + // on the filename's leading token (handles "soundcloud_..." → "SoundCloud"). + val matched = packageFromName + ?.let { pkg -> supportedApps.firstOrNull { it.packageName == pkg } } + ?: fuzzyMatchSupportedApp(file.name, supportedApps) + + val packageName = packageFromName ?: matched?.packageName.orEmpty() + val displayName = matched?.displayName + ?: packageFromName?.substringAfterLast('.', "") + ?.replaceFirstChar { it.uppercase() } + ?.takeIf { it.isNotBlank() } + ?: file.nameWithoutExtension + + val versionResolution = if (matched != null && versionFromName != null) { + app.morphe.gui.util.resolveVersionStatus(versionFromName, matched) + } else { + app.morphe.gui.util.VersionResolution(VersionStatus.UNKNOWN, null) + } + + // Architectures scan is independent of manifest parsing — still reliable. + val architectures = FileUtils.extractArchitectures(file) + + Logger.info( + "Limited-info parse for ${file.name}: package=$packageName, " + + "version=${versionFromName ?: "unknown"}, matched=${matched?.displayName ?: "none"}" + ) + + return ApkInfo( + fileName = file.name, + filePath = file.absolutePath, + fileSize = file.length(), + formattedSize = formatFileSize(file.length()), + appName = displayName, + packageName = packageName, + versionName = versionFromName ?: "Unknown", + architectures = architectures, + minSdk = null, + suggestedVersion = versionResolution.suggestedVersion, + versionStatus = versionResolution.status, + checksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured, + isUnsupportedApp = matched == null, + hasLimitedInfo = true, + ) + } + + /** + * Best-effort package + version extraction from APKMirror-style filenames: + * com.google.android.youtube_19.20.30-12345.apk + * → ("com.google.android.youtube", "19.20.30") + * + * Returns (null, null) when the filename doesn't look like a package_version + * pattern. The version-only path also tries a generic semver / date regex + * against the whole filename for files like `soundcloud_2026.04.27.apkm`. + */ + private fun parseFromApkMirrorFilename(filename: String): Pair { + val noExt = filename.substringBeforeLast('.') + val splitOnUnderscore = noExt.split('_', limit = 2) + + val packageCandidate = splitOnUnderscore.getOrNull(0) + val afterUnderscore = splitOnUnderscore.getOrNull(1) + + // A package name has at least one dot + only lowercase/digits/underscore in + // each segment. Filters out "soundcloud" while accepting "com.foo.bar". + val looksLikePackage = packageCandidate != null && + packageCandidate.contains('.') && + packageCandidate.split('.').all { segment -> + segment.isNotEmpty() && segment.all { c -> c.isLowerCase() || c.isDigit() || c == '_' } + } + + val packageName = if (looksLikePackage) packageCandidate else null + + // Version: prefer the token right after "_" (APKMirror convention), else + // scan the whole filename for a semver / date pattern. + val versionAfterUnderscore = afterUnderscore?.substringBefore('-')?.takeIf { it.isNotBlank() } + val version = versionAfterUnderscore + ?: Regex("""\d+\.\d+\.\d+(?:-dev\.\d+)?""").find(noExt)?.value + ?: Regex("""\d+\.\d+(?:\.\d+)?""").find(noExt)?.value + + return packageName to version + } + + /** + * Fuzzy-match the filename's leading token against supported apps' display names. + * Used when APKMirror-style filename inference fails to give us a package name. + * Examples: + * "soundcloud_2026.04.27.apkm" → leading token "soundcloud" → matches "SoundCloud" + * "YouTube Music_4.81.apkm" → leading token "youtube music" → matches "YouTube Music" + */ + private fun fuzzyMatchSupportedApp( + filename: String, + supportedApps: List, + ): app.morphe.gui.data.model.SupportedApp? { + val noExt = filename.substringBeforeLast('.').lowercase() + val leadingToken = noExt + .substringBefore('_') + .substringBefore('-') + .replace(" ", "") + if (leadingToken.isBlank()) return null + return supportedApps.firstOrNull { app -> + val name = app.displayName.lowercase().replace(" ", "") + name == leadingToken || name.startsWith(leadingToken) || leadingToken.startsWith(name) + } + } + // TODO: Re-enable checksum verification when checksums are provided via .mpp files // private fun verifyChecksum( // file: File, packageName: String, version: String, @@ -613,6 +659,9 @@ data class HomeUiState( val dismissedUpdateVersion: String? = null, /** Session-only dismiss; cleared on next app start. Not persisted. */ val updateBannerSessionDismissed: Boolean = false, + /** True when more than one source is enabled and the user hasn't dismissed + * the one-time multi-source intro hint yet. */ + val showMultiSourceHint: Boolean = false, ) { /** * Show the update banner only when an update was found AND the user hasn't @@ -655,7 +704,12 @@ data class ApkInfo( val suggestedVersion: String? = null, val versionStatus: VersionStatus = VersionStatus.UNKNOWN, val checksumStatus: app.morphe.gui.util.ChecksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured, - val isUnsupportedApp: Boolean = false + val isUnsupportedApp: Boolean = false, + /** True when full manifest parsing failed and we fell back to filename heuristics + * + fuzzy supported-app matching. Most fields are still populated but may be + * less accurate. UI should surface a banner letting the user know they can + * still proceed but card info is approximate. */ + val hasLimitedInfo: Boolean = false ) data class ApkValidationResult( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt index 3702001c..c28896a2 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* @@ -45,7 +46,10 @@ import app.morphe.gui.util.toColor fun ApkInfoCard( apkInfo: ApkInfo, onClearClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + /** Names of enabled sources whose patches target [apkInfo.packageName]. When + * more than one, surfaces the multi-source provenance directly on the card. */ + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -155,6 +159,46 @@ fun ApkInfoCard( } } + // ── Limited-info warning ── + // Surfaced when full manifest parsing failed (typically split APKs + // like SoundCloud where base.apk references resources living in + // other splits). Patching still works because the patcher merges + // splits first — this banner just tells the user the card details + // are approximate. + if (apkInfo.hasLimitedInfo) { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .background(accents.warning.copy(alpha = 0.06f)) + .padding(start = 23.dp, end = 20.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = accents.warning, + modifier = Modifier.size(16.dp) + ) + Text( + text = + "Couldn't fully read this APK's manifest (common for split bundles). " + + "Details below are approximate, patching should still work.", + fontSize = 11.sp, + color = accents.warning, + lineHeight = 14.sp, + ) + } + } + // ── Unsupported app warning ── if (apkInfo.isUnsupportedApp) { Row( @@ -286,6 +330,66 @@ fun ApkInfoCard( } } + // ── Patch sources providing patches for this app ── + if (patchSourceNames.isNotEmpty()) { + 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(start = 23.dp, end = 20.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "FROM", + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.width(4.dp)) + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + patchSourceNames.forEach { name -> + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small), + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small), + ) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + text = name, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 0.3.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + } + } + } + // ── Status bar ── val statusDisplay = resolveVersionStatusDisplay( apkInfo.versionStatus, apkInfo.checksumStatus, apkInfo.suggestedVersion 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 new file mode 100644 index 00000000..e1e9dab6 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt @@ -0,0 +1,413 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.screens.home.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +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.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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.SupportedApp +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.util.DownloadUrlResolver.openUrlAndFollowRedirects + +/** + * Vertical-list-friendly supported-app row. Two-row collapsed layout: + * row 1: initial badge + app name + package name (muted) + * row 2: STABLE LATEST chip + EXPERIMENTAL LATEST chip (or "—") + * + * Whole row is clickable (Phase 3 hooks expansion to it). Version chips are + * also tappable as quick-download shortcuts — their clicks are consumed so + * they don't bubble up and trigger the row click. + */ +@Composable +fun SupportedAppListRow( + app: SupportedApp, + onClick: () -> Unit = {}, + isExpanded: Boolean = false, + /** 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(), + modifier: Modifier = Modifier, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hoverInteraction = remember(app.packageName) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + val borderColor by animateColorAsState( + targetValue = when { + isExpanded -> accents.primary.copy(alpha = 0.45f) + isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + animationSpec = tween(150), + label = "rowBorder", + ) + val backgroundColor by animateColorAsState( + targetValue = when { + isExpanded -> accents.primary.copy(alpha = 0.05f) + isHovered -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + else -> MaterialTheme.colorScheme.surface + }, + animationSpec = tween(150), + label = "rowBg", + ) + + val initial = app.displayName.firstOrNull()?.uppercase() ?: "?" + val hasStable = app.recommendedVersion != null + val hasExperimental = app.experimentalVersions.isNotEmpty() + val latestExperimental = app.experimentalVersions.firstOrNull() + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(backgroundColor) + .hoverable(hoverInteraction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // ── Row 1: initial + name + package ── + 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( + text = initial, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + ) + } + Spacer(Modifier.width(10.dp)) + Text( + text = app.displayName, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = app.packageName, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + // ── Row 2: STABLE LATEST + EXPERIMENTAL LATEST chips ── + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + VersionChip( + channelLabel = "STABLE LATEST", + version = app.recommendedVersion, + color = accents.secondary, + // Pass the URL through unconditionally — when recommendedVersion + // is null (patches work on Any version), the URL still points to + // the app's general APKMirror page and stays clickable. + downloadUrl = app.apkDownloadUrl, + nullLabel = "Any", + mono = mono, + cornerSmall = corners.small, + ) + VersionChip( + channelLabel = "EXPERIMENTAL LATEST", + version = latestExperimental, + color = accents.warning, + downloadUrl = app.experimentalDownloadUrl, + nullLabel = "—", + mono = mono, + cornerSmall = corners.small, + ) + } + + // ── Expanded body: PATCHES FROM + ALSO STABLE + EXPERIMENTAL pills ── + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(animationSpec = tween(220), expandFrom = Alignment.Top) + + fadeIn(animationSpec = tween(180)), + exit = shrinkVertically(animationSpec = tween(180), shrinkTowards = Alignment.Top) + + fadeOut(animationSpec = tween(120)), + ) { + ExpandedBody( + app = app, + patchSourceNames = patchSourceNames, + accents = accents, + mono = mono, + cornerSmall = corners.small, + ) + } + } +} + +/** + * Channel label + version pair. When [downloadUrl] is non-null and [version] is + * present, the chip becomes a clickable quick-download (with hand cursor + open- + * in-new icon). When [version] is null, renders "—" in a muted style with no + * click affordance. + * + * The chip's clickable consumes the press — clicking it does NOT bubble up to + * the row's clickable, so quick-downloading doesn't accidentally expand the row. + */ +@Composable +private fun VersionChip( + channelLabel: String, + version: String?, + color: Color, + downloadUrl: String?, + nullLabel: String, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, +) { + // A chip is a clickable download link whenever the URL is present, even if + // the version is null ("Any" label still routes to the app's general page). + val isLink = downloadUrl != null + val uriHandler = LocalUriHandler.current + val hoverInteraction = remember(channelLabel) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + targetValue = when { + isLink && isHovered -> color.copy(alpha = 0.55f) + isLink -> color.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + }, + animationSpec = tween(150), + label = "chipBorder", + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(RoundedCornerShape(cornerSmall)) + .border(1.dp, borderColor, RoundedCornerShape(cornerSmall)) + .background( + if (isLink) color.copy(alpha = 0.06f) + else Color.Transparent + ) + .hoverable(hoverInteraction) + .then( + if (isLink) Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + openUrlAndFollowRedirects(downloadUrl!!) { uriHandler.openUri(it) } + } + else Modifier + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + text = channelLabel, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.6.sp, + color = if (isLink) color + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Text( + text = "·", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + ) + Text( + text = version?.let { if (it.startsWith("v")) it else "v$it" } ?: nullLabel, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = if (isLink) color + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), + ) + if (isLink) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Download $channelLabel", + tint = color, + modifier = Modifier.size(10.dp), + ) + } + } +} + +/** Body that drops down below the collapsed row when [SupportedAppListRow.isExpanded] + * is true. Sections: PATCHES FROM, ALSO STABLE, EXPERIMENTAL. */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun ExpandedBody( + app: SupportedApp, + patchSourceNames: List, + accents: app.morphe.gui.ui.theme.MorpheAccentColors, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, +) { + // "Other stable" = supported versions other than the recommended latest. + val otherStable = app.supportedVersions.filter { it != app.recommendedVersion } + val maxPills = 16 + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (patchSourceNames.isNotEmpty()) { + SectionLabel(text = "PATCHES FROM", color = accents.primary, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + patchSourceNames.forEach { name -> + // Source pills use a bright near-white label (vs. the colored + // text used by version pills below) so the source name reads + // crisply without feeling dimmed. The accent still shows in + // the border / subtle background tint. + Pill( + text = name, + color = accents.primary, + mono = mono, + cornerSmall = cornerSmall, + textColor = MaterialTheme.colorScheme.onSurface, + borderAlpha = 0.45f, + backgroundAlpha = 0.10f, + ) + } + } + } + + if (otherStable.isNotEmpty()) { + SectionLabel(text = "ALSO STABLE", color = accents.secondary, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + otherStable.take(maxPills).forEach { v -> + Pill(text = v, color = accents.secondary, mono = mono, cornerSmall = cornerSmall) + } + if (otherStable.size > maxPills) { + Text( + text = "+${otherStable.size - maxPills}", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + } + } + + if (app.experimentalVersions.isNotEmpty()) { + SectionLabel(text = "EXPERIMENTAL", color = accents.warning, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + app.experimentalVersions.take(maxPills).forEach { v -> + Pill(text = v, color = accents.warning, mono = mono, cornerSmall = cornerSmall) + } + if (app.experimentalVersions.size > maxPills) { + Text( + text = "+${app.experimentalVersions.size - maxPills}", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + } + } + } +} + +@Composable +private fun SectionLabel( + text: String, + color: Color, + mono: androidx.compose.ui.text.font.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 Pill( + text: String, + color: Color, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, + textColor: Color = color, + borderAlpha: Float = 0.3f, + backgroundAlpha: Float = 0.06f, +) { + Box( + modifier = Modifier + .border(1.dp, color.copy(alpha = borderAlpha), RoundedCornerShape(cornerSmall)) + .background(color.copy(alpha = backgroundAlpha), RoundedCornerShape(cornerSmall)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = textColor, + maxLines = 1, + ) + } +} 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 91345b5f..9f7bbc59 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 @@ -79,15 +79,25 @@ import java.io.File data class PatchSelectionScreen( val apkPath: String, val apkName: String, + /** Primary .mpp file path. Always non-null. In multi-source mode, the first + * enabled source's file. Used for legacy/single-source code paths and as + * the default when [patchesFilePaths] is empty. */ val patchesFilePath: String, val packageName: String, - val apkArchitectures: List = emptyList() + val apkArchitectures: List = emptyList(), + /** All enabled-source .mpp file paths. Single-element in single-source mode. + * Used by the patching pipeline to feed the engine the union of patches. */ + val patchesFilePaths: List = emptyList(), + /** Parallel to [patchesFilePaths] — display name per source. Drives badging + * in the patch list. Empty disables badging (legacy single-source). */ + val patchSourceNames: List = emptyList(), ) : Screen { @Composable override fun Content() { + val effectiveList = patchesFilePaths.takeIf { it.isNotEmpty() } ?: listOf(patchesFilePath) val viewModel = koinScreenModel { - parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures) + parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures, effectiveList, patchSourceNames) } PatchSelectionScreenContent(viewModel = viewModel) } @@ -110,7 +120,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { var keystoreEntryPassword by remember { mutableStateOf(null) } LaunchedEffect(Unit) { val config = configRepository.loadConfig() - keystorePath = config.keystorePath + keystorePath = config.resolvedKeystorePath()?.absolutePath keystorePassword = config.keystorePassword keystoreAlias = config.keystoreAlias keystoreEntryPassword = config.keystoreEntryPassword @@ -350,19 +360,34 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) ) - // Selection-mode chips: Your Defaults · Patch Defaults · All · None + // Global selection-mode chips: only meaningful when there's exactly + // ONE bundle. Multi-bundle moves these chips INTO each bundle box + // so each source can be managed independently. The deprecated + // applySaved/applyDefaults/selectAll/deselectAll methods loop over + // bundles — for a single bundle, they're equivalent to the per- + // bundle methods. + val isSingleBundle = uiState.bundles.size == 1 AnimatedVisibility( - visible = !uiState.isLoading && uiState.allPatches.isNotEmpty(), + visible = !uiState.isLoading && isSingleBundle && uiState.bundles.firstOrNull()?.patches?.isNotEmpty() == true, enter = expandVertically(), exit = shrinkVertically() ) { + val activeBundleId = uiState.bundles.firstOrNull()?.bundleId SelectionModeChips( hasSavedSelection = uiState.hasSavedSelection, - activeMode = uiState.activeSelectionMode, - onApplySaved = { viewModel.applySavedDefaults() }, - onApplyDefaults = { viewModel.applyPatchDefaults() }, - onApplyAll = { viewModel.selectAll() }, - onApplyNone = { viewModel.deselectAll() }, + activeMode = activeBundleId?.let { uiState.selectionModeFor(it) } ?: SelectionMode.CUSTOM, + onApplySaved = { + activeBundleId?.let { viewModel.applySavedDefaultsInBundle(it) } + }, + onApplyDefaults = { + activeBundleId?.let { viewModel.applyPatchDefaultsInBundle(it) } + }, + onApplyAll = { + activeBundleId?.let { viewModel.selectAllInBundle(it) } + }, + onApplyNone = { + activeBundleId?.let { viewModel.deselectAllInBundle(it) } + }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) ) } @@ -394,14 +419,44 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } } - uiState.filteredPatches.isEmpty() && !uiState.isLoading -> { + // Global empty state — when EVERY loaded bundle has zero patches + // compatible with this APK. None of the enabled sources contribute + // anything for this app's package; rendering empty bundle boxes + // would be pure noise. + !uiState.isLoading && uiState.bundles.all { it.patches.isEmpty() } -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( - text = if (uiState.searchQuery.isNotBlank()) "No patches match your search" - else "No patches found", + text = if (uiState.bundles.isEmpty()) "No patches found" + else "None of your enabled sources have patches for this app", + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } + + // Global "no matches for search" empty state — only fires when + // EVERY bundle that HAS patches has been filtered to empty by + // the active search. Bundles with 0 patches for this app are + // hidden separately above, so we only consider non-empty sources. + uiState.searchQuery.isNotBlank() && run { + val nonEmptySourceIds = uiState.bundles + .filter { it.patches.isNotEmpty() } + .map { it.bundleId }.toSet() + uiState.filteredBundles + .filter { it.bundleId in nonEmptySourceIds } + .all { it.patches.isEmpty() } + } -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No patches match your search", fontSize = 12.sp, fontFamily = mono, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) @@ -410,9 +465,16 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } else -> { - // Patch list + // Patch list — single-bundle renders flat (no box chrome), + // multi-bundle renders per-bundle collapsible boxes. val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() + // Expand/collapse state for multi-bundle, keyed by bundleId. + // Default: all bundles expanded. Uses plain `remember` — state + // resets if the user backs out and re-enters the screen, which + // is acceptable since "show me everything" is the right default. + val collapsedBundles = remember { mutableStateListOf() } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { LazyColumn( state = lazyListState, @@ -420,9 +482,6 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - // Strip-libs status banner. Purely informational — the user - // configures their keep-list in Settings, and this banner - // reports what will happen to native libs for the current APK. val showBanner = uiState.stripLibsStatus !is StripLibsStatus.NoNativeLibs if (showBanner) { item(key = "strip_libs_banner") { @@ -430,21 +489,66 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } } - items( - items = uiState.filteredPatches, - key = { it.uniqueId } - ) { patch -> - PatchListItem( - patch = patch, - isSelected = uiState.selectedPatches.contains(patch.uniqueId), - onToggle = { viewModel.togglePatch(patch.uniqueId) }, - getOptionValue = { optionKey, default -> - viewModel.getOptionValue(patch.name, optionKey, default) - }, - onOptionValueChange = { optionKey, value -> - viewModel.setOptionValue(patch.name, optionKey, value) + if (isSingleBundle) { + // ── Flat rendering (single bundle, no chrome) ── + val bundle = uiState.filteredBundles.firstOrNull() ?: return@LazyColumn + val bundleId = bundle.bundleId + val selectedInBundle = uiState.selectedByBundle[bundleId].orEmpty() + items( + items = bundle.patches, + key = { it.uniqueId } + ) { patch -> + PatchListItem( + patch = patch, + isSelected = selectedInBundle.contains(patch.uniqueId), + onToggle = { viewModel.togglePatch(bundleId, patch.uniqueId) }, + sourceName = null, + getOptionValue = { optionKey, default -> + viewModel.getOptionValue(patch.name, optionKey, default) + }, + onOptionValueChange = { optionKey, value -> + viewModel.setOptionValue(patch.name, optionKey, value) + } + ) + } + } else { + // ── Per-bundle collapsible boxes (multi-bundle) ── + // Hide bundles whose pre-filter patches list is empty + // (i.e. the bundle has NO patches compatible with this + // APK at all). Bundles that loaded patches but are + // currently empty due to an active search still + // render — their box shows "no matches in this bundle". + val bundlesById = uiState.bundles.associateBy { it.bundleId } + val visibleBundles = uiState.filteredBundles.filter { fb -> + bundlesById[fb.bundleId]?.patches?.isNotEmpty() == true + } + visibleBundles.forEach { bundle -> + item(key = "bundle-${bundle.bundleId}") { + BundleBox( + bundle = bundle, + selectedInBundle = uiState.selectedByBundle[bundle.bundleId].orEmpty(), + selectionMode = uiState.selectionModeFor(bundle.bundleId), + hasSavedForBundle = uiState.savedSelectedByBundle?.containsKey(bundle.bundleId) == true, + expanded = bundle.bundleId !in collapsedBundles, + searchActive = uiState.searchQuery.isNotBlank(), + onExpandToggle = { + if (bundle.bundleId in collapsedBundles) collapsedBundles.remove(bundle.bundleId) + else collapsedBundles.add(bundle.bundleId) + }, + onTogglePatch = { patchId -> viewModel.togglePatch(bundle.bundleId, patchId) }, + onSelectAll = { viewModel.selectAllInBundle(bundle.bundleId) }, + onDeselectAll = { viewModel.deselectAllInBundle(bundle.bundleId) }, + onApplyDefaults = { viewModel.applyPatchDefaultsInBundle(bundle.bundleId) }, + onApplySaved = { viewModel.applySavedDefaultsInBundle(bundle.bundleId) }, + getOptionValue = { patchName, optionKey, default -> + viewModel.getOptionValue(patchName, optionKey, default) + }, + onOptionValueChange = { patchName, optionKey, value -> + viewModel.setOptionValue(patchName, optionKey, value) + }, + ) } - ) + } } } @@ -660,6 +764,7 @@ private fun PatchListItem( patch: Patch, isSelected: Boolean, onToggle: () -> Unit, + sourceName: String? = null, getOptionValue: (optionKey: String, default: String?) -> String = { _, d -> d ?: "" }, onOptionValueChange: (optionKey: String, value: String) -> Unit = { _, _ -> } ) { @@ -747,6 +852,32 @@ private fun PatchListItem( modifier = Modifier.weight(1f, fill = false) ) + if (sourceName != null) { + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small) + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = sourceName.uppercase(), + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + if (patch.compatiblePackages.isNotEmpty()) { val genericSegments = setOf("com", "org", "net", "android", "google", "apps", "app", "www") patch.compatiblePackages.take(2).forEach { pkg -> @@ -1604,3 +1735,152 @@ private data class BannerDisplay( val stripChips: List = emptyList(), val notInApkChips: List = emptyList() ) + +// ──────────────────────────────────────────────────────────────────────────── +// Per-bundle collapsible box (multi-bundle view) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Collapsible box containing one bundle's patches. Each box has its own + * header (bundle name, count, expand chevron, "Your Defaults" chip), + * per-bundle control buttons (Select all / Deselect / Defaults / Saved), + * and the patches list itself. + * + * In search-active state, the box stays visible even if [BundlePatches.patches] + * is empty — it renders a "no matches in this bundle" inline empty state so + * the structural grouping stays stable while the user iterates on the query. + */ +@Composable +private fun BundleBox( + bundle: BundlePatches, + selectedInBundle: Set, + selectionMode: SelectionMode, + hasSavedForBundle: Boolean, + expanded: Boolean, + searchActive: Boolean, + onExpandToggle: () -> Unit, + onTogglePatch: (String) -> Unit, + onSelectAll: () -> Unit, + onDeselectAll: () -> Unit, + onApplyDefaults: () -> Unit, + onApplySaved: () -> Unit, + getOptionValue: (patchName: String, optionKey: String, default: String?) -> String, + onOptionValueChange: (patchName: String, optionKey: String, value: String) -> Unit, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + val enabledCount = selectedInBundle.size + val totalCount = bundle.patches.size + + val outlineColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.20f) + val bgColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.35f) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .background(bgColor) + .border(1.dp, outlineColor, RoundedCornerShape(corners.medium)) + ) { + // ── Header ── + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onExpandToggle() } + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Chevron + Text( + text = if (expanded) "▼" else "▶", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + fontFamily = mono, + ) + Text( + text = bundle.bundleName, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + // Count chip — "Your Defaults" badge lives in SelectionModeChips + // below so we don't duplicate the signal here. + Text( + text = "$enabledCount / $totalCount", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f), + letterSpacing = 0.5.sp, + ) + Spacer(Modifier.weight(1f)) + } + + // ── Body (controls + patches) ── + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Per-bundle control row — REUSES the same SelectionModeChips + // composable the single-bundle path uses, so icons, hover + // states, "Your Defaults" badge, and full-width layout match + // exactly. Callbacks scope each action to THIS bundle. + SelectionModeChips( + hasSavedSelection = hasSavedForBundle, + activeMode = selectionMode, + onApplySaved = onApplySaved, + onApplyDefaults = onApplyDefaults, + onApplyAll = onSelectAll, + onApplyNone = onDeselectAll, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + + // Patches inside this bundle. Note: this is a regular Column, + // NOT a LazyColumn — bundles aren't typically huge enough + // (tens of patches) to justify lazy rendering, and nesting + // LazyColumns inside a LazyColumn is unsupported. + if (bundle.patches.isEmpty() && searchActive) { + Text( + text = "No matches in this bundle", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + bundle.patches.forEach { patch -> + PatchListItem( + patch = patch, + isSelected = selectedInBundle.contains(patch.uniqueId), + onToggle = { onTogglePatch(patch.uniqueId) }, + // Bundle context is implicit from the box header + sourceName = null, + getOptionValue = { optionKey, default -> + getOptionValue(patch.name, optionKey, default) + }, + onOptionValueChange = { optionKey, value -> + onOptionValueChange(patch.name, optionKey, value) + }, + ) + } + } + } + } + } + } +} + 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 ce2e1e3e..5ec69c7b 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 @@ -22,8 +22,30 @@ import app.morphe.gui.util.PatchService import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.util.FileUtils.ANDROID_ARCHITECTURES import app.morphe.patcher.resource.CpuArchitecture +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import java.io.File +/** + * Per-bundle view of one source's contribution to the patches-selection screen. + * + * - [bundleId] is an internal handle stable for the screen lifetime; the screen + * uses it as a map key for selection state and the LazyColumn item key. + * - [bundleName] is the display label AND the persistence key (matches the + * `sourceName` slot inside [PatchPreferencesRepository]). Renaming a source + * carries its saved selection with it. + * - [patches] holds the patches from this bundle ALONE — no cross-bundle dedup. + * When two sources ship an identical patch (same name/body/options), each + * bundle still has its own entry here, and the user toggles them + * independently. The patcher dedups at apply time so this doesn't double-apply. + */ +data class BundlePatches( + val bundleId: String, + val bundleName: String, + val patches: List, +) + class PatchSelectionViewModel( private val apkPath: String, private val apkName: String, @@ -35,19 +57,29 @@ class PatchSelectionViewModel( private val configRepository: ConfigRepository, private val preferencesRepository: PatchPreferencesRepository, private val patchSourceName: String, - private val localPatchFilePath: String? = null + private val localPatchFilePath: String? = null, + /** All enabled-source .mpp file paths. Single-element in single-source mode. */ + private val patchesFilePaths: List = listOf(patchesFilePath), + /** Parallel to [patchesFilePaths] — display name of each source. Used as the + * per-bundle label AND persistence key. */ + private val patchSourceNames: List = emptyList(), ) : ScreenModel { - // Actual path to use - may differ from patchesFilePath if we had to re-download + // Actual path to use for the primary file — may differ from patchesFilePath + // if we had to re-download (cache cleared, etc.) private var actualPatchesFilePath: String = patchesFilePath + // All resolved file paths — drives multi-source patching when invoking the engine. + private var actualPatchesFilePaths: List = patchesFilePaths // User-configured output folder; null means save next to the input APK. private var defaultOutputDirectory: String? = null - private val _uiState = MutableStateFlow(PatchSelectionUiState( - apkArchitectures = apkArchitectures, - stripLibsStatus = computeStripLibsStatus(apkArchitectures, ANDROID_ARCHITECTURES) - )) + private val _uiState = MutableStateFlow( + PatchSelectionUiState( + apkArchitectures = apkArchitectures, + stripLibsStatus = computeStripLibsStatus(apkArchitectures, ANDROID_ARCHITECTURES), + ) + ) val uiState: StateFlow = _uiState.asStateFlow() init { @@ -58,7 +90,9 @@ class PatchSelectionViewModel( private fun loadStripLibsPreference() { screenModelScope.launch { val config = configRepository.loadConfig() - defaultOutputDirectory = config.defaultOutputDirectory + // Store the resolved absolute path so the lookup at line ~487 can + // pass it straight into File(...) without re-resolving. + defaultOutputDirectory = config.resolvedDefaultOutputDirectory()?.absolutePath _uiState.value = _uiState.value.copy( stripLibsStatus = computeStripLibsStatus(apkArchitectures, config.keepArchitectures) ) @@ -67,6 +101,9 @@ class PatchSelectionViewModel( fun getApkPath(): String = apkPath fun getPatchesFilePath(): String = actualPatchesFilePath + fun getApkName(): String = apkName + + // ── Loading ────────────────────────────────────────────────────────────── fun loadPatches() { screenModelScope.launch { @@ -77,177 +114,277 @@ class PatchSelectionViewModel( if (!patchesFile.exists()) { Logger.info("Patches file not found at $patchesFilePath, attempting to download...") - // Try to extract version from the filename and find a matching release - // Filename format: morphe-patches-x.x.x.mpp or similar val downloadResult = downloadMissingPatches(patchesFile.name) if (downloadResult.isFailure) { _uiState.value = _uiState.value.copy( isLoading = false, - error = "Patches file missing and could not be downloaded: ${downloadResult.exceptionOrNull()?.message}" + error = "Patches file missing and could not be downloaded: ${downloadResult.exceptionOrNull()?.message}", ) return@launch } actualPatchesFilePath = downloadResult.getOrNull()!!.absolutePath + // Mirror the swap into the multi-paths list so the union load uses + // the freshly-downloaded file rather than the missing one. + actualPatchesFilePaths = actualPatchesFilePaths.map { + if (it == patchesFilePath) actualPatchesFilePath else it + } } - // Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(actualPatchesFilePath, packageName.ifEmpty { null }) + val patchesResult = loadFromAllPaths() patchesResult.fold( - onSuccess = { patches -> - // Deduplicate by uniqueId in case of true duplicates - val deduplicatedPatches = patches.distinctBy { it.uniqueId } - - Logger.info("Loaded ${deduplicatedPatches.size} patches for $packageName") - - // Compute the .mpp's default selection (patches with use=true) - val defaultSelected = deduplicatedPatches - .filter { it.isEnabled } - .map { it.uniqueId } - .toSet() - - // If a saved selection exists for this source+package, silently apply it. - // Otherwise fall back to .mpp defaults. - val savedBundle = preferencesRepository.get(patchSourceName, packageName) - val (initialSelected, savedOptions) = if (savedBundle != null) { - val byName = deduplicatedPatches.associateBy { it.name } - val selected = savedBundle.patches - .filter { (_, entry) -> entry.enabled } - .keys - .mapNotNull { byName[it]?.uniqueId } + onSuccess = { bundles -> + Logger.info( + "Loaded ${bundles.size} bundle(s), " + + "${bundles.sumOf { it.patches.size }} total patches for $packageName" + ) + + // For each bundle, derive its default selection (use=true) and + // its saved selection (if any). Persistence is per-bundle + // keyed by bundleName — single load per bundle. + val defaultsByBundle = bundles.associate { bundle -> + bundle.bundleId to bundle.patches + .filter { it.isEnabled } + .map { it.uniqueId } .toSet() - // Materialize saved option values into the patchOptionValues map - // (which is keyed "patchName.optionKey" → string). - val opts = mutableMapOf() - for ((patchName, entry) in savedBundle.patches) { - for ((optKey, jsonValue) in entry.options) { - opts["$patchName.$optKey"] = jsonValue.toString().trim('"') + } + 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('"') + } } } - Logger.info("Applied saved patch preferences for $patchSourceName / $packageName") - selected to opts.toMap() - } else { - defaultSelected to emptyMap() } - val savedSelectedIds: Set? = if (savedBundle != null) { - val byName = deduplicatedPatches.associateBy { it.name } - savedBundle.patches - .filter { (_, entry) -> entry.enabled } - .keys - .mapNotNull { byName[it]?.uniqueId } - .toSet() - } else null + // Initial selection for each bundle: saved if present, else .mpp defaults. + val initialSelectedByBundle = bundles.associate { bundle -> + bundle.bundleId to (savedByBundle[bundle.bundleId] + ?: defaultsByBundle[bundle.bundleId].orEmpty()) + } + + if (anyBundleHasSaved) { + Logger.info("Applied saved patch preferences for $packageName " + + "(${savedByBundle.size}/${bundles.size} bundle(s))") + } _uiState.value = _uiState.value.copy( isLoading = false, - allPatches = deduplicatedPatches, - filteredPatches = deduplicatedPatches, - selectedPatches = initialSelected, - patchOptionValues = savedOptions, - hasSavedSelection = savedBundle != null, - savedSelectedIds = savedSelectedIds + bundles = bundles, + filteredBundles = bundles, + selectedByBundle = initialSelectedByBundle, + savedSelectedByBundle = if (savedByBundle.isNotEmpty()) savedByBundle else null, + hasSavedSelection = anyBundleHasSaved, + patchOptionValues = initialOptions, ) }, onFailure = { e -> _uiState.value = _uiState.value.copy( isLoading = false, - error = "Failed to list patches: ${e.message}" + error = "Failed to list patches: ${e.message}", ) Logger.error("Failed to list patches", e) - } + }, ) } } + /** + * Load patches from every resolved enabled-source file in parallel. Returns + * one [BundlePatches] entry per source — NO cross-bundle dedup. Bundles + * whose load failed are dropped; the call fails only when ALL bundles fail. + */ + private suspend fun loadFromAllPaths(): Result> = coroutineScope { + val pkgFilter = packageName.ifEmpty { null } + val perFile = actualPatchesFilePaths.mapIndexed { idx, path -> + async { + val result = patchService.listPatches(path, pkgFilter) + Triple(idx, path, result) + } + }.awaitAll() + + val bundles: List = perFile.mapNotNull { (idx, path, result) -> + val patches = result.getOrNull() ?: return@mapNotNull null + val displayName = patchSourceNames.getOrNull(idx) + ?: File(path).nameWithoutExtension + BundlePatches( + bundleId = "bundle-$idx-${File(path).nameWithoutExtension}", + bundleName = displayName, + patches = patches, + ) + } + + if (bundles.isEmpty()) { + val firstError = perFile.firstNotNullOfOrNull { (_, _, r) -> r.exceptionOrNull() } + return@coroutineScope if (firstError != null) Result.failure(firstError) + else Result.success(emptyList()) + } + Result.success(bundles) + } + + // ── Legacy flat API (shims) ───────────────────────────────────────────── + // + // These shim methods keep the existing PatchSelectionScreen rendering + // compiling while the per-bundle UI is built out in a follow-up commit. + // Once the screen renders collapsible bundle boxes, these can be deleted. + // + // Behavior is best-effort: `togglePatch(patchId)` toggles in EVERY bundle + // that contains the patch (so old single-list UI matches old behavior: + // one click flips state everywhere). `selectAll`/`deselectAll`/etc. apply + // across all bundles in one go. + + @Deprecated("Per-bundle UI: use togglePatch(bundleId, patchId)") fun togglePatch(patchId: String) { - val current = _uiState.value.selectedPatches - val newSelection = if (current.contains(patchId)) { - current - patchId - } else { - current + patchId + val state = _uiState.value + val newMap = state.selectedByBundle.toMutableMap() + for (bundle in state.bundles) { + if (bundle.patches.none { it.uniqueId == patchId }) continue + val cur = newMap[bundle.bundleId].orEmpty() + newMap[bundle.bundleId] = if (patchId in cur) cur - patchId else cur + patchId } - _uiState.value = _uiState.value.copy(selectedPatches = newSelection) + _uiState.value = state.copy(selectedByBundle = newMap) } + @Deprecated("Per-bundle UI: use selectAllInBundle") fun selectAll() { - val allIds = _uiState.value.allPatches.map { it.uniqueId }.toSet() - _uiState.value = _uiState.value.copy(selectedPatches = allIds) + val state = _uiState.value + _uiState.value = state.copy( + selectedByBundle = state.bundles.associate { bundle -> + bundle.bundleId to bundle.patches.map { it.uniqueId }.toSet() + } + ) } + @Deprecated("Per-bundle UI: use deselectAllInBundle") fun deselectAll() { - _uiState.value = _uiState.value.copy(selectedPatches = emptySet()) + val state = _uiState.value + _uiState.value = state.copy( + selectedByBundle = state.bundles.associate { it.bundleId to emptySet() } + ) } - /** - * Reset the selection to the .mpp's per-patch `use=true/false` defaults. - */ + @Deprecated("Per-bundle UI: use applyPatchDefaultsInBundle") fun applyPatchDefaults() { - val defaults = _uiState.value.allPatches - .filter { it.isEnabled } - .map { it.uniqueId } - .toSet() - _uiState.value = _uiState.value.copy(selectedPatches = defaults) + val state = _uiState.value + _uiState.value = state.copy( + selectedByBundle = state.bundles.associate { bundle -> + bundle.bundleId to bundle.patches.filter { it.isEnabled } + .map { it.uniqueId }.toSet() + } + ) } - /** - * Apply the user's previously-saved selection for this source+package, if any. - * No-op if no saved selection exists. - */ + @Deprecated("Per-bundle UI: use applySavedDefaultsInBundle") fun applySavedDefaults() { - screenModelScope.launch { - val saved = preferencesRepository.get(patchSourceName, packageName) ?: return@launch - val byName = _uiState.value.allPatches.associateBy { it.name } - val selected = saved.patches - .filter { (_, entry) -> entry.enabled } - .keys - .mapNotNull { byName[it]?.uniqueId } - .toSet() - val opts = mutableMapOf() - for ((patchName, entry) in saved.patches) { - for ((optKey, jsonValue) in entry.options) { - opts["$patchName.$optKey"] = jsonValue.toString().trim('"') - } - } - _uiState.value = _uiState.value.copy( - selectedPatches = selected, - patchOptionValues = opts - ) - } + val saved = _uiState.value.savedSelectedByBundle ?: return + _uiState.value = _uiState.value.copy(selectedByBundle = saved) + } + + @Deprecated("Per-bundle UI: sourceName is implicit from the bundle context") + fun getSourceNameFor(patchId: String): String? { + val bundles = _uiState.value.bundles + if (bundles.size <= 1) return null + return bundles.firstOrNull { it.patches.any { p -> p.uniqueId == patchId } } + ?.bundleName } + // ── Per-bundle selection methods ──────────────────────────────────────── + + fun togglePatch(bundleId: String, patchId: String) { + val current = _uiState.value.selectedByBundle + val bundleSet = current[bundleId].orEmpty() + val newSet = if (patchId in bundleSet) bundleSet - patchId else bundleSet + patchId + _uiState.value = _uiState.value.copy( + selectedByBundle = current + (bundleId to newSet), + ) + } + + fun selectAllInBundle(bundleId: String) { + val bundle = _uiState.value.bundles.firstOrNull { it.bundleId == bundleId } ?: return + val all = bundle.patches.map { it.uniqueId }.toSet() + _uiState.value = _uiState.value.copy( + selectedByBundle = _uiState.value.selectedByBundle + (bundleId to all), + ) + } + + fun deselectAllInBundle(bundleId: String) { + _uiState.value = _uiState.value.copy( + selectedByBundle = _uiState.value.selectedByBundle + (bundleId to emptySet()), + ) + } + + /** Reset this bundle's selection to its .mpp `use=true/false` defaults. */ + fun applyPatchDefaultsInBundle(bundleId: String) { + val bundle = _uiState.value.bundles.firstOrNull { it.bundleId == bundleId } ?: return + val defaults = bundle.patches.filter { it.isEnabled }.map { it.uniqueId }.toSet() + _uiState.value = _uiState.value.copy( + selectedByBundle = _uiState.value.selectedByBundle + (bundleId to defaults), + ) + } + + /** Restore this bundle's saved selection. No-op if no saved state for this bundle. */ + fun applySavedDefaultsInBundle(bundleId: String) { + val bundle = _uiState.value.bundles.firstOrNull { it.bundleId == bundleId } ?: return + val saved = _uiState.value.savedSelectedByBundle?.get(bundleId) ?: return + _uiState.value = _uiState.value.copy( + selectedByBundle = _uiState.value.selectedByBundle + (bundleId to saved), + ) + } + + // ── Filter / search ───────────────────────────────────────────────────── + fun setSearchQuery(query: String) { - val filtered = if (query.isBlank()) { - _uiState.value.allPatches - } else { - _uiState.value.allPatches.filter { - it.name.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true) - } - } _uiState.value = _uiState.value.copy( searchQuery = query, - filteredPatches = filtered + filteredBundles = computeFilteredBundles(query, _uiState.value.showOnlySelected), ) } fun setShowOnlySelected(show: Boolean) { - val filtered = if (show) { - _uiState.value.allPatches.filter { _uiState.value.selectedPatches.contains(it.uniqueId) } - } else if (_uiState.value.searchQuery.isNotBlank()) { - _uiState.value.allPatches.filter { - it.name.contains(_uiState.value.searchQuery, ignoreCase = true) || - it.description.contains(_uiState.value.searchQuery, ignoreCase = true) - } - } else { - _uiState.value.allPatches - } _uiState.value = _uiState.value.copy( showOnlySelected = show, - filteredPatches = filtered + filteredBundles = computeFilteredBundles(_uiState.value.searchQuery, show), ) } + /** + * Per-bundle filter — preserves bundle grouping, so a bundle that has zero + * matches still appears in [PatchSelectionUiState.filteredBundles] with an + * empty `patches` list. The UI uses that to render the "no matches in this + * bundle" empty state inside the box. + */ + private fun computeFilteredBundles(query: String, showOnlySelected: Boolean): List { + val q = query.trim() + return _uiState.value.bundles.map { bundle -> + val selectedInBundle = _uiState.value.selectedByBundle[bundle.bundleId].orEmpty() + val patches = bundle.patches.filter { patch -> + val matchesSearch = q.isBlank() || + patch.name.contains(q, ignoreCase = true) || + patch.description.contains(q, ignoreCase = true) + val matchesSelection = !showOnlySelected || patch.uniqueId in selectedInBundle + matchesSearch && matchesSelection + } + bundle.copy(patches = patches) + } + } + fun clearError() { _uiState.value = _uiState.value.copy(error = null) } @@ -261,106 +398,103 @@ class PatchSelectionViewModel( } /** - * Set a patch option value. Key format: "patchName.optionKey" + * Set a patch option value. Key format: "patchName.optionKey". Options are + * keyed by patch name, so identical patches across bundles share option + * values — intentional, since the patch IS the same patch. */ fun setOptionValue(patchName: String, optionKey: String, value: String) { val key = "$patchName.$optionKey" val current = _uiState.value.patchOptionValues.toMutableMap() - if (value.isBlank()) { - current.remove(key) - } else { - current[key] = value - } + if (value.isBlank()) current.remove(key) else current[key] = value _uiState.value = _uiState.value.copy(patchOptionValues = current) } - /** - * Get a patch option value. Returns the user-set value, or the default if not set. - */ fun getOptionValue(patchName: String, optionKey: String, default: String?): String { val key = "$patchName.$optionKey" return _uiState.value.patchOptionValues[key] ?: default ?: "" } - /** - * Count of patches that are disabled by default (from .mpp metadata). - */ - fun getDefaultDisabledCount(): Int { - return _uiState.value.allPatches.count { !it.isEnabled } - } + /** Total count of patches across all bundles that ship disabled by default. */ + fun getDefaultDisabledCount(): Int = + _uiState.value.bundles.sumOf { bundle -> bundle.patches.count { !it.isEnabled } } + + // ── Persistence ───────────────────────────────────────────────────────── /** - * Persist the current selection + option values as the user's saved preference - * for this source+package. Called from createPatchConfig (auto-save on Patch click). + * Persist each bundle's current selection + option values under its own + * source name. Called from createPatchConfig (auto-save on Patch click). */ private fun saveCurrentSelection() { val state = _uiState.value - val enabledNames = state.allPatches - .filter { state.selectedPatches.contains(it.uniqueId) } - .map { it.name } - .toSet() - val disabledNames = state.allPatches - .filter { !state.selectedPatches.contains(it.uniqueId) } - .map { it.name } - .toSet() - - // Group "patchName.optionKey" -> JsonElement under each patch name. - val grouped = mutableMapOf>() + // Group "patchName.optionKey" -> JsonElement under each patch name, ONCE. + // (Option values are global by design — see setOptionValue.) + val groupedOptions = mutableMapOf>() for ((compoundKey, value) in state.patchOptionValues) { val dotIdx = compoundKey.indexOf('.') if (dotIdx <= 0) continue val patchName = compoundKey.substring(0, dotIdx) val optKey = compoundKey.substring(dotIdx + 1) - grouped.getOrPut(patchName) { mutableMapOf() }[optKey] = + groupedOptions.getOrPut(patchName) { mutableMapOf() }[optKey] = kotlinx.serialization.json.JsonPrimitive(value) } screenModelScope.launch { - preferencesRepository.save( - sourceName = patchSourceName, - packageName = packageName, - enabledPatchNames = enabledNames, - disabledPatchNames = disabledNames, - options = grouped - ) - // After saving, the live selection IS the saved selection — so update - // the snapshot so the "YOUR DEFAULTS" chip stays highlighted post-patch. + for (bundle in state.bundles) { + val selected = state.selectedByBundle[bundle.bundleId].orEmpty() + val enabledNames = bundle.patches + .filter { selected.contains(it.uniqueId) } + .map { it.name } + .toSet() + val disabledNames = bundle.patches + .filterNot { selected.contains(it.uniqueId) } + .map { it.name } + .toSet() + + // Only save options for patches actually in this bundle — avoids + // bleeding bundle A's option into bundle B's preferences. + val patchNamesInBundle = bundle.patches.mapNotNull { it.name }.toSet() + val scopedOptions = groupedOptions.filterKeys { it in patchNamesInBundle } + + preferencesRepository.save( + sourceName = bundle.bundleName, + packageName = packageName, + enabledPatchNames = enabledNames, + disabledPatchNames = disabledNames, + options = scopedOptions, + ) + } + + // After saving, the live selection IS the saved selection — refresh + // the snapshot so the per-bundle "Your Defaults" chips stay + // highlighted post-patch. _uiState.value = _uiState.value.copy( hasSavedSelection = true, - savedSelectedIds = state.selectedPatches + savedSelectedByBundle = state.selectedByBundle, ) } } + // ── Patcher integration ───────────────────────────────────────────────── + fun createPatchConfig(continueOnError: Boolean = false): PatchConfig { - // Auto-save the current selection as the user's "Your Defaults" before patching. saveCurrentSelection() + // Delegate to the shared engine helper — same path the CLI computes. + // Passing apkName as the display name preserves the friendly label + // (e.g. "Youtube") instead of falling back to the filename. val inputFile = File(apkPath) - val appFolderName = apkName.replace(" ", "-") - val baseOutputDir = defaultOutputDirectory?.let { File(it) } ?: inputFile.parentFile - val outputDir = File(baseOutputDir, appFolderName) - outputDir.mkdirs() + val outputPath = app.morphe.engine.util.ApkOutputNaming.outputApkPath( + inputApk = inputFile, + patchesFile = File(actualPatchesFilePath), + baseOutputDir = defaultOutputDirectory?.let { File(it) }, + appDisplayName = apkName, + ).absolutePath + + // Flatten across bundles: the engine takes a single flat enable/disable + // list and dedups identical patches at apply time, so the union of + // selected patches across bundles is the right input. + val (selectedPatchNames, disabledPatchNames) = flattenSelection() - // Extract version from APK filename and patches version for output name - val version = extractVersionFromFilename(inputFile.name) ?: "patched" - val patchesVersion = extractPatchesVersion(File(actualPatchesFilePath).name) - val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" - val outputFileName = "${appFolderName}-Morphe-${version}${patchesSuffix}.apk" - val outputPath = File(outputDir, outputFileName).absolutePath - - // Convert unique IDs back to patch names for CLI - val selectedPatchNames = _uiState.value.allPatches - .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } - .map { it.name } - - val disabledPatchNames = _uiState.value.allPatches - .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } - .map { it.name } - - // Only ship a non-empty keepArchitectures set when the current status actually - // prescribes stripping. All other states (no native libs, universal, keep-all, - // fallback) → empty set → patcher leaves native libs untouched. val keepArches = (uiState.value.stripLibsStatus as? StripLibsStatus.WillStrip) ?.keeping ?.mapNotNull { CpuArchitecture.valueOfOrNull(it) } @@ -370,33 +504,46 @@ class PatchSelectionViewModel( return PatchConfig( inputApkPath = apkPath, outputApkPath = outputPath, - patchesFilePath = actualPatchesFilePath, + patchesFilePaths = actualPatchesFilePaths, enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, patchOptions = _uiState.value.patchOptionValues, useExclusiveMode = true, keepArchitectures = keepArches, - continueOnError = continueOnError + continueOnError = continueOnError, ) } - private fun extractVersionFromFilename(fileName: String): String? { - // Extract version from APKMirror format: com.google.android.youtube_20.40.45-xxx - return try { - val afterPackage = fileName.substringAfter("_") - afterPackage.substringBefore("-").takeIf { it.isNotEmpty() } - } catch (e: Exception) { - null + /** + * Flatten per-bundle selection into the patcher's flat (enabled, disabled) + * pair of patch-name lists. `.distinct()` is belt-and-suspenders — the + * engine deduplicates again at apply time. + */ + private fun flattenSelection(): Pair, List> { + val state = _uiState.value + val selected = mutableSetOf() + val disabled = mutableSetOf() + for (bundle in state.bundles) { + val bundleSelected = state.selectedByBundle[bundle.bundleId].orEmpty() + for (patch in bundle.patches) { + if (patch.uniqueId in bundleSelected) selected.add(patch.name) + else disabled.add(patch.name) + } } + // A patch enabled in any bundle wins over its disabled-in-another counterpart + // (engine dedup means the patch is one entity at apply time). + disabled.removeAll(selected) + return selected.toList() to disabled.toList() } - private fun extractPatchesVersion(patchesFileName: String): String? { - // Extract version from patches filename: morphe-patches-1.13.0-dev.11.mpp -> 1.13.0-dev.11 - val regex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") - return regex.find(patchesFileName)?.groupValues?.get(1) - } + // Delegate to the shared engine helper so GUI and CLI agree on filename + // parsing. Returning these as instance methods (not direct calls) keeps + // existing call sites in this file unchanged. + private fun extractVersionFromFilename(fileName: String): String? = + app.morphe.engine.util.ApkOutputNaming.extractApkVersionFromFilename(fileName) - fun getApkName(): String = apkName + private fun extractPatchesVersion(patchesFileName: String): String? = + app.morphe.engine.util.ApkOutputNaming.extractPatchesVersion(patchesFileName) /** * Generate a preview of the CLI command that will be executed. @@ -408,7 +555,7 @@ class PatchSelectionViewModel( keystorePath: String? = null, keystorePassword: String? = null, keystoreAlias: String? = null, - keystoreEntryPassword: String? = null + keystoreEntryPassword: String? = null, ): String { val inputFile = File(apkPath) val patchesFile = File(actualPatchesFilePath) @@ -418,23 +565,13 @@ class PatchSelectionViewModel( val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" val outputFileName = "${appFolderName}-Morphe-${version}${patchesSuffix}.apk" - val selectedPatchNames = _uiState.value.allPatches - .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } - .map { it.name } - - val disabledPatchNames = _uiState.value.allPatches - .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } - .map { it.name } + val (selectedPatchNames, disabledPatchNames) = flattenSelection() - // Use whichever produces fewer flags val useExclusive = selectedPatchNames.size <= disabledPatchNames.size - // striplibs flag: only when the computed status prescribes actual stripping val striplibsArg = (uiState.value.stripLibsStatus as? StripLibsStatus.WillStrip) - ?.keeping - ?.joinToString(",") + ?.keeping?.joinToString(",") - // Keystore flags (only if custom keystore is set) val hasCustomKeystore = keystorePath != null return if (cleanMode) { @@ -447,24 +584,12 @@ class PatchSelectionViewModel( --force \ """.trimIndent() ) - - if (continueOnError) { - appendLine(" --continue-on-error \\") - } - - if (useExclusive) { - appendLine(" --exclusive \\") - } - - striplibsArg?.let { - appendLine(" --striplibs $it \\") - } - + if (continueOnError) appendLine(" --continue-on-error \\") + if (useExclusive) appendLine(" --exclusive \\") + striplibsArg?.let { appendLine(" --striplibs $it \\") } if (hasCustomKeystore) { appendLine(" --keystore \"$keystorePath\" \\") - keystorePassword?.let { - appendLine(" --keystore-password \"$it\" \\") - } + keystorePassword?.let { appendLine(" --keystore-password \"$it\" \\") } if (keystoreAlias != null && keystoreAlias != DEFAULT_KEYSTORE_ALIAS) { appendLine(" --keystore-entry-alias \"$keystoreAlias\" \\") } @@ -472,15 +597,12 @@ class PatchSelectionViewModel( appendLine(" --keystore-entry-password \"$keystoreEntryPassword\" \\") } } - val flagPatches = if (useExclusive) selectedPatchNames else disabledPatchNames val flag = if (useExclusive) "-e" else "-d" - flagPatches.forEachIndexed { index, patch -> val suffix = if (index == flagPatches.lastIndex) "" else " \\" appendLine(" $flag \"$patch\"$suffix") } - append(" ${inputFile.name}") } } else { @@ -504,93 +626,124 @@ class PatchSelectionViewModel( /** * Download patches file if it's missing (e.g., after cache clear). * For LOCAL sources, uses the local file directly. - * Tries to find a release matching the expected filename, or falls back to latest stable. */ private suspend fun downloadMissingPatches(expectedFilename: String): Result { - // LOCAL source: use the local file directly instead of downloading if (localPatchFilePath != null) { val localFile = File(localPatchFilePath) - return if (localFile.exists()) { - Result.success(localFile) - } else { - Result.failure(Exception("Local patch file not found: ${localFile.name}")) - } + return if (localFile.exists()) Result.success(localFile) + else Result.failure(Exception("Local patch file not found: ${localFile.name}")) } - // Try to extract version from filename (e.g., "morphe-patches-1.9.0.mpp" -> "1.9.0") val versionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") val versionMatch = versionRegex.find(expectedFilename) val expectedVersion = versionMatch?.groupValues?.get(1) Logger.info("Looking for patches version: ${expectedVersion ?: "latest"}") - // Fetch releases val releasesResult = patchRepository.fetchReleases() if (releasesResult.isFailure) { - return Result.failure(releasesResult.exceptionOrNull() - ?: Exception("Failed to fetch releases")) + return Result.failure( + releasesResult.exceptionOrNull() ?: Exception("Failed to fetch releases") + ) } - val releases = releasesResult.getOrNull() ?: emptyList() - if (releases.isEmpty()) { - return Result.failure(Exception("No releases found")) - } + if (releases.isEmpty()) return Result.failure(Exception("No releases found")) - // Find matching release by version, or use latest stable val targetRelease = if (expectedVersion != null) { releases.find { it.tagName.contains(expectedVersion) } - ?: releases.firstOrNull { !it.isDevRelease() } // Fallback to latest stable + ?: releases.firstOrNull { !it.isDevRelease() } } else { - releases.firstOrNull { !it.isDevRelease() } // Latest stable - } - - if (targetRelease == null) { - return Result.failure(Exception("No suitable release found")) - } + releases.firstOrNull { !it.isDevRelease() } + } ?: return Result.failure(Exception("No suitable release found")) Logger.info("Downloading patches from release: ${targetRelease.tagName}") - - // Download the patches return patchRepository.downloadPatches(targetRelease) } - } +// ── State / supporting types ──────────────────────────────────────────────── + data class PatchSelectionUiState( val isLoading: Boolean = false, - val allPatches: List = emptyList(), - val filteredPatches: List = emptyList(), - val selectedPatches: Set = emptySet(), + /** Per-bundle patches. Each bundle is one source's contribution — NO cross-bundle dedup. */ + val bundles: List = emptyList(), + /** Same shape as [bundles] but each bundle's patches list is post-filter. A bundle with + * zero matches stays in the list with `patches = emptyList()` so the UI can render + * the "no matches in this bundle" empty state inside the box. */ + val filteredBundles: List = emptyList(), + /** bundleId → set of patch uniqueIds enabled in that bundle. Independent across bundles. */ + val selectedByBundle: Map> = emptyMap(), + /** Snapshot of each bundle's saved selection. Null = no saved state for any bundle. */ + val savedSelectedByBundle: Map>? = null, + /** True when at least ONE bundle has a saved selection. Drives the per-box "Your Defaults" + * chip visibility — but per-box highlighting still uses [selectionModeFor]. */ + val hasSavedSelection: Boolean = false, val searchQuery: String = "", val showOnlySelected: Boolean = false, val error: String? = null, val apkArchitectures: List = emptyList(), val stripLibsStatus: StripLibsStatus = StripLibsStatus.NoNativeLibs, + /** "patchName.optionKey" → value. Options keyed by patch name, so identical patches + * across bundles share option values (intentional — same patch means same option). */ val patchOptionValues: Map = emptyMap(), - val hasSavedSelection: Boolean = false, - /** Snapshot of the saved-bundle's selected uniqueIds — used to highlight the - * Your Defaults chip whenever the live selection happens to match. Null when - * no saved selection exists. */ - val savedSelectedIds: Set? = null ) { - val selectedCount: Int get() = selectedPatches.size - val totalCount: Int get() = allPatches.size + /** Total count of patches enabled across all bundles. Patches identical across bundles + * are counted once per bundle they're enabled in — matches what the user toggled. */ + val selectedCount: Int get() = selectedByBundle.values.sumOf { it.size } + + /** Total count of patches across all bundles. */ + val totalCount: Int get() = bundles.sumOf { it.patches.size } + + // ── Legacy flat shims ──────────────────────────────────────────────── + // + // These let the existing PatchSelectionScreen render against the new + // per-bundle state without changes. Deleted once the screen renders + // collapsible bundle boxes. + + @Deprecated("Use bundles directly") + val allPatches: List get() = bundles.flatMap { it.patches } + + @Deprecated("Use filteredBundles directly") + val filteredPatches: List get() = filteredBundles.flatMap { it.patches } + + @Deprecated("Use selectedByBundle directly") + val selectedPatches: Set + get() = selectedByBundle.values.flatten().toSet() + + /** Snapshot of saved selection as a flat uniqueId set, for the legacy chip. Null when no bundle has saved state. */ + @Deprecated("Use savedSelectedByBundle directly") + val savedSelectedIds: Set? + get() = savedSelectedByBundle?.values?.flatten()?.toSet() /** - * Which preset (if any) the current selection matches. Drives chip highlighting: - * the chip whose mode equals `activeSelectionMode` gets the accent border + tint. - * SAVED is checked first so when the saved set happens to also be ALL or DEFAULTS, - * we still attribute the highlight to the user's saved preference. + * Legacy global selection mode — collapsed from per-bundle modes. Used + * only by the temporary flat-rendering path. Returns: + * - SAVED if EVERY bundle is in SAVED mode + * - DEFAULTS if EVERY bundle is in DEFAULTS mode + * - ALL / NONE similarly + * - CUSTOM otherwise (bundles disagree) */ + @Deprecated("Per-bundle UI: use selectionModeFor(bundleId)") val activeSelectionMode: SelectionMode get() { - if (allPatches.isEmpty()) return SelectionMode.CUSTOM - val all = allPatches.map { it.uniqueId }.toSet() - val defaults = allPatches.filter { it.isEnabled }.map { it.uniqueId }.toSet() + if (bundles.isEmpty()) return SelectionMode.CUSTOM + val modes = bundles.map { selectionModeFor(it.bundleId) }.distinct() + return if (modes.size == 1) modes.single() else SelectionMode.CUSTOM + } + + /** Which preset (if any) the SPECIFIED bundle's selection matches. Each box renders + * its own chip highlighting independently. */ + fun selectionModeFor(bundleId: String): SelectionMode { + val bundle = bundles.firstOrNull { it.bundleId == bundleId } ?: return SelectionMode.CUSTOM + if (bundle.patches.isEmpty()) return SelectionMode.CUSTOM + val selected = selectedByBundle[bundleId].orEmpty() + val all = bundle.patches.map { it.uniqueId }.toSet() + val defaults = bundle.patches.filter { it.isEnabled }.map { it.uniqueId }.toSet() + val saved = savedSelectedByBundle?.get(bundleId) return when { - savedSelectedIds != null && selectedPatches == savedSelectedIds -> SelectionMode.SAVED - selectedPatches.isEmpty() -> SelectionMode.NONE - selectedPatches == all -> SelectionMode.ALL - selectedPatches == defaults -> SelectionMode.DEFAULTS + saved != null && selected == saved -> SelectionMode.SAVED + selected.isEmpty() -> SelectionMode.NONE + selected == all -> SelectionMode.ALL + selected == defaults -> SelectionMode.DEFAULTS else -> SelectionMode.CUSTOM } } @@ -627,7 +780,7 @@ sealed class StripLibsStatus { data class WillStrip( val keeping: List, val stripping: List, - val notInApk: List + val notInApk: List, ) : StripLibsStatus() } @@ -640,7 +793,7 @@ sealed class StripLibsStatus { */ internal fun computeStripLibsStatus( apkArches: List, - userKeep: Set + userKeep: Set, ): StripLibsStatus { if (apkArches.isEmpty()) return StripLibsStatus.NoNativeLibs if (apkArches.size == 1 && apkArches[0].equals("universal", ignoreCase = true)) { @@ -657,7 +810,7 @@ internal fun computeStripLibsStatus( else -> StripLibsStatus.WillStrip( keeping = apkArches.filter { it in overlap }, stripping = apkArches.filter { it !in overlap }, - notInApk = notInApk + notInApk = notInApk, ) } } 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 e0cb0916..480c4778 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 @@ -42,7 +42,7 @@ import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import app.morphe.gui.data.model.Release +import app.morphe.engine.model.Release import org.koin.core.parameter.parametersOf import cafe.adriel.voyager.koin.koinScreenModel import app.morphe.gui.ui.components.ErrorDialog diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt index da8940c4..f03ec7e7 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt @@ -9,7 +9,7 @@ import app.morphe.cli.command.model.toPatchBundle import app.morphe.patcher.patch.loadPatchesFromJar import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import app.morphe.gui.data.model.Release +import app.morphe.engine.model.Release import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager @@ -22,7 +22,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import app.morphe.gui.util.Logger -import app.morphe.gui.data.model.ReleaseAsset +import app.morphe.engine.model.ReleaseAsset import java.io.File class PatchesViewModel( @@ -81,9 +81,11 @@ class PatchesViewModel( val stableReleases = releases.filter { !it.isDevRelease() } val devReleases = releases.filter { it.isDevRelease() } - // Check config for previously selected version - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion + // Check config for previously selected version FOR THIS SOURCE + val activeSourceId = patchSourceManager?.getActiveSource()?.id + val savedVersion = activeSourceId?.let { + configRepository.getLastPatchesVersionsBySource()[it] + } // Find the saved release, or fall back to latest stable val initialRelease = if (savedVersion != null) { @@ -134,8 +136,10 @@ class PatchesViewModel( parseVersionParts(version) .fold(0L) { acc, part -> acc * 10000 + part } } - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion + val activeSourceId = patchSourceManager?.getActiveSource()?.id + val savedVersion = activeSourceId?.let { + configRepository.getLastPatchesVersionsBySource()[it] + } // Pre-select the saved version, or fall back to the first (most recent) val initialRelease = if (savedVersion != null) { @@ -246,7 +250,12 @@ class PatchesViewModel( private fun checkCachedPatches(release: Release): File? { val asset = patchRepository.findPatchAsset(release) ?: return null val patchesDir = patchRepository.getCacheDir() - val cachedFile = File(patchesDir, asset.name) + // Match the version-prefixed filename PatchRepository.downloadPatches writes. + // Looking up by bare asset.name would falsely "find" the latest version's + // file for every other version's check (since maintainers commonly reuse + // the asset filename across releases) — that was the cause of the + // "latest stable shows SELECT after Clear Cache" bug. + val cachedFile = File(patchesDir, PatchRepository.cachedFileName(release, asset)) // Verify file exists and size matches (size check acts as basic integrity verification) return if (cachedFile.exists() && cachedFile.length() == asset.size) { @@ -297,9 +306,13 @@ class PatchesViewModel( ) Logger.info("Patches downloaded: ${patchFile.absolutePath}") - // Save the selected version to config so HomeScreen can pick it up - configRepository.setLastPatchesVersion(release.tagName) - Logger.info("Saved selected patches version to config: ${release.tagName}") + // Save the selected version PER SOURCE so HomeScreen can pick it up + // without contaminating other enabled sources. + val activeSourceId = patchSourceManager?.getActiveSource()?.id + if (activeSourceId != null) { + configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) + Logger.info("Saved selected patches version for source '$activeSourceId': ${release.tagName}") + } }, onFailure = { e -> _uiState.value = _uiState.value.copy( @@ -323,8 +336,11 @@ class PatchesViewModel( fun confirmSelection() { val release = _uiState.value.selectedRelease ?: return screenModelScope.launch { - configRepository.setLastPatchesVersion(release.tagName) - Logger.info("Confirmed patches selection: ${release.tagName}") + val activeSourceId = patchSourceManager?.getActiveSource()?.id + if (activeSourceId != null) { + configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) + Logger.info("Confirmed patches selection for source '$activeSourceId': ${release.tagName}") + } } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt index fefa6602..deeb747f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt @@ -18,8 +18,11 @@ 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.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close @@ -31,9 +34,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight 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 java.io.File import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator @@ -360,6 +368,7 @@ private fun FailureBottomBar( val hasTempFiles = remember { FileUtils.hasTempFiles() } val tempFilesSize = remember { FileUtils.getTempDirSize() } val logFile = remember { Logger.getLogFile() } + var showLogViewer by remember { mutableStateOf(false) } Column( modifier = Modifier @@ -427,6 +436,32 @@ private fun FailureBottomBar( ) } + val viewHover = remember { MutableInteractionSource() } + val isViewHovered by viewHover.collectIsHoveredAsState() + val viewBg by animateColorAsState( + if (isViewHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .hoverable(viewHover) + .clip(RoundedCornerShape(corners.small)) + .background(viewBg) + .clickable { showLogViewer = true } + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = "VIEW", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + val openHover = remember { MutableInteractionSource() } val isOpenHovered by openHover.collectIsHoveredAsState() val openBg by animateColorAsState( @@ -450,16 +485,26 @@ private fun FailureBottomBar( .padding(horizontal = 10.dp, vertical = 4.dp) ) { Text( - text = "OPEN", + text = "REVEAL", fontSize = 10.sp, fontWeight = FontWeight.Bold, fontFamily = mono, - color = accents.primary, + color = accents.primary.copy(alpha = 0.7f), letterSpacing = 0.5.sp ) } } + if (showLogViewer) { + LogFileViewerDialog( + file = logFile, + corners = corners, + mono = mono, + borderColor = borderColor, + onDismiss = { showLogViewer = false } + ) + } + Spacer(modifier = Modifier.height(8.dp)) } @@ -672,3 +717,154 @@ private fun getStatusColor(status: PatchingStatus): Color { else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) } } + +@Composable +private fun LogFileViewerDialog( + file: File, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + onDismiss: () -> Unit, +) { + val accents = LocalMorpheAccents.current + val clipboard = LocalClipboardManager.current + + // Read file once on open. Logs are line-oriented text, typically well + // under a few MB; if a single patching session ever produces something + // pathologically large we'd notice and tail it then. + val content = remember(file) { + runCatching { file.readText() }.getOrElse { e -> + "Failed to read log file: ${e.message}" + } + } + var copied by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp) + .clip(RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "LOG FILE", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 2.sp, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(2.dp)) + Text( + text = file.absolutePath, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1 + ) + } + + val copyHover = remember { MutableInteractionSource() } + val isCopyHovered by copyHover.collectIsHoveredAsState() + val copyBg by animateColorAsState( + if (isCopyHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .hoverable(copyHover) + .clip(RoundedCornerShape(corners.small)) + .background(copyBg) + .clickable { + clipboard.setText(AnnotatedString(content)) + copied = true + } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text( + text = if (copied) "COPIED" else "COPY ALL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (copied) accents.secondary else accents.primary, + letterSpacing = 0.5.sp + ) + } + + Spacer(Modifier.width(4.dp)) + + val closeHover = remember { MutableInteractionSource() } + val isCloseHovered by closeHover.collectIsHoveredAsState() + val closeBg by animateColorAsState( + if (isCloseHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .hoverable(closeHover) + .clip(RoundedCornerShape(corners.small)) + .background(closeBg) + .clickable { onDismiss() } + .padding(6.dp) + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + } + + // Log content — read-only, selectable, monospace. + val scrollState = rememberScrollState() + Box(modifier = Modifier.fillMaxSize()) { + SelectionContainer( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp) + ) { + Text( + text = content, + fontSize = 11.sp, + fontFamily = mono, + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f) + ) + } + + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle() + ) + } + } + } + } +} 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 386c17c5..d0443513 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 @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import app.morphe.engine.MorpheData import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import java.io.File @@ -52,18 +53,30 @@ class PatchingViewModel( addLog("Output: ${File(config.outputApkPath).name}", LogLevel.INFO) addLog("Patches: ${config.enabledPatches.size} enabled", LogLevel.INFO) - // Resolve keystore: use saved path, or derive from output APK location + // Resolve keystore. Two modes: + // - User configured one in Settings → use it; fail loudly if the + // file is missing (don't silently swap in our default — that + // would produce APKs signed by a different identity than the + // user picked, breaking on-device updates without explanation). + // - Otherwise → use the shared MorpheData default keystore. The + // patcher library creates it on first sign if missing; reused + // every patch session so all Morphe-patched apps share one + // signing identity. val appConfig = configRepository.loadConfig() - val resolvedKeystorePath = appConfig.keystorePath - ?: File(config.outputApkPath).let { out -> - out.resolveSibling(out.nameWithoutExtension + ".keystore").absolutePath - }.also { path -> - configRepository.setKeystorePath(path) - } + val userKeystore = appConfig.resolvedKeystorePath() + if (userKeystore != null && !userKeystore.exists()) { + val msg = "Configured keystore not found: ${userKeystore.absolutePath}. " + + "Restore the file, pick another in Settings, or clear the setting to use Morphe's default." + addLog(msg, LogLevel.ERROR) + _uiState.value = _uiState.value.copy(status = PatchingStatus.FAILED, error = msg) + Logger.error("Patching aborted: $msg") + return@launch + } + val resolvedKeystorePath = (userKeystore ?: MorpheData.defaultKeystoreFile).absolutePath // Use PatchService for direct library patching val result = patchService.patch( - patchesFilePath = config.patchesFilePath, + patchesFilePaths = config.patchesFilePaths, inputApkPath = config.inputApkPath, outputApkPath = config.outputApkPath, enabledPatches = config.enabledPatches, @@ -107,17 +120,16 @@ class PatchingViewModel( ) Logger.info("Patching completed: ${config.outputApkPath}") } else { - val failedMsg = if (patchResult.failedPatches.isNotEmpty()) { - "Failed patches: ${patchResult.failedPatches.joinToString(", ")}" - } else { - "Patching failed" - } - addLog(failedMsg, LogLevel.ERROR) + val reason = patchResult.failureReason + ?: if (patchResult.failedPatches.isNotEmpty()) + "Failed patches: ${patchResult.failedPatches.joinToString(", ")}" + else "Patching failed for an unknown reason" + addLog(reason, LogLevel.ERROR) _uiState.value = _uiState.value.copy( status = PatchingStatus.FAILED, - error = "Patching failed. Check logs for details." + error = reason, ) - Logger.error("Patching failed: ${patchResult.failedPatches}") + Logger.error("Patching failed: $reason") } }, onFailure = { e -> 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 eeb1bafb..60791398 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -39,11 +39,16 @@ import cafe.adriel.voyager.core.screen.Screen import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchSourceManager +import androidx.compose.ui.input.pointer.pointerHoverIcon +import app.morphe.gui.ui.components.MorpheErrorBar import app.morphe.gui.ui.components.OfflineBanner +import app.morphe.gui.ui.components.SourceManagementSheet +import app.morphe.gui.ui.components.SourceSheetMode import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.screens.home.components.FullScreenDropZone @@ -84,6 +89,21 @@ class QuickPatchScreen : Screen { fun QuickPatchContent(viewModel: QuickPatchViewModel) { val uiState by viewModel.uiState.collectAsState() + // Source picker state — Quick Patch is single-source by design. The picker + // uses the same SourceManagementSheet as Expert mode but in SINGLE_SELECT + // mode (radio behavior). Users can also add/edit/remove sources from here, + // matching morphe-manager which doesn't gate source management on expert mode. + val patchSourceManager: PatchSourceManager = koinInject() + val allSources by patchSourceManager.allSources.collectAsState() + val pickerScope = rememberCoroutineScope() + var showSourcePicker by remember { mutableStateOf(false) } + var activeSourceId by remember { mutableStateOf(null) } + LaunchedEffect(uiState.patchSourceName, allSources) { + // Resolve the current active source's id by name for radio selection. + activeSourceId = allSources.firstOrNull { it.name == uiState.patchSourceName }?.id + ?: patchSourceManager.getActiveSource().id + } + val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current @@ -93,6 +113,26 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { var trailingWidthPx by remember { mutableIntStateOf(0) } val centerSidePadding = with(density) { maxOf(leadingWidthPx, trailingWidthPx).toDp() } + 16.dp + if (showSourcePicker) { + SourceManagementSheet( + sources = allSources, + mode = SourceSheetMode.SINGLE_SELECT, + activeSourceId = activeSourceId, + onSelectSingle = { id -> + showSourcePicker = false + pickerScope.launch { patchSourceManager.switchSource(id) } + }, + onToggleEnabled = { _, _ -> /* no-op in SINGLE_SELECT mode */ }, + onAdd = { src -> pickerScope.launch { patchSourceManager.addSource(src) } }, + onEdit = { src -> pickerScope.launch { patchSourceManager.updateSource(src) } }, + onRemove = { id -> pickerScope.launch { patchSourceManager.removeSource(id) } }, + onOpenPatches = { /* unused in SINGLE_SELECT mode */ }, + onDismiss = { showSourcePicker = false }, + enabled = uiState.phase != QuickPatchPhase.DOWNLOADING && + uiState.phase != QuickPatchPhase.PATCHING, + ) + } + FullScreenDropZone( isDragHovering = uiState.isDragHovering, onDragHoverChange = { viewModel.setDragHover(it) }, @@ -134,7 +174,9 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { BrandingLogo() } - // Patches version badge — centered + // Patches version badge — centered. Click opens the source-management + // sheet in SINGLE_SELECT mode so the user can pick which source Quick + // Patch uses (and add/edit/remove sources too). Box( modifier = Modifier .align(Alignment.Center) @@ -147,7 +189,8 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { latestLabel = if (uiState.patchesVersion != null && uiState.patchesVersion == uiState.latestPatchesVersion) { "LATEST STABLE" - } else null + } else null, + onClick = { showSourcePicker = true }, ) } @@ -262,75 +305,15 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { // Error/warning bar uiState.error?.let { error -> - val mono = LocalMorpheFont.current val isUnsupportedWarning = error.contains("not supported in Quick Patch") - val accentColor = if (isUnsupportedWarning) accents.warning else MaterialTheme.colorScheme.error - val barBg = MaterialTheme.colorScheme.surface - val borderCol = accentColor.copy(alpha = 0.4f) - - Row( + MorpheErrorBar( + message = error, + onDismiss = { viewModel.clearError() }, + isWarning = isUnsupportedWarning, modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 24.dp, vertical = 20.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(corners.small)) - .border(1.dp, borderCol, RoundedCornerShape(corners.small)) - .background(barBg) - .drawBehind { - drawRect( - color = accentColor, - size = androidx.compose.ui.geometry.Size(3.dp.toPx(), size.height) - ) - } - .padding(start = 3.dp) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(8.dp) - .background(accentColor, RoundedCornerShape(1.dp)) - ) - Spacer(Modifier.width(12.dp)) - Text( - text = error, - fontFamily = mono, - fontSize = 12.sp, - lineHeight = 16.sp, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - Spacer(Modifier.width(12.dp)) - - // Dismiss — same hover pattern as the close button - val dismissHover = remember { MutableInteractionSource() } - val isDismissHovered by dismissHover.collectIsHoveredAsState() - val dismissBg by animateColorAsState( - if (isDismissHovered) accentColor.copy(alpha = 0.12f) - else Color.Transparent, - animationSpec = tween(150) - ) - Box( - modifier = Modifier - .height(28.dp) - .hoverable(dismissHover) - .clip(RoundedCornerShape(corners.small)) - .background(dismissBg) - .clickable { viewModel.clearError() } - .padding(horizontal = 12.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "DISMISS", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = if (isDismissHovered) accentColor - else accentColor.copy(alpha = 0.7f), - letterSpacing = 1.sp - ) - } - } + ) } } } @@ -361,10 +344,12 @@ private fun PatchesVersionBadge( isLoading: Boolean, patchSourceName: String? = null, latestLabel: String? = null, + onClick: (() -> Unit)? = null, ) { val mono = LocalMorpheFont.current val corners = LocalMorpheCorners.current val accents = LocalMorpheAccents.current + val interactive = onClick != null if (isLoading) { Row( @@ -398,7 +383,13 @@ private fun PatchesVersionBadge( .clip(RoundedCornerShape(corners.small)) .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) .background(MaterialTheme.colorScheme.surface) - .padding(start = 12.dp, end = 4.dp), + .then( + if (interactive) Modifier + .pointerHoverIcon(androidx.compose.ui.input.pointer.PointerIcon.Hand) + .clickable(onClick = onClick!!) + else Modifier + ) + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -1199,6 +1190,8 @@ private fun CompletedContent( val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } val monitorState by DeviceMonitor.state.collectAsState() + val adbPreference = LocalAdbPreference.current + val isAdbDisabledByUser = !adbPreference.enabled var isInstalling by remember { mutableStateOf(false) } var installError by remember { mutableStateOf(null) } var installSuccess by remember { mutableStateOf(false) } @@ -1336,8 +1329,44 @@ private fun CompletedContent( } } - // ADB install - if (monitorState.isAdbAvailable == true) { + // ADB install — when the user has the toggle off, render a compact + // "ADB OFF" hint with an inline enable button rather than hiding the + // affordance entirely (otherwise users wonder where install went). + if (isAdbDisabledByUser) { + Spacer(modifier = Modifier.height(12.dp)) + val enableHover = remember { MutableInteractionSource() } + val enableHovered by enableHover.collectIsHoveredAsState() + Box( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth() + .height(38.dp) + .hoverable(enableHover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (enableHovered) accents.primary.copy(alpha = 0.5f) + else accents.primary.copy(alpha = 0.25f), + RoundedCornerShape(corners.small) + ) + .background( + if (enableHovered) accents.primary.copy(alpha = 0.08f) + else Color.Transparent, + RoundedCornerShape(corners.small) + ) + .clickable { adbPreference.onChange(true) }, + contentAlignment = Alignment.Center + ) { + Text( + text = "ADB OFF · ENABLE TO INSTALL", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + } else if (monitorState.isAdbAvailable == true) { Spacer(modifier = Modifier.height(12.dp)) val readyDevices = monitorState.devices.filter { it.isReady } 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 20bf8b13..fd8855d7 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 @@ -11,6 +11,7 @@ import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchConfig import app.morphe.gui.data.model.SupportedApp +import app.morphe.engine.MorpheData import app.morphe.engine.UpdateInfo import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository @@ -22,8 +23,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch -import net.dongliu.apk.parser.ApkFile +import app.morphe.engine.util.ApkManifestReader import app.morphe.gui.util.ChecksumStatus +import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService @@ -55,6 +57,20 @@ class QuickPatchViewModel( private var cachedPatches: List = emptyList() private var cachedSupportedApps: List = emptyList() private var cachedPatchesFile: File? = null + /** All successfully-resolved patch files across enabled sources. Single-element + * in single-source mode. Used by the patching call to feed the engine the + * union of patches when multiple sources are enabled. */ + private var cachedAllPatchFiles: List = emptyList() + + private fun currentResolvedPatchFiles(): List = + cachedAllPatchFiles.takeIf { it.isNotEmpty() } + ?: listOfNotNull(cachedPatchesFile) + + /** Snapshot of the most recent multi-source load. Used by the QuickPatchScreen + * header to render the same SourcesCountPill as Expert mode (no click action + * in Quick Patch — sources are managed only from Expert mode). */ + fun getResolvedSourcesSnapshot(): EnabledSourcesLoader.Result? = cachedSourcesResult + private var cachedSourcesResult: EnabledSourcesLoader.Result? = null init { // Load patches on startup to get dynamic app info @@ -132,170 +148,81 @@ class QuickPatchViewModel( } /** - * Load patches from GitHub and extract supported apps dynamically. + * Load patches from all enabled sources via [EnabledSourcesLoader] and build + * the union supported-apps list. Single-source case (default) produces output + * equivalent to the pre-multi-source flow. */ private fun loadPatchesAndSupportedApps() { loadJob?.cancel() loadJob = screenModelScope.launch { _uiState.value = _uiState.value.copy(isLoadingPatches = true, patchLoadError = null) - // LOCAL source: skip GitHub entirely, load directly from the .mpp file - if (localPatchFilePath != null) { - val localFile = File(localPatchFilePath) - if (localFile.exists()) { - loadPatchesFromFile(localFile, localFile.nameWithoutExtension, isOffline = false) - } else { + try { + // Quick Patch is intentionally single-source — multi-source belongs in + // Expert mode. The user picks WHICH single source via the source-picker + // sheet, which calls patchSourceManager.switchSource and updates + // activePatchSourceId. Quick Patch loads only that source regardless of + // Expert's enabled flags — the two modes operate independently. + val activeSource = patchSourceManager.getActiveSource() + val activeRepo = patchSourceManager.getRepositoryForSource(activeSource) + val pair: Pair = + activeSource to activeRepo + + val result = EnabledSourcesLoader.loadAll(listOf(pair), patchService) + + if (!result.anyLoaded) { + val firstError = result.resolved.firstNotNullOfOrNull { it.error } + ?: result.loaded.perSource.firstNotNullOfOrNull { it.error?.message } + ?: "Could not load any patches" _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = "Local patch file not found: ${localFile.name}" + patchLoadError = firstError ) - } - return@launch - } - - try { - // Fetch releases - val releasesResult = patchRepository.fetchReleases() - val releases = releasesResult.getOrNull() - - if (releases.isNullOrEmpty()) { - // Try to fall back to cached .mpp file when offline - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile)) - return@launch - } - Logger.warn("Quick mode: Could not fetch releases") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not fetch releases. Check your internet connection.") - return@launch - } - - // Quick mode always uses the latest stable release - val release = releases.firstOrNull { !it.isDevRelease() } - - if (release == null) { - Logger.warn("Quick mode: No suitable release found") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "No suitable release found") - return@launch - } - - // Download patches - val patchFileResult = patchRepository.downloadPatches(release) - val patchFile = patchFileResult.getOrNull() - - if (patchFile == null) { - Logger.warn("Quick mode: Could not download patches") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not download patches") return@launch } - cachedPatchesFile = patchFile - - // Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches.isNullOrEmpty()) { - Logger.warn("Quick mode: Could not load patches: ${patchesResult.exceptionOrNull()?.message}") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not load patches") - return@launch - } - - cachedPatches = patches - - // Extract supported apps dynamically - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) + val supportedApps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + cachedPatches = result.unionGuiPatches cachedSupportedApps = supportedApps + val firstResolved = result.resolved.firstOrNull { it.patchFile != null } + cachedPatchesFile = firstResolved?.patchFile + cachedAllPatchFiles = result.resolved.mapNotNull { it.patchFile } + cachedSourcesResult = result + + Logger.info( + "Quick mode: Loaded ${supportedApps.size} supported apps from " + + "${result.resolved.count { it.patchFile != null }} source(s)" + ) - Logger.info("Quick mode: Loaded ${supportedApps.size} supported apps: ${supportedApps.map { "${it.displayName} (${it.recommendedVersion})" }}") + // Multi-source: only flag offline when EVERY resolved source is offline. + val resolvedSources = result.resolved.filter { it.patchFile != null } + val isOffline = resolvedSources.isNotEmpty() && resolvedSources.all { it.isOffline } + val displayVersion = firstResolved?.resolvedVersion + val sourceName = if (result.resolved.size == 1) { + firstResolved?.source?.name ?: patchSourceManager.getActiveSourceName() + } else { + "${result.resolved.count { it.patchFile != null }} sources" + } _uiState.value = _uiState.value.copy( isLoadingPatches = false, supportedApps = supportedApps, - patchesVersion = release.tagName, - latestPatchesVersion = release.tagName, - patchSourceName = patchSourceManager.getActiveSourceName(), + patchesVersion = displayVersion, + latestPatchesVersion = displayVersion, + patchSourceName = sourceName, patchLoadError = null, - isOffline = false + isOffline = isOffline ) } catch (e: Exception) { Logger.error("Quick mode: Failed to load patches", e) - // Try to fall back to cached .mpp file - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - try { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile)) - return@launch - } catch (inner: Exception) { - Logger.error("Quick mode: Failed to load cached patches fallback", inner) - } - } - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Failed to load patches: ${e.message}") + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = "Failed to load patches: ${e.message}" + ) } } } - /** - * Find any cached .mpp file when offline. - * Searches the per-source cache directory. - */ - private fun findCachedPatchFile(savedVersion: String?): File? { - val patchesDir = patchRepository.getCacheDir() - val patchFiles = patchesDir.listFiles { file -> - val ext = file.extension.lowercase() - ext == "mpp" || ext == "jar" - }?.filter { it.length() > 0 } ?: return null - - if (patchFiles.isEmpty()) return null - - return if (savedVersion != null) { - patchFiles.firstOrNull { it.name.contains(savedVersion, ignoreCase = true) } - ?: patchFiles.maxByOrNull { it.lastModified() } - } else { - patchFiles.maxByOrNull { it.lastModified() } - } - } - - private fun versionFromFilename(file: File): String { - val name = file.nameWithoutExtension - val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(name) - return match?.value ?: name - } - - /** - * Load patches from a local .mpp file (offline fallback). - */ - private suspend fun loadPatchesFromFile(patchFile: File, version: String, isOffline: Boolean = true) { - cachedPatchesFile = patchFile - - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches.isNullOrEmpty()) { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Could not load patches: ${patchesResult.exceptionOrNull()?.message}" - ) - return - } - - cachedPatches = patches - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - cachedSupportedApps = supportedApps - Logger.info("Quick mode: Loaded ${supportedApps.size} supported apps from ${if (isOffline) "cached" else "local"} patches: ${patchFile.name}") - - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - supportedApps = supportedApps, - patchesVersion = version, - patchSourceName = patchSourceManager.getActiveSourceName(), - patchLoadError = null, - isOffline = isOffline - ) - } - /** * Retry loading patches after a failure. */ @@ -355,10 +282,14 @@ class QuickPatchViewModel( } return try { - ApkFile(apkToParse).use { apk -> - val meta = apk.apkMeta - val packageName = meta.packageName - val versionName = meta.versionName ?: "Unknown" + // ARSCLib manifest reader (engine) — replaces apk-parser. Same + // library morphe-patcher uses; handles split APKs cleanly. + val manifest = ApkManifestReader.read(apkToParse) + ?: throw IllegalStateException("ARSCLib couldn't read manifest") + + run { + val packageName = manifest.packageName + val versionName = manifest.versionName ?: "Unknown" // Check if supported using dynamic data val dynamicAppInfo = cachedSupportedApps.find { it.packageName == packageName } @@ -376,7 +307,7 @@ class QuickPatchViewModel( } if (packageName !in supportedPackages) { - val appName = SupportedApp.resolveDisplayName(packageName, meta.label) + val appName = SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) val supportedNames = cachedSupportedApps.map { it.displayName } .ifEmpty { listOf("YouTube", "YouTube Music", "Reddit") } .joinToString(", ") @@ -390,7 +321,7 @@ class QuickPatchViewModel( // Get display name and recommended version from dynamic data, fallback to constants val displayName = dynamicAppInfo?.displayName - ?: SupportedApp.resolveDisplayName(packageName, meta.label) + ?: SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) val recommendedVersion = dynamicAppInfo?.recommendedVersion @@ -425,7 +356,7 @@ class QuickPatchViewModel( // Extract architectures — scan the original file (bundles have splits with native libs) val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) - val minSdk = meta.minSdkVersion?.toIntOrNull() + val minSdk = manifest.minSdkVersion Logger.info("Quick mode: Analyzed $displayName v$versionName (recommended: $recommendedVersion, status: $versionStatus, archs: $architectures)") @@ -516,31 +447,34 @@ class QuickPatchViewModel( progress = 0.4f ) - // Generate output path + // Generate output path via the shared engine helper — same path + // the CLI and Expert mode compute. Passing apkInfo.displayName + // as the display name preserves the friendly label. val appConfig = configRepository.loadConfig() - val baseName = apkInfo.displayName.replace(" ", "-") - val baseOutputDir = appConfig.defaultOutputDirectory?.let { File(it) } - ?: apkFile.parentFile - ?: File(System.getProperty("user.home")) - val outputDir = File(baseOutputDir, baseName).also { it.mkdirs() } - val patchesVersion = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") - .find(patchFile.name)?.groupValues?.get(1) - val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" - val outputFileName = "$baseName-Morphe-${apkInfo.versionName}${patchesSuffix}.apk" - val outputPath = File(outputDir, outputFileName).absolutePath - - // Resolve keystore: use saved path, or derive from output APK location - val resolvedKeystorePath = appConfig.keystorePath - ?: File(outputPath).let { out -> - out.resolveSibling(out.nameWithoutExtension + ".keystore").absolutePath - }.also { path -> - configRepository.setKeystorePath(path) - } + val outputPath = app.morphe.engine.util.ApkOutputNaming.outputApkPath( + inputApk = apkFile, + patchesFile = patchFile, + baseOutputDir = appConfig.resolvedDefaultOutputDirectory(), + appDisplayName = apkInfo.displayName, + ).absolutePath + + // Resolve keystore — see PatchingViewModel for the full rationale. + // User-configured: use it; fail loudly if missing. + // Default: shared MorpheData keystore, auto-created on first sign. + val userKeystore = appConfig.resolvedKeystorePath() + if (userKeystore != null && !userKeystore.exists()) { + val msg = "Configured keystore not found: ${userKeystore.absolutePath}. " + + "Restore the file, pick another in Settings, or clear the setting to use Morphe's default." + _uiState.value = _uiState.value.copy(phase = QuickPatchPhase.READY, error = msg) + Logger.error("Quick patching aborted: $msg") + return@launch + } + val resolvedKeystorePath = (userKeystore ?: MorpheData.defaultKeystoreFile).absolutePath // Use PatchService for direct library patching (no CLI subprocess) // exclusiveMode = false means the library's patch.use field determines defaults val patchResult = patchService.patch( - patchesFilePath = patchFile.absolutePath, + patchesFilePaths = currentResolvedPatchFiles().map { it.absolutePath }, inputApkPath = apkFile.absolutePath, outputApkPath = outputPath, enabledPatches = emptyList(), 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 fbce3149..64b0d6c8 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.repository.ConfigRepository import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -85,6 +86,8 @@ fun ResultScreenContent(outputPath: String) { // ADB state from DeviceMonitor val monitorState by DeviceMonitor.state.collectAsState() + val adbPreference = LocalAdbPreference.current + val isAdbDisabledByUser = !adbPreference.enabled var isInstalling by remember { mutableStateOf(false) } var installProgress by remember { mutableStateOf("") } var installError by remember { mutableStateOf(null) } @@ -352,7 +355,14 @@ fun ResultScreenContent(outputPath: String) { } // ADB Install section - if (monitorState.isAdbAvailable == true) { + if (isAdbDisabledByUser) { + AdbDisabledHint( + corners = corners, + mono = mono, + borderColor = borderColor, + onEnableClick = { adbPreference.onChange(true) } + ) + } else if (monitorState.isAdbAvailable == true) { AdbInstallSection( devices = monitorState.devices, selectedDevice = monitorState.selectedDevice, @@ -393,8 +403,10 @@ fun ResultScreenContent(outputPath: String) { ) } - // ADB help text - if (monitorState.isAdbAvailable == false) { + // ADB help text — only when the toggle is ON but the binary is + // missing. When the toggle is OFF, AdbDisabledHint above carries + // the explanation; suppress the duplicate "ADB not found" text. + if (!isAdbDisabledByUser && monitorState.isAdbAvailable == false) { Text( text = "ADB not found. Install Android SDK Platform Tools to enable direct installation.", fontSize = 10.sp, @@ -868,6 +880,87 @@ private fun CleanupSection( } } +/** + * Replaces [AdbInstallSection] when the user has the auto-start ADB toggle off. + * Mirrors the bordered card layout so the result screen doesn't collapse — + * but the install button is replaced with a clearly-disabled "ENABLE ADB" + * hint that flips the toggle in one click. + */ +@Composable +private fun AdbDisabledHint( + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + onEnableClick: () -> Unit, +) { + val accents = LocalMorpheAccents.current + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) { + Text( + text = "ADB INSTALL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "ADB is off. Install-on-device is disabled.", + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Enable ADB in Settings to push patched APKs directly.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + ) + Spacer(Modifier.height(14.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (isHovered) accents.primary.copy(alpha = 0.5f) + else accents.primary.copy(alpha = 0.25f), + RoundedCornerShape(corners.small) + ) + .background( + if (isHovered) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + ) + .clickable(onClick = onEnableClick), + contentAlignment = Alignment.Center + ) { + Text( + text = "ENABLE ADB", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 1.sp + ) + } + } + } +} + private fun formatFileSize(bytes: Long): String { return when { bytes < 1024 -> "$bytes B" diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt index da3715bc..df2f8dc9 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt @@ -95,6 +95,14 @@ private val MatchaAccents = MorpheAccentColors( warning = Color(0xFFB77833), // Toasted ochre ) +/** Deepspace — high-saturation cyan on near-black. Cyberdeck/dev-tool aesthetic. */ +private val DeepspaceAccents = MorpheAccentColors( + primary = Color(0xFF00D9FF), // Electric cyan — primary + secondary = Color(0xFF79E3A5), // Mint green — stable / success + tertiary = Color(0xFF7AB7FF), // Cool blue — structural + warning = Color(0xFFFFB347), // Warm amber — older / warning +) + // ════════════════════════════════════════════════════════════════════ // CORNER / SHAPE STYLE SYSTEM // ════════════════════════════════════════════════════════════════════ @@ -111,6 +119,24 @@ data class MorpheCornerStyle( val LocalMorpheCorners = compositionLocalOf { MorpheCornerStyle() } +/** + * Canonical control sizing across the app. Use these instead of hardcoded `.dp` + * values for buttons, text fields, search bars, and dialog action rows so the + * same dimensions apply everywhere — no per-screen drift. + * + * - [controlHeight]: standard interactive height (buttons, text fields, pills, + * search bars). Matches the height of OPEN LOGS / OPEN APP DATA action buttons. + * - [iconInControl]: icon size used inside controlHeight-sized affordances. + * - [controlHorizontalPadding]: standard horizontal padding inside a control. + */ +data class MorpheDimens( + val controlHeight: Dp = 36.dp, + val iconInControl: Dp = 14.dp, + val controlHorizontalPadding: Dp = 12.dp, +) + +val LocalMorpheDimens = compositionLocalOf { MorpheDimens() } + /** Sharp corners for cyberdeck/dev themes. */ private val SharpCorners = MorpheCornerStyle(small = 2.dp, medium = 2.dp, large = 2.dp) @@ -248,6 +274,25 @@ private val MatchaColorScheme = lightColorScheme( onError = Color.White ) +// ── Deepspace ── +// Cyberdeck dev-tool aesthetic: electric cyan + mint on near-black blue. +private val DeepspaceColorScheme = darkColorScheme( + primary = Color(0xFF00D9FF), // Electric cyan + secondary = Color(0xFF79E3A5), // Mint green + tertiary = Color(0xFF7AB7FF), // Cool blue + background = Color(0xFF0D1117), // Near-black blue + surface = Color(0xFF14191F), // Slightly raised + surfaceVariant = Color(0xFF1B2128), // Card surfaces + onPrimary = Color(0xFF001A22), // Deep cyan-black for high contrast on cyan + onSecondary = Color(0xFF0A2317), // Deep green-black on mint + onTertiary = Color(0xFF051628), // Deep blue-black + onBackground = Color(0xFFD6DEEB), // Warm light text + onSurface = Color(0xFFD6DEEB), + onSurfaceVariant = Color(0xFF8E97A6), // Muted text + error = Color(0xFFFF6B6B), + onError = Color(0xFF1E0707), +) + // ════════════════════════════════════════════════════════════════════ // THEME PREFERENCE // ════════════════════════════════════════════════════════════════════ @@ -260,11 +305,12 @@ enum class ThemePreference { CATPPUCCIN, SAKURA, MATCHA, + DEEPSPACE, SYSTEM; /** Whether this theme uses dark color scheme (for resource qualifiers). */ fun isDark(): Boolean = when (this) { - DARK, AMOLED, NORD, CATPPUCCIN -> true + DARK, AMOLED, NORD, CATPPUCCIN, DEEPSPACE -> true LIGHT, SAKURA, MATCHA -> false SYSTEM -> false // caller should check isSystemInDarkTheme() } @@ -293,6 +339,7 @@ fun MorpheTheme( ThemePreference.CATPPUCCIN -> CatppuccinMochaColorScheme ThemePreference.SAKURA -> SakuraColorScheme ThemePreference.MATCHA -> MatchaColorScheme + ThemePreference.DEEPSPACE -> DeepspaceColorScheme ThemePreference.SYSTEM -> { if (isSystemInDarkTheme()) MorpheDarkColorScheme else MorpheLightColorScheme } @@ -308,13 +355,15 @@ fun MorpheTheme( ThemePreference.CATPPUCCIN -> CatppuccinAccents ThemePreference.SAKURA -> SakuraAccents ThemePreference.MATCHA -> MatchaAccents + ThemePreference.DEEPSPACE -> DeepspaceAccents ThemePreference.SYSTEM -> if (isSystemInDarkTheme()) DarkAccents else LightAccents } CompositionLocalProvider( LocalMorpheCorners provides corners, LocalMorpheFont provides font, - LocalMorpheAccents provides accents + LocalMorpheAccents provides accents, + LocalMorpheDimens provides MorpheDimens(), ) { MaterialTheme( colorScheme = colorScheme, diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt index cfcdda20..b8fa6e1c 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -8,6 +8,8 @@ package app.morphe.gui.util import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File +import java.net.InetSocketAddress +import java.net.Socket /** * Manages ADB (Android Debug Bridge) operations for installing APKs. @@ -17,6 +19,49 @@ class AdbManager { private var adbPath: String? = null + /** + * Set to true once [startServer] confirms Morphe was the process that + * spawned the ADB daemon (vs. attaching to one that was already running — + * Android Studio, scrcpy, a prior shell session). Gates [killServerIfOwned] + * so we never nuke a daemon someone else is depending on. + * + * Updated on every [startServer] call: if we attach to a pre-existing + * daemon and that daemon later dies + our next [startServer] tick + * respawns it, ownership flips from false → true. Without that, the + * polling loop's implicit respawns would leak a daemon Morphe is + * actively maintaining. + */ + @Volatile + var weStartedDaemon: Boolean = false + private set + + /** + * One-shot log dedup for the "we attached to a pre-existing daemon" + * message — without this, the polling loop would log it every 5s. + * Reset by [killServerIfOwned] so a re-attach after a kill cycle + * re-logs once. + */ + @Volatile + private var loggedAttachOnce: Boolean = false + + /** + * Cheap probe to check if the ADB daemon is listening on its conventional + * loopback port (5037). Used by [startServer] to detect ownership without + * relying on adb's stderr output (which varies across versions and can be + * suppressed when invoked programmatically). + * + * Short timeout keeps the polling loop snappy; localhost connects in <1ms + * when alive, refuses immediately when down. + */ + private fun isDaemonAlive(): Boolean = try { + Socket().use { socket -> + socket.connect(InetSocketAddress("127.0.0.1", 5037), 250) + } + true + } catch (_: Exception) { + false + } + /** * Find ADB binary in common locations or PATH. * Returns the path to ADB if found, null otherwise. @@ -106,6 +151,102 @@ class AdbManager { */ suspend fun isAdbAvailable(): Boolean = findAdb() != null + /** + * Ensure the ADB daemon is running, and record whether Morphe was the + * process that spawned the *current* daemon. Idempotent and cheap — safe + * to call on every poll tick. + * + * Detection is a TCP probe of 127.0.0.1:5037 (adb's conventional listen + * port). Before: alive? After invoking `adb start-server`: alive? + * - was-down + now-up → we own it (set [weStartedDaemon] = true). + * - was-up → no-op; ownership flag unchanged. + * + * Re-detection on every call matters: if Morphe initially attached to a + * pre-existing daemon (flag = false) and that daemon dies mid-session, + * the *next* tick's call will spawn a fresh one and flip the flag to + * true — so a subsequent [killServerIfOwned] correctly tears it down. + */ + suspend fun startServer(): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + try { + if (isDaemonAlive()) { + // Daemon already up — Morphe is attaching, not spawning. + // Don't touch the ownership flag (a prior tick may have set + // it to true and the daemon is still ours). + if (!weStartedDaemon && !loggedAttachOnce) { + Logger.info("ADB daemon was already running — leaving it alone on shutdown") + loggedAttachOnce = true + } + return@withContext Result.success(Unit) + } + + // Daemon is down. Spawn it. + val process = ProcessBuilder(adb, "start-server") + .redirectErrorStream(true) + .start() + process.inputStream.bufferedReader().readText() // drain so the child exits cleanly + val exitCode = process.waitFor() + if (exitCode != 0) { + return@withContext Result.failure( + AdbException("adb start-server exited with code $exitCode") + ) + } + + if (isDaemonAlive()) { + if (!weStartedDaemon) { + Logger.info("ADB daemon spawned by Morphe — will kill on shutdown") + } + weStartedDaemon = true + loggedAttachOnce = false + } else { + Logger.warn("adb start-server returned success but port 5037 is still closed") + } + Result.success(Unit) + } catch (e: Exception) { + Logger.error("Failed to start ADB server", e) + Result.failure(AdbException("Failed to start ADB server: ${e.message}")) + } + } + + /** + * Kill the ADB server, but only if [weStartedDaemon] — i.e. Morphe was + * the one that spawned it. Refusing to kill a daemon we attached to + * keeps Android Studio / scrcpy / other concurrent users alive. + * + * Clears [weStartedDaemon] on success so repeated calls are idempotent. + */ + suspend fun killServerIfOwned(): Result = withContext(Dispatchers.IO) { + if (!weStartedDaemon) { + Logger.debug("Skipping adb kill-server — daemon wasn't started by Morphe") + return@withContext Result.success(false) + } + val adb = findAdb() ?: return@withContext Result.success(false) + + try { + val process = ProcessBuilder(adb, "kill-server") + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + if (exitCode != 0) { + Logger.warn("adb kill-server exited with code $exitCode: $output") + return@withContext Result.failure( + AdbException("adb kill-server exited with code $exitCode") + ) + } + weStartedDaemon = false + loggedAttachOnce = false // next attach (if any) re-logs once + Logger.info("ADB daemon killed by Morphe") + Result.success(true) + } catch (e: Exception) { + Logger.error("Failed to kill ADB server", e) + Result.failure(AdbException("Failed to kill ADB server: ${e.message}")) + } + } + /** * Get list of connected devices. * Returns list of device IDs and their status. @@ -237,6 +378,83 @@ class AdbManager { } } + /** + * Clear the device's logcat buffers (main + crash). + * Crash buffer clear is best-effort — older devices may not have it. + */ + suspend fun clearLogcat(deviceId: String): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + try { + val main = ProcessBuilder(adb, "-s", deviceId, "logcat", "-c") + .redirectErrorStream(true) + .start() + val mainOutput = main.inputStream.bufferedReader().readText() + if (main.waitFor() != 0) { + return@withContext Result.failure(AdbException("Failed to clear logs: $mainOutput")) + } + + // Best-effort: also clear the crash buffer. Ignore failure. + try { + val crash = ProcessBuilder(adb, "-s", deviceId, "logcat", "-b", "crash", "-c") + .redirectErrorStream(true) + .start() + crash.inputStream.bufferedReader().readText() + crash.waitFor() + } catch (_: Exception) { /* older devices may not have crash buffer */ } + + Logger.info("Cleared logcat on $deviceId") + Result.success(Unit) + } catch (e: Exception) { + Logger.error("Error clearing logcat", e) + Result.failure(AdbException("Failed to clear logs: ${e.message}")) + } + } + + /** + * Capture a logcat snapshot from the device, filtered to lines that contain + * "morphe:" or "AndroidRuntime", and write them to [outputFile]. + * Returns the number of lines written. + */ + suspend fun captureLogcat(deviceId: String, outputFile: File): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + try { + val process = ProcessBuilder(adb, "-s", deviceId, "logcat", "-d", "-b", "main,crash") + .redirectErrorStream(true) + .start() + + val kept = mutableListOf() + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> + if (line.contains("morphe:", ignoreCase = true) || line.contains("AndroidRuntime")) { + kept += line + } + } + } + val exitCode = process.waitFor() + if (exitCode != 0) { + return@withContext Result.failure(AdbException("logcat exited with code $exitCode")) + } + + if (kept.isEmpty()) { + Logger.info("No matching logcat lines on $deviceId — skipping file write") + } else { + outputFile.parentFile?.mkdirs() + outputFile.writeText(kept.joinToString("\n") + "\n") + Logger.info("Captured ${kept.size} logcat line(s) to ${outputFile.absolutePath}") + } + Result.success(kept.size) + } catch (e: Exception) { + Logger.error("Error capturing logcat", e) + Result.failure(AdbException("Failed to capture logs: ${e.message}")) + } + } + /** * Parse output from 'adb devices -l' command. * Example line: "XXXXXXXX device usb:1-1 product:flame model:Pixel_4 device:flame transport_id:1" diff --git a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt index 1ce204e0..cc8a3fa0 100644 --- a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt +++ b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt @@ -34,8 +34,14 @@ object DeviceMonitor { if (!adbAvailable) return@launch - // Poll every 5 seconds + // Re-detect ownership on every poll. startServer() is cheap when + // the daemon's already alive (TCP port probe + early-return), + // and when the daemon has died externally + Morphe respawns it, + // ownership correctly flips to true — so a later kill on toggle + // OFF / window close tears down the daemon Morphe is actively + // keeping alive instead of leaking it. while (isActive) { + adbManager.startServer() refreshDevices() delay(5000) } @@ -47,6 +53,20 @@ object DeviceMonitor { pollingJob = null } + /** + * Stop polling AND kill the ADB daemon if Morphe owns it. Use this when + * the user toggles auto-start ADB OFF or closes the window. The owned-check + * lives in [AdbManager.killServerIfOwned] — if the daemon was already + * running when Morphe attached, this is a no-op. + * + * Clears device state immediately so UI doesn't flash stale entries. + */ + suspend fun stopMonitoringAndKillIfOwned() { + stopMonitoring() + _state.value = DeviceMonitorState(isAdbAvailable = _state.value.isAdbAvailable) + adbManager.killServerIfOwned() + } + fun selectDevice(device: AdbDevice) { _state.value = _state.value.copy(selectedDevice = device) } diff --git a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt new file mode 100644 index 00000000..a85e7fa0 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.util + +import app.morphe.engine.MultiSourceLoader +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.data.repository.PatchRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.io.File + +/** + * GUI-side orchestrator that resolves each enabled patch source to a downloaded + * `.mpp` file (LOCAL = read filePath, GITHUB = fetch latest release + download) + * in parallel, then hands the resulting files to [MultiSourceLoader] for the + * actual patch loading + union. + * + * The single-source case (one enabled source) produces output equivalent to the + * pre-multi-source per-ViewModel flow. Per-source version pinning via + * [preferredVersionsBySource] keeps each source independent — picking a tag in + * one source's PatchesScreen does NOT contaminate other sources. + */ +object EnabledSourcesLoader { + + /** + * Per-source resolution result before patch-loading. Successful sources have + * a [patchFile]; failed ones have an [error] message and the UI can render + * the failure inline. + */ + /** What channel the resolved release is on. Used by the home pill LEDs and + * the sheet's channel badge so we don't keep re-deriving from tag strings. */ + enum class Channel { STABLE_LATEST, STABLE_OLDER, DEV_LATEST, DEV_OLDER, UNKNOWN } + + data class ResolvedSource( + val source: PatchSource, + val patchFile: File? = null, + val resolvedVersion: String? = null, + val isOffline: Boolean = false, + val error: String? = null, + val channel: Channel = Channel.UNKNOWN, + ) + + data class Result( + /** Resolution outcome per source (success or failure). */ + val resolved: List, + /** MultiSourceLoader output across the successfully-resolved sources. */ + val loaded: MultiSourceLoader.Result, + /** Union of GUI patches across all sources, for SupportedAppExtractor / UI. */ + val unionGuiPatches: List, + /** GUI patches grouped by sourceId, for badging UI in PatchSelectionScreen. */ + val guiPatchesBySource: Map>, + ) { + val anyLoaded: Boolean get() = loaded.allPatches.isNotEmpty() + val anyFailed: Boolean get() = resolved.any { it.error != null } || loaded.hasErrors + } + + /** + * Resolve and load every enabled source in parallel. + * + * @param enabled list of (source, repository) pairs from + * [app.morphe.gui.data.repository.PatchSourceManager.getEnabledRepositories]. + * Repository is null for LOCAL sources. + */ + suspend fun loadAll( + enabled: List>, + patchService: PatchService, + preferredVersionsBySource: Map = emptyMap(), + ): Result = coroutineScope { + val resolved = enabled.map { (source, repo) -> + async(Dispatchers.IO) { resolve(source, repo, preferredVersionsBySource[source.id]) } + }.awaitAll() + + val inputs = resolved.mapNotNull { res -> + val file = res.patchFile ?: return@mapNotNull null + MultiSourceLoader.SourceInput( + sourceId = res.source.id, + sourceName = res.source.name, + patchFile = file, + ) + } + + val loaded = if (inputs.isEmpty()) { + MultiSourceLoader.Result( + perSource = emptyList(), + allPatches = emptySet(), + patchToSourceIds = emptyMap(), + ) + } else { + MultiSourceLoader.load(inputs) + } + + // Convert library patches → GUI patches once. Both the union and per-source + // groupings are derived from this single conversion. + val unionGui = patchService.convertToGuiPatches(loaded.allPatches) + val guiBySource: Map> = + loaded.perSource.associate { src -> + src.sourceId to patchService.convertToGuiPatches(src.patches) + } + + Result( + resolved = resolved, + loaded = loaded, + unionGuiPatches = unionGui, + guiPatchesBySource = guiBySource, + ) + } + + private suspend fun resolve( + source: PatchSource, + repo: PatchRepository?, + preferredVersion: String?, + ): ResolvedSource = withContext(Dispatchers.IO) { + when (source.type) { + PatchSourceType.LOCAL -> resolveLocal(source) + // GitHub / GitLab / built-in default all flow through the same + // remote-fetch path. The PatchRepository instance itself knows + // which API to talk to based on the source's provider type. + PatchSourceType.DEFAULT, + PatchSourceType.GITHUB, + PatchSourceType.GITLAB -> resolveRemote(source, repo, preferredVersion) + } + } + + private fun resolveLocal(source: PatchSource): ResolvedSource { + val path = source.filePath + if (path.isNullOrBlank()) { + return ResolvedSource(source = source, error = "Local source has no file path configured") + } + val file = File(path) + if (!file.exists()) { + return ResolvedSource(source = source, error = "Local patch file not found: ${file.name}") + } + return ResolvedSource( + source = source, + patchFile = file, + resolvedVersion = file.nameWithoutExtension, + isOffline = false, + ) + } + + private suspend fun resolveRemote( + source: PatchSource, + repo: PatchRepository?, + preferredVersion: String?, + ): ResolvedSource { + if (repo == null) { + return ResolvedSource(source = source, error = "No repository configured for source") + } + + val releasesResult = repo.fetchReleases() + val releases = releasesResult.getOrNull() + + if (releases.isNullOrEmpty()) { + // Offline fallback: scan source's cache dir for any .mpp/.jar file + val cached = findCachedPatchFile(repo) + if (cached != null) { + return ResolvedSource( + source = source, + patchFile = cached, + resolvedVersion = versionFromFilename(cached), + isOffline = true, + ) + } + val errMsg = releasesResult.exceptionOrNull()?.message ?: "Could not fetch releases" + return ResolvedSource(source = source, error = errMsg) + } + + // Honor a user-pinned version if it exists in this source's releases. + // Otherwise pick latest stable, falling back to latest dev. + val release = preferredVersion + ?.let { pinned -> releases.find { it.tagName == pinned } } + ?: releases.firstOrNull { !it.isDevRelease() } + ?: releases.firstOrNull() + ?: return ResolvedSource(source = source, error = "No releases found") + + // Classify against this source's release list so the LED + badge can + // distinguish "latest stable" from "older stable" from "dev". + val latestStableTag = releases.firstOrNull { !it.isDevRelease() }?.tagName + val latestDevTag = releases.firstOrNull { it.isDevRelease() }?.tagName + val channel = when { + release.isDevRelease() && release.tagName == latestDevTag -> Channel.DEV_LATEST + release.isDevRelease() -> Channel.DEV_OLDER + release.tagName == latestStableTag -> Channel.STABLE_LATEST + else -> Channel.STABLE_OLDER + } + + val downloadResult = repo.downloadPatches(release) + val patchFile = downloadResult.getOrNull() + ?: return ResolvedSource( + source = source, + error = "Could not download patches: ${downloadResult.exceptionOrNull()?.message}", + ) + + return ResolvedSource( + source = source, + patchFile = patchFile, + resolvedVersion = release.tagName, + isOffline = false, + channel = channel, + ) + } + + private fun findCachedPatchFile(repo: PatchRepository): File? { + val cacheDir = repo.getCacheDir() + return cacheDir.listFiles { file -> + val ext = file.extension.lowercase() + (ext == "mpp" || ext == "jar") && file.length() > 0 + }?.maxByOrNull { it.lastModified() } + } + + private fun versionFromFilename(file: File): String { + val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(file.nameWithoutExtension) + return match?.value ?: file.nameWithoutExtension + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt index c229a158..45532cab 100644 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -5,18 +5,22 @@ package app.morphe.gui.util +import app.morphe.engine.MorpheData import java.io.File -import java.nio.file.Paths import java.util.zip.ZipFile /** * Platform-agnostic file utilities. * Handles app directories, temp files, and cross-platform path operations. + * + * Directory paths delegate to [MorpheData] (the engine-level single source of + * truth) so the GUI, CLI, and any future surface all agree on where data + * lives. The previous per-OS app-data folders (`%APPDATA%/morphe-gui`, + * `~/Library/Application Support/morphe-gui`, `~/.config/morphe-gui`) are + * superseded by `MorpheData.root` — see `unified-data-location-plan.md`. */ object FileUtils { - private const val APP_NAME = "morphe-gui" - /** * All modern Android architectures. Obsolete architectures such as Mips are not included. */ @@ -25,64 +29,25 @@ object FileUtils { private val EXTENSION_APK_BUNDLES = setOf("apkm", "xapk", "apks") private val EXTENSION_APK_ANY = EXTENSION_APK_BUNDLES + "apk" - /** - * Get the app data directory based on OS. - * - Windows: %APPDATA%/morphe-gui - * - macOS: ~/Library/Application Support/morphe-gui - * - Linux: ~/.config/morphe-gui - */ - fun getAppDataDir(): File { - val osName = System.getProperty("os.name").lowercase() - val userHome = System.getProperty("user.home") + /** Returns the unified Morphe data root. Was: per-OS app-data folder. */ + fun getAppDataDir(): File = MorpheData.root - val appDataPath = when { - osName.contains("win") -> { - val appData = System.getenv("APPDATA") ?: Paths.get(userHome, "AppData", "Roaming").toString() - Paths.get(appData, APP_NAME) - } - osName.contains("mac") -> { - Paths.get(userHome, "Library", "Application Support", APP_NAME) - } - else -> { - // Linux and others - Paths.get(userHome, ".config", APP_NAME) - } - } - - return appDataPath.toFile().also { it.mkdirs() } - } - - /** - * Get the patches cache directory. - */ - fun getPatchesDir(): File { - return File(getAppDataDir(), "patches").also { it.mkdirs() } - } + /** Returns the patches cache directory. */ + fun getPatchesDir(): File = MorpheData.patchesDir - /** - * Get the logs directory. - */ - fun getLogsDir(): File { - return File(getAppDataDir(), "logs").also { it.mkdirs() } - } + /** Returns the logs directory. */ + fun getLogsDir(): File = MorpheData.logsDir - /** - * Get the config file path. - */ - fun getConfigFile(): File { - return File(getAppDataDir(), "config.json") - } + /** Returns the GUI config file path. */ + fun getConfigFile(): File = MorpheData.configFile - /** - * Get the app temp directory for patching operations. - */ - fun getTempDir(): File { - val systemTemp = System.getProperty("java.io.tmpdir") - return File(systemTemp, APP_NAME).also { it.mkdirs() } - } + /** Returns the patcher-scratch directory shared with the CLI. */ + fun getTempDir(): File = MorpheData.tmpDir /** - * Create a unique temp directory for a patching session. + * Create a unique temp directory for a patching session. Session-scoped + * timestamp keeps concurrent CLI/GUI patches from stepping on each other + * (see Phase 6 of the unified-data-location plan). */ fun createPatchingTempDir(): File { val timestamp = System.currentTimeMillis() diff --git a/src/main/kotlin/app/morphe/gui/util/Logger.kt b/src/main/kotlin/app/morphe/gui/util/Logger.kt index 31cef73d..b05e95a0 100644 --- a/src/main/kotlin/app/morphe/gui/util/Logger.kt +++ b/src/main/kotlin/app/morphe/gui/util/Logger.kt @@ -14,7 +14,10 @@ import java.util.* /** * Simple file logger with rotation support. - * Logs to ~/.morphe-gui/logs/morphe-gui.log + * + * Log file location: `/logs/morphe-gui.log` — JAR-adjacent + * `morphe-data/logs/` for shipped jars, `~/morphe/logs/` for IDE/dev runs. + * See [app.morphe.engine.MorpheData] for the full resolution + fallback rules. */ object Logger { diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 53513874..5f96e184 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -77,7 +77,7 @@ class PatchService { * Delegates to PatchEngine for the actual pipeline. */ suspend fun patch( - patchesFilePath: String, + patchesFilePaths: List, inputApkPath: String, outputApkPath: String, enabledPatches: List = emptyList(), @@ -93,23 +93,29 @@ class PatchService { onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { try { - val patchFile = File(patchesFilePath) + if (patchesFilePaths.isEmpty()) { + return@withContext Result.failure(Exception("No patches files supplied")) + } + val patchFiles = patchesFilePaths.map { File(it) } val inputApk = File(inputApkPath) val outputFile = File(outputApkPath) - if (!patchFile.exists()) { - return@withContext Result.failure(Exception("Patches file not found")) + patchFiles.firstOrNull { !it.exists() }?.let { + return@withContext Result.failure(Exception("Patches file not found: ${it.name}")) } if (!inputApk.exists()) { return@withContext Result.failure(Exception("Input APK not found")) } - // Load patches (copy to temp to avoid Windows file lock) + // Load patches (copy each to temp to avoid Windows file lock) onProgress("Loading patches...") - val patchTempCopy = File.createTempFile("morphe-patches-", ".mpp") + val tempCopies = patchFiles.map { src -> + val tmp = File.createTempFile("morphe-patches-", ".mpp") + src.copyTo(tmp, overwrite = true) + tmp + } try { - patchFile.copyTo(patchTempCopy, overwrite = true) - val loadedPatches = loadPatchesFromJar(setOf(patchTempCopy)) + val loadedPatches = loadPatchesFromJar(tempCopies.toSet()) // Convert GUI's flat "patchName.optionKey" -> value map // to engine's Map> format @@ -144,14 +150,25 @@ class PatchService { val engineResult = PatchEngine.patch(config, onProgress) + val failureReason = if (engineResult.success) null else { + // Prefer a specific failed-patch error, else the last failed + // step's error (rebuild/sign), else a generic fallback. + engineResult.failedPatches.firstOrNull()?.let { fp -> + "${fp.name}: ${fp.error.lineSequence().first()}" + } + ?: engineResult.stepResults.lastOrNull { !it.success && it.error != null } + ?.let { "${it.step.name.lowercase().replaceFirstChar { c -> c.uppercase() }} failed: ${it.error}" } + ?: "Patching failed for an unknown reason" + } Result.success(PatchResult( success = engineResult.success, outputPath = engineResult.outputPath, appliedPatches = engineResult.appliedPatches, failedPatches = engineResult.failedPatches.map { it.name }, + failureReason = failureReason, )) } finally { - patchTempCopy.delete() + tempCopies.forEach { runCatching { it.delete() } } } } catch (e: Exception) { Logger.error("Patching failed", e) @@ -159,25 +176,59 @@ class PatchService { } } + /** + * Convert a set of already-loaded library patches into GUI patches. + * Used by EnabledSourcesLoader / MultiSourceLoader paths so we don't have to + * re-open the .mpp file just to convert. + */ + fun convertToGuiPatches(loaded: Set>): List = + loaded.map { it.toGuiPatch() } + /** * Convert library Patch to GUI Patch model. + * + * Reads BOTH the new [compatibility] API and the deprecated [compatiblePackages] + * field — some forks (e.g. hoo-dles) compiled their patches against the older + * patcher API and only declare compatibility via the legacy field. Without the + * fallback, those patches would convert to a GUI Patch with empty + * compatiblePackages, which means SupportedAppExtractor under-counts apps and + * the per-source attribution map misses entire sources. */ + @Suppress("DEPRECATION") private fun LibraryPatch<*>.toGuiPatch(): Patch { - return Patch( - name = this.name ?: "Unknown", - description = this.description ?: "", - compatiblePackages = this.compatibility - ?.mapNotNull { compatibility -> - val packageName = compatibility.packageName ?: return@mapNotNull null - val (experimental, stable) = compatibility.targets.partition { it.isExperimental } + // Primary: new compatibility API (typed, with experimental flag, display name). + val fromNewApi: List = this.compatibility + ?.mapNotNull { compatibility -> + val packageName = compatibility.packageName ?: return@mapNotNull null + val (experimental, stable) = compatibility.targets.partition { it.isExperimental } + CompatiblePackage( + name = packageName, + displayName = compatibility.name, + versions = stable.mapNotNull { it.version }, + experimentalVersions = experimental.mapNotNull { it.version } + ) + } + ?: emptyList() + + // Fallback: legacy compatiblePackages field (Set>). + // No display name or experimental flag in the legacy schema — those stay null/empty. + val fromLegacyApi: List = if (fromNewApi.isEmpty()) { + this.compatiblePackages + ?.map { (pkgName, versions) -> CompatiblePackage( - name = packageName, - displayName = compatibility.name, - versions = stable.mapNotNull { it.version }, - experimentalVersions = experimental.mapNotNull { it.version } + name = pkgName, + displayName = null, + versions = versions?.toList() ?: emptyList(), + experimentalVersions = emptyList(), ) } - ?: emptyList(), + ?: emptyList() + } else emptyList() + + return Patch( + name = this.name ?: "Unknown", + description = this.description ?: "", + compatiblePackages = fromNewApi.ifEmpty { fromLegacyApi }, options = this.options.values.map { opt -> PatchOption( key = opt.key, @@ -220,5 +271,9 @@ data class PatchResult( val success: Boolean, val outputPath: String, val appliedPatches: List, - val failedPatches: List + val failedPatches: List, + // Human-readable reason for [success == false]. Populated from the first + // 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, )