diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 9d01efa9..0f4ac831 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/open_pull_request.yml b/.github/workflows/open_pull_request.yml index 75b8e67f..113a91cf 100644 --- a/.github/workflows/open_pull_request.yml +++ b/.github/workflows/open_pull_request.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Open pull request uses: repo-sync/pull-request@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b13e7d07..3ffa72ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: # Make sure the release step uses its own credentials: # https://github.com/cycjimmy/semantic-release-action#private-packages diff --git a/CHANGELOG.md b/CHANGELOG.md index 01657333..f4f4829c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,80 @@ +# [1.10.0-dev.10](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.9...v1.10.0-dev.10) (2026-07-09) + + +### Bug Fixes + +* use latest patcher ([d7e9b62](https://github.com/MorpheApp/morphe-cli/commit/d7e9b625ae0571f2bafa082a3716138a2d7e2e8e)) + +# [1.10.0-dev.9](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.8...v1.10.0-dev.9) (2026-07-02) + + +### Features + +* cli ignores unknown keys (fix) + unify CLI/GUI patch cache + add clear-cache + make purge opt-out ([#192](https://github.com/MorpheApp/morphe-cli/issues/192)) ([70d16f0](https://github.com/MorpheApp/morphe-cli/commit/70d16f0e105f6362ee45e5e600c9ded01ab6c09e)) + +# [1.10.0-dev.8](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.7...v1.10.0-dev.8) (2026-07-02) + + +### Bug Fixes + +* Allow patches with empty targets to apply automatically ([#207](https://github.com/MorpheApp/morphe-cli/issues/207)) ([5a07bf7](https://github.com/MorpheApp/morphe-cli/commit/5a07bf7d6fe34d42e2dd1b95e601386e6e214f83)) + +# [1.10.0-dev.7](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.6...v1.10.0-dev.7) (2026-06-29) + + +### Features + +* Add `--include-experimental` flag to `list-patches` and `list-versions` ([#189](https://github.com/MorpheApp/morphe-cli/issues/189)) ([8ef916c](https://github.com/MorpheApp/morphe-cli/commit/8ef916cdaab28e51c244906341a32d9434126ccf)) + +# [1.10.0-dev.6](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.5...v1.10.0-dev.6) (2026-06-29) + + +### Bug Fixes + +* Update to latest patcher ([2c89128](https://github.com/MorpheApp/morphe-cli/commit/2c891284460762e15115f21c83036ac544549372)) + +# [1.10.0-dev.5](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.4...v1.10.0-dev.5) (2026-06-28) + + +### Features + +* Patch sources follows latest (stable/dev) instead of to last used version ([#185](https://github.com/MorpheApp/morphe-cli/issues/185)) ([5d34993](https://github.com/MorpheApp/morphe-cli/commit/5d349931b283f62f617e7a1ceb10009cf2639a8e)) + +# [1.10.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.3...v1.10.0-dev.4) (2026-06-27) + + +### Features + +* Array GUI patches alphabetically, improve error logging, use separate class loader for multiple patch bundles ([#187](https://github.com/MorpheApp/morphe-cli/issues/187)) ([755909c](https://github.com/MorpheApp/morphe-cli/commit/755909ca2ab8735ffe99b3ad200a80316e6ec0c9)) + +# [1.10.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.2...v1.10.0-dev.3) (2026-06-23) + + +### Bug Fixes + +* Handle GUI patching xapk / apks, add distinct patch-source channel colors, improve patch loading errors ([#180](https://github.com/MorpheApp/morphe-cli/issues/180)) ([d8245f0](https://github.com/MorpheApp/morphe-cli/commit/d8245f081b507cac74b06e16120e4271ce0e1f96)) + +# [1.10.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.10.0-dev.1...v1.10.0-dev.2) (2026-06-23) + + +### Features + +* route links through patched app + uninstall from "Your Apps" section's cards ([#179](https://github.com/MorpheApp/morphe-cli/issues/179)) ([1626ce5](https://github.com/MorpheApp/morphe-cli/commit/1626ce5ffe3300de2299443ed9b7587ebd1cdcb6)) + +# [1.10.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.2-dev.1...v1.10.0-dev.1) (2026-06-15) + + +### Features + +* "your apps" section + settings and tools split + re-orderable patch sources + block play store updates for patched apps ([#176](https://github.com/MorpheApp/morphe-cli/issues/176)) ([d865abd](https://github.com/MorpheApp/morphe-cli/commit/d865abdbbe2258cf24476add02a361b05f3fdf7e)) + +## [1.9.2-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.1...v1.9.2-dev.1) (2026-06-15) + + +### Bug Fixes + +* CLI --purge command does not cleanup temp APK ([#169](https://github.com/MorpheApp/morphe-cli/issues/169)) ([6163e9c](https://github.com/MorpheApp/morphe-cli/commit/6163e9c29070e5013274792437d05f8ed2c20853)) + ## [1.9.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0...v1.9.1) (2026-06-08) diff --git a/NOTICE b/NOTICE index 7cee7880..df948cda 100644 --- a/NOTICE +++ b/NOTICE @@ -4,15 +4,26 @@ https://github.com/MorpheApp/morphe-cli ============= -7b. Attribution Requirement ---------------------------- +GPLv3 Section 7b: Preservation of User Notices -This NOTICE file must be preserved and retained in all distributions -of the Source Code and any Derivative Works. +All derivative works (source code and binaries) must include this +NOTICE text and provide it to the software user. -7c. Project Name Restriction ----------------------------- +Portions of this software are provided "AS IS" by the Morphe software project. +Any express or implied warranties, including the implied warranties of +merchantability and fitness for a particular purpose, are disclaimed. -The project name "Morphe" is a protected identifier. Derivative works -must adopt a completely different identity that is not related to, -confusingly similar to, or an imitation of the name "Morphe". + +GPLv3 Section 7c: Prohibiting Misrepresentation of Origin + +You are prohibited from misrepresenting the origin of the Program, +and modified versions of the Program must be identified and marked in +reasonable ways as different from the original version so as not to cause +confusion regarding their origin. + + +GPLv3 Section 7e: Declining Grant of Trademark Rights + +This License does not grant any rights or permission under trademark law +to use the name "Morphe" or any of the Program's trade names, trademarks, +service marks, or logos. diff --git a/README.md b/README.md index 96212898..f731aa7a 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ You can find the documentation of Morphe CLI [here](/docs). Morphe Patches are licensed under the [GNU General Public License v3.0](LICENSE), with additional conditions under GPLv3 Section 7: -- **Name Restriction (7c):** The name **"Morphe"** may not be used for derivative works. - Derivatives must adopt a distinct identity unrelated to "Morphe". +- **Attribution (7b):** Any use of this code, including derivative works, must preserve all original notices and disclaimers. +- **Name & Branding Restrictions (7c & 7e):** Derivative works must use their own distinct branding. The **"Morphe"** name, logos, and trademarks may not be used for the branding or title of derivative works (e.g., names like *"Morphe Plus"*, *"Morphe Expanded"*, or *"Morphe UserXYZ"* are strictly prohibited). See the [LICENSE](LICENSE) file for the full GPLv3 terms and the [NOTICE](NOTICE) file for full conditions of GPLv3 Section 7 diff --git a/build.gradle.kts b/build.gradle.kts index d9bc4ea6..82a9b6c9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { api(libs.morphe.patcher) implementation(libs.arsclib) implementation(libs.morphe.library) + implementation(libs.jadb) implementation(libs.picocli) // -- Compose Desktop --------------------------------------------------- diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 74ef0d4e..fa3188b1 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -12,6 +12,6 @@ dependencies { // gets these classes on its buildscript classpath at runtime. The root // project must then apply the shadow plugin without a version to avoid // the "plugin is already on the classpath" conflict. - implementation("com.gradleup.shadow:shadow-gradle-plugin:9.4.1") + implementation("com.gradleup.shadow:shadow-gradle-plugin:9.4.3") implementation("org.jetbrains:annotations:26.1.0") } diff --git a/gradle.properties b/gradle.properties index 2893a41a..6f933c77 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,10 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.9.1 +version = 1.10.0-dev.10 +# Compose Desktop (5 OS targets) + Kotlin/Compose codegen need a bit more heap than +# the Gradle/Kotlin defaults (which intermittently OOM with "GC overhead limit +# exceeded" during codegen), but not so much it swaps alongside the IDE. 2g each +# (~4g total) fits comfortably on a 16GB machine. +org.gradle.jvmargs = -Xmx2g +kotlin.daemon.jvmargs = -Xmx2g diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e99be645..2c0522b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,32 +1,33 @@ [versions] # Core -junit = "6.0.3" +junit = "6.1.1" kotlin = "2.3.21" # CLI picocli = "4.7.7" arsclib = "a28c6fb2a7" -morphe-patcher = "1.5.2-dev.2" # TODO: Change to stable release +morphe-patcher = "1.6.0-dev.2" morphe-library = "1.3.0" +jadb = "1.2.3" # Compose Desktop -compose = "1.10.3" +compose = "1.11.1" # Networking -ktor = "3.5.0" +ktor = "3.5.1" # DI -koin-bom = "4.2.1" +koin-bom = "4.2.2" # Navigation -voyager = "1.1.0-beta03" +voyager = "2.2.21-1.10.3" # Async / Serialization -coroutines = "1.10.2" +coroutines = "1.11.0" kotlinx-serialization = "1.11.0" # JNA (Windows DWM title bar tinting) -jna = "5.18.1" +jna = "5.19.1" # Testing mockk = "1.14.11" @@ -35,7 +36,7 @@ mockk = "1.14.11" slf4j = "2.0.18" # Libraries -about-libraries = "14.1.0" +about-libraries = "15.0.3" [libraries] # Morphe Core @@ -44,6 +45,7 @@ junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref picocli = { module = "info.picocli:picocli", version.ref = "picocli" } morphe-patcher = { module = "app.morphe:morphe-patcher", version.ref = "morphe-patcher" } morphe-library = { module = "app.morphe:morphe-library-jvm", version.ref = "morphe-library" } +jadb = { module = "app.morphe:jadb", version.ref = "jadb" } # Ktor Client ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4b271a64..dbe66e1d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139f..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index 24c62d56..8508ef68 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,82 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute Gradle -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/package-lock.json b/package-lock.json index f5a43ada..41277c58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "devDependencies": { - "@cleyrop-org/semantic-release-backmerge": "^5.2.4", + "@cleyrop-org/semantic-release-backmerge": "^6.1.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "gradle-semantic-release-plugin": "^1.10.3", @@ -45,9 +45,9 @@ } }, "node_modules/@actions/http-client/node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "dev": true, "license": "MIT", "engines": { @@ -62,13 +62,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -77,9 +77,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -87,18 +87,215 @@ } }, "node_modules/@cleyrop-org/semantic-release-backmerge": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@cleyrop-org/semantic-release-backmerge/-/semantic-release-backmerge-5.2.4.tgz", - "integrity": "sha512-Bp+jjc2WSfvucaUNFNx+dj97bYKpUEv26VWmr0azNqp1SkLiT5cYAnwCNEw7BRO2aYIPXZa58lfiHdKKwtfk1Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@cleyrop-org/semantic-release-backmerge/-/semantic-release-backmerge-6.1.0.tgz", + "integrity": "sha512-fbUFDC/cMZUYZ/ywOZXLpI3s9kcRy+eq8z3AUNvoNXmwg6vlzFFjeIr7ZT+7ZXQs1VXDVd8Qk8frTj6RxCu1eA==", "dev": true, "license": "MIT", "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.1.0", + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", "debug": "^4.4.3", - "execa": "^5.1.1", - "lodash": "^4.17.21", - "semantic-release": "^25.0.0" + "execa": "^9.6.1", + "lodash": "^4.18.1" + }, + "engines": { + "node": "^22.14.0 || >=24.10.0" + }, + "peerDependencies": { + "semantic-release": ">=24" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/@semantic-release/error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@cleyrop-org/semantic-release-backmerge/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@colors/colors": { @@ -229,16 +426,16 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", - "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.10.tgz", + "integrity": "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==", "dev": true, "license": "MIT", "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", + "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" }, @@ -300,9 +497,9 @@ "license": "ISC" }, "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", "dev": true, "license": "MIT", "dependencies": { @@ -1142,9 +1339,9 @@ } }, "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, "license": "MIT", "dependencies": { @@ -1284,6 +1481,20 @@ "proto-list": "~1.2.1" } }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/conventional-changelog-angular": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", @@ -1365,9 +1576,9 @@ "license": "MIT" }, "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { @@ -1751,23 +1962,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -2028,28 +2222,30 @@ } }, "node_modules/http-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", - "integrity": "sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "9.0.0", - "debug": "^4.3.4" + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { "node": ">= 20" } }, "node_modules/https-proxy-agent": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", - "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "dev": true, "license": "MIT", "dependencies": { "agent-base": "9.0.0", - "debug": "^4.3.4" + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { "node": ">= 20" @@ -2279,10 +2475,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2426,9 +2632,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2636,9 +2842,9 @@ } }, "node_modules/normalize-url": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", - "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", "dev": true, "license": "MIT", "engines": { @@ -2649,9 +2855,9 @@ } }, "node_modules/npm": { - "version": "11.14.1", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.14.1.tgz", - "integrity": "sha512-aopNZ0eEl6LbxoFcrXLmTEPzNBNxfiQnVgR9RmJBqzm+5h5pFoOmRljpRJbsXxocBeSl7GLcx3MoDf2UlEOjZw==", + "version": "11.17.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.17.0.tgz", + "integrity": "sha512-PurxiZexEHDTE4SSaLI3ZrnbAGiZfeyUcQcxcP5D+hfytNAze/D1IzDuInTn9XVLIbAQUnQuSPXJx02LHjLvQw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -2730,8 +2936,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.5.0", - "@npmcli/config": "^10.9.0", + "@npmcli/arborist": "^9.8.0", + "@npmcli/config": "^10.11.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -2749,27 +2955,27 @@ "fs-minipass": "^3.0.3", "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "hosted-git-info": "^9.0.2", + "hosted-git-info": "^9.0.3", "ini": "^6.0.0", "init-package-json": "^8.2.5", "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.7", - "libnpmexec": "^10.2.7", - "libnpmfund": "^7.0.21", + "libnpmdiff": "^8.1.10", + "libnpmexec": "^10.3.0", + "libnpmfund": "^7.0.24", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.7", - "libnpmpublish": "^11.1.3", + "libnpmpack": "^9.1.10", + "libnpmpublish": "^11.2.0", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.3", - "make-fetch-happen": "^15.0.5", + "libnpmversion": "^8.0.4", + "make-fetch-happen": "^15.0.6", "minimatch": "^10.2.5", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^12.3.0", + "node-gyp": "^12.4.0", "nopt": "^9.0.0", "npm-audit-report": "^7.0.0", "npm-install-checks": "^8.0.0", @@ -2779,16 +2985,16 @@ "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", - "pacote": "^21.5.0", + "pacote": "^21.5.1", "parse-conflict-json": "^5.0.1", "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.7.4", + "semver": "^7.8.4", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.13", + "tar": "^7.5.16", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -2844,7 +3050,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { - "version": "4.0.0", + "version": "4.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -2860,7 +3066,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.5.0", + "version": "9.8.0", "dev": true, "inBundle": true, "license": "ISC", @@ -2908,7 +3114,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.9.0", + "version": "10.11.0", "dev": true, "inBundle": true, "license": "ISC", @@ -3102,7 +3308,7 @@ } }, "node_modules/npm/node_modules/@sigstore/core": { - "version": "3.2.0", + "version": "3.2.1", "dev": true, "inBundle": true, "license": "Apache-2.0", @@ -3150,13 +3356,13 @@ } }, "node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.1.0", + "version": "3.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -3225,7 +3431,7 @@ } }, "node_modules/npm/node_modules/bin-links": { - "version": "6.0.0", + "version": "6.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -3253,7 +3459,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.5", + "version": "5.0.6", "dev": true, "inBundle": true, "license": "MIT", @@ -3446,7 +3652,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "9.0.2", + "version": "9.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -3545,7 +3751,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.1.1", + "version": "10.2.0", "dev": true, "inBundle": true, "license": "MIT", @@ -3627,12 +3833,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.7", + "version": "8.1.10", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.5.0", + "@npmcli/arborist": "^9.8.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -3646,13 +3852,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.7", + "version": "10.3.0", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.5.0", + "@npmcli/arborist": "^9.8.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -3669,12 +3875,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.21", + "version": "7.0.24", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.5.0" + "@npmcli/arborist": "^9.8.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -3694,12 +3900,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.7", + "version": "9.1.10", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.5.0", + "@npmcli/arborist": "^9.8.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -3709,7 +3915,7 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.3", + "version": "11.2.0", "dev": true, "inBundle": true, "license": "ISC", @@ -3753,7 +3959,7 @@ } }, "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.3", + "version": "8.0.4", "dev": true, "inBundle": true, "license": "ISC", @@ -3769,7 +3975,7 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "11.3.5", + "version": "11.5.1", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -3778,7 +3984,7 @@ } }, "node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.5", + "version": "15.0.6", "dev": true, "inBundle": true, "license": "ISC", @@ -3944,7 +4150,7 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "12.3.0", + "version": "12.4.0", "dev": true, "inBundle": true, "license": "MIT", @@ -4121,7 +4327,7 @@ } }, "node_modules/npm/node_modules/pacote": { - "version": "21.5.0", + "version": "21.5.1", "dev": true, "inBundle": true, "license": "ISC", @@ -4182,7 +4388,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.1", + "version": "7.1.4", "dev": true, "inBundle": true, "license": "MIT", @@ -4279,7 +4485,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.7.4", + "version": "7.8.4", "dev": true, "inBundle": true, "license": "ISC", @@ -4303,17 +4509,17 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "4.1.0", + "version": "4.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -4330,7 +4536,7 @@ } }, "node_modules/npm/node_modules/socks": { - "version": "2.8.8", + "version": "2.8.9", "dev": true, "inBundle": true, "license": "MIT", @@ -4404,7 +4610,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.13", + "version": "7.5.16", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -4432,7 +4638,7 @@ "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.16", + "version": "0.2.17", "dev": true, "inBundle": true, "license": "MIT", @@ -4500,7 +4706,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.25.0", + "version": "6.26.0", "dev": true, "inBundle": true, "license": "MIT", @@ -4905,6 +5111,24 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -5047,9 +5271,9 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", - "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", + "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", "dev": true, "license": "MIT", "dependencies": { @@ -5295,9 +5519,9 @@ } }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5788,9 +6012,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5872,9 +6096,9 @@ } }, "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -5902,9 +6126,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e722f5f5..0309e7be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "devDependencies": { - "@cleyrop-org/semantic-release-backmerge": "^5.2.4", + "@cleyrop-org/semantic-release-backmerge": "^6.1.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "gradle-semantic-release-plugin": "^1.10.3", diff --git a/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt b/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt index 80cf7814..65a8ddce 100644 --- a/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt +++ b/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt @@ -9,6 +9,7 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json /** * Lazy initialized HttpClient for CLI commands. One client per process is fine for short-lived @@ -19,7 +20,9 @@ import io.ktor.serialization.kotlinx.json.json object CliHttpClient { val instance: HttpClient by lazy { HttpClient(CIO) { - install(ContentNegotiation) { json() } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } } } } diff --git a/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt b/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt index 5f240e5e..76df390b 100644 --- a/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt +++ b/src/main/kotlin/app/morphe/cli/command/CommandUtils.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import picocli.CommandLine diff --git a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt index 523389c4..7fa88ce5 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -1,10 +1,14 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + 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.engine.CompatibleVersionsMap +import app.morphe.engine.VersionMap +import app.morphe.engine.mostCommonCompatibleVersions import app.morphe.patcher.patch.loadPatchesFromJar -import app.morphe.patcher.patch.mostCommonCompatibleVersions import picocli.CommandLine import picocli.CommandLine.Command import picocli.CommandLine.Help.Visibility.ALWAYS @@ -57,10 +61,11 @@ internal class ListCompatibleVersions : Runnable { private var countUnusedPatches: Boolean = false @Option( - names = ["-t", "--temporary-files-path"], - description = ["Path to store temporary files."], + names = ["-x", "--include-experimental"], + description = ["Include experimental app versions in the output."], + showDefaultValue = ALWAYS, ) - private var temporaryFilesPath: File? = null + private var includeExperimental: Boolean = false @Spec private lateinit var spec: CommandSpec @@ -76,7 +81,7 @@ internal class ListCompatibleVersions : Runnable { } } - fun buildString(entry: Map.Entry) = + fun buildString(entry: Map.Entry) = buildString { val (name, versions) = entry appendLine("Package name: $name") @@ -84,13 +89,10 @@ internal class ListCompatibleVersions : Runnable { appendLine(versions.buildVersionsString().prependIndent("\t")) } - val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - try { patchesFiles = PatchFileResolver.resolve( patchesFiles, prerelease, - temporaryFilesPath, CliHttpClient.instance ) } catch (e: IllegalArgumentException) { @@ -105,6 +107,7 @@ internal class ListCompatibleVersions : Runnable { patches.mostCommonCompatibleVersions( packageNames, countUnusedPatches, + includeExperimental, ).entries.joinToString("\n", transform = ::buildString).let(logger::info) } } diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index bf2a3156..7eeca9a9 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -8,8 +8,8 @@ package app.morphe.cli.command -import app.morphe.engine.MorpheData -import app.morphe.patcher.patch.Package +import app.morphe.engine.compatibleVersionsForDisplay +import app.morphe.engine.isCompatibleWith import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar import picocli.CommandLine @@ -62,12 +62,6 @@ internal object ListPatchesCommand : Runnable { ) private var withDescriptions: Boolean = true - @Option( - names = ["-t", "--temporary-files-path"], - description = ["Path to store temporary files."], - ) - private var temporaryFilesPath: File? = null - @Option( names = ["-p", "--with-packages"], description = ["List the packages the patches are compatible with."], @@ -109,24 +103,17 @@ internal object ListPatchesCommand : Runnable { ) private var packageName: String? = null + @Option( + names = ["-x", "--include-experimental"], + description = ["Include experimental app versions in the output."], + showDefaultValue = ALWAYS, + ) + private var includeExperimental: Boolean = false + @Spec private lateinit var spec: CommandSpec override fun run() { - fun Package.buildString(): String { - val (name, versions) = this - - return buildString { - if (withVersions && versions != null) { - appendLine("Package name: $name") - appendLine("Compatible versions:") - append(versions.joinToString("\n") { version -> version }.prependIndent("\t")) - } else { - append("Package name: $name") - } - } - } - fun PatchOption<*>.buildString() = buildString { appendLine("Title: $title") @@ -165,30 +152,41 @@ internal object ListPatchesCommand : Runnable { ) } - if (withPackages && patch.compatiblePackages != null) { - appendLine("\nCompatible packages:") - append( - patch.compatiblePackages!!.joinToString("\n") { - it.buildString() - }.prependIndent("\t"), - ) + if (withPackages) { + val packages = patch.compatibleVersionsForDisplay(includeExperimental) + if (packages.isNotEmpty()) { + appendLine("\nCompatible packages:") + append( + packages.joinToString("\n") { (name, versions) -> + buildString { + val displayName = name ?: "(universal)" + if (withVersions && versions.isNotEmpty()) { + appendLine("Package name: $displayName") + appendLine("Compatible versions:") + append(versions.joinToString("\n").prependIndent("\t")) + } else { + append("Package name: $displayName") + } + } + }.prependIndent("\t"), + ) + } } } } fun Patch<*>.filterCompatiblePackages(name: String) = - compatiblePackages?.any { (compatiblePackageName, _) -> - compatiblePackageName == name - } ?: withUniversalPatches - + isCompatibleWith( + packageName = name, + includeExperimental = includeExperimental, + includeUniversalPatches = withUniversalPatches, + ) - val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir try { patchesFiles = PatchFileResolver.resolve( patchesFiles, prerelease, - temporaryFilesPath, CliHttpClient.instance ) } catch (e: IllegalArgumentException) { diff --git a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt index 979c709c..b90bc369 100644 --- a/src/main/kotlin/app/morphe/cli/command/MainCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/MainCommand.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import app.morphe.cli.command.utility.UtilityCommand diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index 3229d55c..1e027738 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -1,7 +1,11 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + 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 @@ -58,12 +62,6 @@ internal object OptionsCommand : Callable { ) private var prerelease: Boolean = false - @Option( - names = ["-t", "--temporary-files-path"], - description = ["Path to store temporary files."], - ) - private var temporaryFilesPath: File? = null - @Option( names = ["-f", "--filter-package-name"], description = ["Filter patches by compatible package name."], @@ -73,15 +71,12 @@ internal object OptionsCommand : Callable { private val json = Json { prettyPrint = true } override fun call(): Int { - val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - try { // Since we could have many URLs, we resolve each of them separately patchesFiles = patchesFiles.map { file -> val resolved = PatchFileResolver.resolve( setOf(file), prerelease, - temporaryFilesPath, CliHttpClient.instance ) resolved.single() diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 4134eb3a..c6a4fab7 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -12,6 +12,7 @@ import app.morphe.cli.command.model.* import app.morphe.engine.MorpheData import app.morphe.engine.PatchEngine import app.morphe.engine.isWindows +import app.morphe.engine.supportedVersionsFor import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME @@ -212,13 +213,13 @@ internal object PatchCommand : Callable { description = ["Alias of the private key and certificate pair keystore entry."], showDefaultValue = ALWAYS, ) - private var keyStoreEntryAlias = PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS + private var keyStoreEntryAlias = DEFAULT_KEYSTORE_ALIAS @CommandLine.Option( names = ["--keystore-entry-password"], description = ["Password of the keystore entry."], ) - private var keyStoreEntryPassword = PatchEngine.Config.DEFAULT_KEYSTORE_PASSWORD + private var keyStoreEntryPassword = DEFAULT_KEYSTORE_PASSWORD @CommandLine.Option( names = ["--signer"], @@ -233,15 +234,14 @@ internal object PatchCommand : Callable { ) private var temporaryFilesPath: File? = null - private var aaptBinaryPath: File? = null - @CommandLine.Option( - names = ["--purge"], - description = ["Delete THIS run's scratch files after patching. " + - "Does not affect cached patches, other sessions, or config."], + names = ["--disable-purge"], + description = ["Keep THIS run's scratch files instead of deleting them after patching. " + + "By default the scratch files are purged once patching finishes; this keeps them " + + "(e.g. for debugging a failed patch). Does not affect cached patches, other sessions, or config."], showDefaultValue = ALWAYS, ) - private var purge: Boolean = false + private var disablePurge: Boolean = false @CommandLine.Parameters( description = ["APK file to patch."], @@ -267,28 +267,6 @@ internal object PatchCommand : Callable { ) private var prerelease: Boolean = false - @CommandLine.Option( - names = ["--custom-aapt2-binary"], - description = ["apktool is deprecated. This parameter has no effect and will be removed in a future release."], - ) - @Suppress("unused") - private fun setAaptBinaryPath(aaptBinaryPath: File) { - if (!aaptBinaryPath.exists()) { - throw CommandLine.ParameterException( - spec.commandLine(), - "AAPT binary ${aaptBinaryPath.name} does not exist", - ) - } - this.aaptBinaryPath = aaptBinaryPath - } - - @CommandLine.Option( - names = ["--force-apktool"], - description = ["apktool is deprecated. This parameter has no effect and will be removed in a future release."], - showDefaultValue = ALWAYS, - ) - private var forceApktool: Boolean = false - @CommandLine.Option( names = ["--unsigned"], description = ["Disable signing of the final apk."], @@ -336,7 +314,7 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["--verify-with-sdk"], - description = ["Verify the patched DEX and APK files using the provided Android SDK. If not specified, the patched files will not be verified."], + description = ["Verify the patched DEX and APK files using the provided Android SDK. If not specified, the patched files will not be verified. Verification may throw false positives and is for patch developer use only."], fallbackValue = "", arity = "0..1", ) @@ -432,7 +410,38 @@ internal object PatchCommand : Callable { val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - val keystoreFilePath = keyStoreFilePath ?: MorpheData.defaultKeystoreFile + // Resolve and possibly convert the keystore. The patcher only loads BKS, + // but users frequently pass PKCS12 (Android Studio default) or JKS (older + // projects, URV exports). KeystoreImporter sniffs by magic bytes (not + // extension — URV ships the same bytes under multiple suffixes), short- + // circuits when already BKS so existing setups are zero-risk, and writes + // converted bytes to a separate file so the user's source is never mutated. + val keystoreFilePath = run { + val source = keyStoreFilePath ?: return@run MorpheData.defaultKeystoreFile + if (!source.exists()) return@run source // patcher will produce a clearer error + val importResult = app.morphe.engine.util.KeystoreImporter.ensureBks( + source = source, + convertedOutput = MorpheData.importedKeystoreFile, + alias = keyStoreEntryAlias, + password = keyStoreEntryPassword, + ) + when (importResult) { + is app.morphe.engine.util.KeystoreImporter.Result.AlreadyBks -> importResult.file + is app.morphe.engine.util.KeystoreImporter.Result.Converted -> { + logger.info( + "Converted ${importResult.sourceFormat.displayName} keystore → BKS: ${importResult.file.absolutePath}" + ) + importResult.file + } + is app.morphe.engine.util.KeystoreImporter.Result.Failed -> { + logger.severe("Keystore conversion failed: ${importResult.reason}") + importResult.cause?.let { logger.severe(it.stackTraceToString()) } + throw IllegalArgumentException( + "Could not use keystore '${source.absolutePath}': ${importResult.reason}" + ) + } + } + } val installer = if (deviceSerial != null) { val deviceSerial = deviceSerial!!.ifEmpty { null } @@ -479,7 +488,6 @@ internal object PatchCommand : Callable { val resolved = PatchFileResolver.resolve( setOf(bundle.patchesFile), prerelease, - temporaryFilesPath, CliHttpClient.instance ) bundle.patchesFile = resolved.single() @@ -492,7 +500,7 @@ internal object PatchCommand : Callable { } // Per-session scratch dir. Hoisted out of the patching `try` block so - // the `finally` block can reference it for --purge scope (Phase 6). + // the `finally` block can reference it for the auto-purge (unless --disable-purge). // Naming matches the GUI's FileUtils.createPatchingTempDir() so the // tmp/ folder shows consistent siblings across CLI + GUI sessions. val patcherTemporaryFilesPath = @@ -585,11 +593,11 @@ internal object PatchCommand : Callable { // endregion // (patcherTemporaryFilesPath is declared above the outer try - // block so it's visible to --purge in the finally clause.) + // block so it's visible to the auto-purge in the finally clause.) // We need to check for apkm (like reddit), xapk and apks formats here - val inputApk = if (apk.extension.lowercase() in setOf("apkm", "xapk", "apks")) { + val inputApk = if (app.morphe.engine.util.BundleFormats.isBundle(apk)) { logger.info("Merging split APK bundle") @@ -600,7 +608,7 @@ internal object PatchCommand : Callable { ApkMerger(logger.toMorpheLogger()).merge( inputFile = apk, outputFile = outputApk, - cleanMetaInf = true + cleanMetaInf = false ) mergedApkToCleanup = outputApk @@ -614,15 +622,9 @@ internal object PatchCommand : Callable { PatcherConfig( inputApk, patcherTemporaryFilesPath, - aaptBinaryPath?.path, - patcherTemporaryFilesPath.absolutePath, - useArsclib = if (aaptBinaryPath != null) { false } else { !forceApktool }, + useArsclib = true, keepArchitectures = keepArchitectures, - /* - TODO: Remove Windows override once the patcher ships its proper fix - (reflection-based MappedByteBuffer release + copy-instead-of-rename for output DEX files). - */ - useBytecodeMode = if (isWindows()) { BytecodeMode.FULL } else { bytecodeMode }, + useBytecodeMode = bytecodeMode, verifier = verifier ), ).use { patcher -> @@ -642,8 +644,13 @@ internal object PatchCommand : Callable { val compatiblePatchNames = bundlePatches .filter { patch -> - patch.compatiblePackages == null || - patch.compatiblePackages!!.any { (name, _) -> name == packageName } + val compat = patch.compatibility + if (!compat.isNullOrEmpty()) { + compat.any { it.packageName == packageName || it.packageName == null } + } else { + @Suppress("DEPRECATION") + patch.compatiblePackages == null || patch.compatiblePackages!!.any { (name, _) -> name == packageName } + } } .mapNotNull { it.name?.lowercase() } .toSet() @@ -670,11 +677,15 @@ internal object PatchCommand : Callable { // Compare against the live patch in this bundle (not the snapshot) // so multi-app patches with the same name aren't merged together. - val actualPatch = bundlePatches.find { - it.name.equals(patchName, ignoreCase = true) && - (it.compatiblePackages == null || it.compatiblePackages!!.any { - (name, _) -> name == packageName - }) + val actualPatch = bundlePatches.find { patch -> + if (!patch.name.equals(patchName, ignoreCase = true)) return@find false + val compat = patch.compatibility + if (!compat.isNullOrEmpty()) { + compat.any { it.packageName == packageName || it.packageName == null } + } else { + @Suppress("DEPRECATION") + patch.compatiblePackages == null || patch.compatiblePackages!!.any { (name, _) -> name == packageName } + } } val actualOptionKeys = actualPatch?.options?.keys ?: emptySet() @@ -823,7 +834,7 @@ internal object PatchCommand : Callable { // region Save. - inputApk.copyTo(temporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { + inputApk.copyTo(patcherTemporaryFilesPath.resolve(inputApk.name), overwrite = true).apply { patchingResult.addStepResult( PatchingStep.REBUILDING, { @@ -935,7 +946,7 @@ internal object PatchCommand : Callable { } } - if (purge) { + if (!disablePurge) { // Scope: only THIS session's tmp subfolder. Cached patches, // logs, config, and other in-flight sessions (CLI or GUI) are // never touched. @@ -988,34 +999,31 @@ internal object PatchCommand : Callable { val patchNameLower = patchName.lowercase() // Check package compatibility first to avoid duplicate logs for multi-app patches. - patch.compatiblePackages?.let { packages -> - packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) -> - if (versions?.isEmpty() == true) { - return@patchLoop logger.warning( - "Skipping \"$patchName\": incompatible with $packageName" - ) - } - - val matchesVersion = - force || versions?.let { it.any { version -> version == packageVersion } } ?: true - + val supportedVersions = patch.supportedVersionsFor(packageName) + when { + supportedVersions != null && supportedVersions.isEmpty() -> { + return@patchLoop logger.fine( + "Skipping \"$patchName\": incompatible with $packageName " + + "(only compatible with " + + (patch.compatibility + ?.mapNotNull { it.packageName } + ?.joinToString(", ") + ?: @Suppress("DEPRECATION") patch.compatiblePackages + ?.joinToString(", ") { (name, _) -> name } + ?: "") + ")" + ) + } + supportedVersions != null -> { + val matchesVersion = force || packageVersion in supportedVersions if (!matchesVersion) { - val compatibilityHint = packages.joinToString("; ") { (pkg, vers) -> - pkg + " " + (vers ?: emptySet()).joinToString(", ") - } + val hint = supportedVersions.joinToString(", ") return@patchLoop logger.warning( "Skipping \"$patchName\": incompatible with $packageName $packageVersion " + - "(supported: $compatibilityHint)" + "(supported: $packageName $hint)" ) } - } ?: return@patchLoop logger.fine( - "Skipping \"$patchName\": incompatible with $packageName " + - "(only compatible with " + - packages.joinToString(", ") { (name, _) -> name } + ")" - ) - - return@let - } ?: logger.fine("\"$patchName\" has no package constraints") + } + } // CLI flags take precedence over JSON, JSON takes precedence over defaults. // Log strings match the GUI engine's "Skipping disabled: …" format so @@ -1044,7 +1052,7 @@ internal object PatchCommand : Callable { val isJsonEnabled = patchNameLower in jsonEnabledPatches - val isEnabled = !exclusive && patch.use + val isEnabled = !exclusive && patch.default if (!(isEnabled || isCliEnabled || isJsonEnabled)) { // Default-disabled patches (the patch ships with use=false and diff --git a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt index ece55d2e..5af03a63 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt @@ -1,5 +1,11 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command +import app.morphe.engine.patches.PatchCache import app.morphe.engine.patches.RemotePatchSourceFactory import app.morphe.engine.patches.findPatchAsset import io.ktor.client.HttpClient @@ -16,12 +22,12 @@ object PatchFileResolver { * Returns a new Set with URLs replaced by downloaded/cached .mpp files. * * Provider detection (GitHub vs GitLab) + URL parsing + API talk lives in the engine. - * This function only owns the CLI's disk cache layout and the "which release do we pick" decision. + * This function owns the "which release do we pick" decision. The on-disk cache + * layout is shared with the GUI via [PatchCache] (one cache for both surfaces). */ fun resolve( patchFiles: Set, prerelease: Boolean, - cacheDir: File, httpClient: HttpClient ): Set { val urlEntry = patchFiles.firstOrNull { @@ -67,37 +73,21 @@ object PatchFileResolver { val asset = targetRelease.findPatchAsset() ?: throw IllegalArgumentException("No .mpp file found in release ${targetRelease.tagName}") - // Disk-cache check (same layout as before: {cacheDir}/download/{owner}-{repo}/). - val versionNumber = targetRelease.tagName.removePrefix("v") - val repoCacheDir = - cacheDir.resolve("download").resolve(parsed.repoPath.replace("/", "-")) - - val cachedFile = repoCacheDir.listFiles()?.find { - it.name.endsWith(".mpp") && it.name.contains(versionNumber) - } - - val resolvedFile = if (cachedFile != null) { - val rel = cachedFile.relativeTo(cacheDir.parentFile).path - logger.info("Using cached patch file at $rel") + // Shared on-disk cache (via PatchCache): the same directory and tag-prefixed filename + // the GUI uses, so a .mpp downloaded by either side is reused by the other. + // Layout: morphe-data/patches/-/__.mpp + val repoCacheDir = PatchCache.sourceDir(parsed.repoPath) + val targetFile = File(repoCacheDir, PatchCache.cachedFileName(targetRelease, asset)) - cachedFile + val resolvedFile = if (targetFile.exists() && targetFile.length() > 0L) { + logger.info("Using cached patch file at ${targetFile.absolutePath}") + targetFile } else { - // Different version cached -> wipe it before downloading (matches the existing behavior) - repoCacheDir.listFiles() - ?.filter { it.name.endsWith(".mpp") } - ?.forEach{ it.delete() } - repoCacheDir.mkdirs() - - val targetFile = File(repoCacheDir, asset.name) - logger.info("Downloading patches from ${parsed.repoPath} $versionNumber...") - + logger.info("Downloading patches from ${parsed.repoPath} ${targetRelease.tagName}...") source.downloadAsset(asset, targetFile).getOrThrow() - - val rel = targetFile.relativeTo(cacheDir.parentFile).path - logger.info("Patches mpp saved to $rel. This file will be used on your next run as long as it is not deleted!") - + logger.info("Patches mpp saved to ${targetFile.absolutePath}. This file will be used on your next run as long as it is not deleted!") targetFile - } + } patchFiles - urlEntry + resolvedFile } catch (e: Exception) { throw IllegalArgumentException("Failed to download patches from URL: ${e.message}") diff --git a/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt b/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt index 3b80ce44..a26fbbbc 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/FailedPatch.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import kotlinx.serialization.ExperimentalSerializationApi diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt index 2d65757f..1dccb32a 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchOptionsFile.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import app.morphe.patcher.patch.Patch diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt index 2ffaaf24..c56862be 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingResult.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import kotlinx.serialization.EncodeDefault diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt index 8f521da6..ed8873cf 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingStep.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model enum class PatchingStep { diff --git a/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt b/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt index 419ba2f9..27c2ba9b 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/PatchingStepResult.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import kotlinx.serialization.ExperimentalSerializationApi diff --git a/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt b/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt index fb484cdc..57d4594c 100644 --- a/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt +++ b/src/main/kotlin/app/morphe/cli/command/model/SerializablePatch.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.model import app.morphe.patcher.patch.Patch diff --git a/src/main/kotlin/app/morphe/cli/command/utility/ClearCacheCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/ClearCacheCommand.kt new file mode 100644 index 00000000..67c0fdf5 --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/utility/ClearCacheCommand.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.cli.command.utility + +import app.morphe.engine.CacheManager +import picocli.CommandLine.Command +import picocli.CommandLine.Option +import java.util.concurrent.Callable +import java.util.logging.Logger + +@Command( + name = "clear-cache", + description = ["Delete cached patch files, logs, and temporary files."], +) +internal object ClearCacheCommand : Callable { + private val logger = Logger.getLogger(this::class.java.name) + + private const val EXIT_CODE_SUCCESS = 0 + private const val EXIT_CODE_ERROR = 1 + + @Option( + names = ["--info"], + description = ["Show a per-category breakdown of what was cleared and how much space was freed."], + ) + private var info: Boolean = false + + override fun call(): Int { + val result = CacheManager.clearCaches() + + if (info) { + logger.info( + buildString { + appendLine("Cache cleared:") + result.perDirectory.forEach { dir -> + appendLine( + " ${dir.label.padEnd(8)} ${humanReadable(dir.bytesFreed).padStart(9)}" + + " (${dir.filesDeleted} file${if (dir.filesDeleted == 1) "" else "s"})" + ) + } + append(" ${"Total".padEnd(8)} ${humanReadable(result.bytesFreed).padStart(9)}") + } + ) + } else { + logger.info("Cache cleared.") + } + + // Deletion failures always shown, even without --info. + return if (result.success) { + EXIT_CODE_SUCCESS + } else { + logger.warning("${result.failedFiles} file(s) could not be deleted (may be locked)") + EXIT_CODE_ERROR + } + } + + private fun humanReadable(bytes: Long): String = when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + } +} diff --git a/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt index 412e8498..721d59e2 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt @@ -1,10 +1,18 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.utility +import app.morphe.engine.util.ApkManifestReader +import app.morphe.engine.util.AppLinkCommands import app.morphe.library.installation.installer.* import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import picocli.CommandLine.* +import se.vidstige.jadb.JadbConnection import java.io.File import java.util.logging.Logger @@ -34,6 +42,18 @@ internal object InstallCommand : Runnable { ) private var packageName: String? = null + @Option( + names = ["--route-links"], + description = ["After installing, route this app's supported web links to it (\"open with\")."], + ) + private var routeLinks: Boolean = false + + @Option( + names = ["--disable-stock"], + description = ["With --route-links: also stop this stock package from handling the links."], + ) + private var stockPackage: String? = null + override fun run() { suspend fun install(deviceSerial: String? = null) { val result = try { @@ -44,20 +64,51 @@ internal object InstallCommand : Runnable { }.install(Installer.Apk(apk, packageName)) } catch (e: Exception) { logger.severe(e.toString()) + return } when (result) { - RootInstallerResult.FAILURE -> + RootInstallerResult.FAILURE -> { logger.severe("Failed to mount the APK file") - is AdbInstallerResult.Failure -> + return + } + is AdbInstallerResult.Failure -> { logger.severe(result.exception.toString()) - else -> - logger.info("Installed the APK file") + return + } + else -> logger.info("Installed the APK file") } + + if (routeLinks) routeLinks(deviceSerial) } runBlocking { deviceSerials?.map { async { install(it) } }?.awaitAll() ?: install() } } + + private fun routeLinks(deviceSerial: String?) { + val patched = ApkManifestReader.read(apk)?.packageName ?: run { + logger.severe("Could not read package name from APK; skipping link routing") + return + } + val commands = AppLinkCommands.enablePatched(patched) + + (stockPackage?.let { AppLinkCommands.disableStock(it) } ?: emptyList()) + + val devices = JadbConnection().devices + val device = deviceSerial?.let { s -> devices.firstOrNull { it.serial == s } } + ?: devices.firstOrNull() + ?: run { logger.severe("No ADB device for link routing"); return } + + commands.forEach { argv -> + val cmd = argv.joinToString(" ") + val process = device.shellProcessBuilder(cmd).start() + val out = process.inputStream.bufferedReader().readText().trim() + val exit = process.waitFor() + if (exit != 0 || out.contains("Error", true) || out.contains("Failure", true)) { + logger.severe("Link command failed: $cmd -> ${out.ifBlank { "exit $exit" }}") + } + } + logger.info("Routed links to $patched") + } } diff --git a/src/main/kotlin/app/morphe/cli/command/utility/UninstallCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/UninstallCommand.kt index ffd13ad8..cd0c174a 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/UninstallCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/UninstallCommand.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.utility import app.morphe.library.installation.installer.AdbInstaller diff --git a/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt index 18b1e9e4..1194e4ae 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/UtilityCommand.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command.utility import picocli.CommandLine @@ -5,6 +10,6 @@ import picocli.CommandLine @CommandLine.Command( name = "utility", description = ["Commands for utility purposes."], - subcommands = [InstallCommand::class, UninstallCommand::class], + subcommands = [InstallCommand::class, UninstallCommand::class, ClearCacheCommand::class], ) internal object UtilityCommand diff --git a/src/main/kotlin/app/morphe/engine/CacheManager.kt b/src/main/kotlin/app/morphe/engine/CacheManager.kt new file mode 100644 index 00000000..5015d607 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/CacheManager.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine + +import java.io.File + +/** + * Clears Morphe's on-disk caches. Shared by the GUI's "Clear Cache" action and + * the CLI's `utility clear-cache` subcommand so both wipe the same set of + * directories: downloaded patches, logs, and temp scratch. + */ +object CacheManager { + + /** What was cleared from a single directory. */ + data class DirResult( + val label: String, + val bytesFreed: Long, + val filesDeleted: Int, + val failedFiles: Int, + ) + + /** Outcome of a [clearCaches] run, with a per-directory breakdown. */ + data class ClearResult( + val perDirectory: List, + ) { + val bytesFreed: Long get() = perDirectory.sumOf { it.bytesFreed } + val failedFiles: Int get() = perDirectory.sumOf { it.failedFiles } + val success: Boolean get() = failedFiles == 0 + } + + /** + * Deletes the contents of the patches cache, logs, and temp scratch + * directories (keeping the top-level directories themselves). + * + * Best-effort: a file that can't be deleted (e.g. a log still held open by + * the running process) is counted in [DirResult.failedFiles] rather than + * aborting the rest. + */ + fun clearCaches(): ClearResult = ClearResult( + listOf( + clearContents("Patches", MorpheData.patchesDir), + clearContents("Logs", MorpheData.logsDir), + clearContents("Temp", MorpheData.tmpDir), + ), + ) + + private fun clearContents(label: String, dir: File): DirResult { + var bytesFreed = 0L + var filesDeleted = 0 + var failedFiles = 0 + + dir.listFiles()?.forEach { entry -> + // Measure before deleting. + val files = entry.walkTopDown().filter { it.isFile }.toList() + val size = files.sumOf { it.length() } + if (entry.deleteRecursively()) { + bytesFreed += size + filesDeleted += files.size + } else { + failedFiles++ + } + } + return DirResult(label, bytesFreed, filesDeleted, failedFiles) + } +} diff --git a/src/main/kotlin/app/morphe/engine/MorpheData.kt b/src/main/kotlin/app/morphe/engine/MorpheData.kt index 4e6ea42c..18fae748 100644 --- a/src/main/kotlin/app/morphe/engine/MorpheData.kt +++ b/src/main/kotlin/app/morphe/engine/MorpheData.kt @@ -84,6 +84,16 @@ object MorpheData { */ val defaultKeystoreFile: File get() = File(root, "morphe.keystore") + /** + * Destination for keystores the user imports in non-BKS formats + * (PKCS12 / JKS). The original source file is left untouched; we write + * the BKS-converted bytes here and point the user's keystore config + * at this path. Distinct from [defaultKeystoreFile] so the import flow + * doesn't clobber the auto-generated default — clearing the configured + * path reverts patching to that default. + */ + val importedKeystoreFile: File get() = File(root, "imported.keystore") + /** * Reason the primary (JAR-adjacent) location was rejected. Drives the * fallback log message so a user reporting "where's my cache?" can diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index 26d64a2f..5eeb72a1 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -8,6 +8,7 @@ package app.morphe.engine +import app.morphe.engine.util.BundleFormats import app.morphe.engine.util.signWithLegacyFallback import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig @@ -55,7 +56,6 @@ object PatchEngine { val signerName: String = DEFAULT_SIGNER_NAME, val keystoreDetails: ApkUtils.KeyStoreDetails? = null, val architecturesToKeep: Set = emptySet(), - val aaptBinaryPath: File? = null, val tempDir: File? = null, val failOnError: Boolean = true, val bytecodeMode: BytecodeMode = BytecodeMode.STRIP_FAST @@ -100,14 +100,16 @@ object PatchEngine { val failedPatches = mutableListOf() try { - // 1. Handle APKM format (split APK bundle) - val actualInputApk = if (config.inputApk.extension.equals("apkm", ignoreCase = true)) { - onProgress("Converting APKM to APK...") + // 1. Handle split-APK bundles (.apkm/.xapk/.apks) — merge splits into + // a single APK. ApkMerger is format-agnostic (it just extracts the + // .apk entries from the zip), so all three formats go through here. + val actualInputApk = if (BundleFormats.isBundle(config.inputApk)) { + onProgress("Merging split APKs...") val mergedApk = File(tempDir, "${config.inputApk.nameWithoutExtension}-merged.apk") ApkMerger(logger.toMorpheLogger()).merge( inputFile = config.inputApk, outputFile = mergedApk, - cleanMetaInf = true + cleanMetaInf = false ) mergedApkToCleanup = mergedApk mergedApk @@ -125,15 +127,10 @@ object PatchEngine { val patcherConfig = PatcherConfig( actualInputApk, patcherTempDir, - config.aaptBinaryPath?.path, patcherTempDir.absolutePath, useArsclib = true, keepArchitectures = config.architecturesToKeep, - /* - TODO: Remove Windows override once the patcher ships its proper fix - (reflection-based MappedByteBuffer release + copy-instead-of-rename for output DEX files). - */ - useBytecodeMode = if (isWindows()) { BytecodeMode.FULL } else { config.bytecodeMode } + useBytecodeMode = config.bytecodeMode ) Patcher(patcherConfig).use { patcher -> @@ -317,23 +314,15 @@ object PatchEngine { val patchName = patch.name ?: return@patchLoop // Check package compatibility first to avoid duplicate logs for multi-app patches. - patch.compatiblePackages?.let { packages -> - val matchingPkg = packages.singleOrNull { (name, _) -> name == packageName } - if (matchingPkg == null) { - return@patchLoop - } - - val (_, versions) = matchingPkg - if (versions?.isEmpty() == true) { - return@patchLoop - } - - val matchesVersion = forceCompatibility || - versions?.any { it == packageVersion } ?: true - - if (!matchesVersion) { - onProgress("Skipping \"$patchName\": incompatible with $packageName $packageVersion") - return@patchLoop + val supportedVersions = patch.supportedVersionsFor(packageName) + when { + supportedVersions != null && supportedVersions.isEmpty() -> return@patchLoop + supportedVersions != null -> { + val matchesVersion = forceCompatibility || packageVersion in supportedVersions + if (!matchesVersion) { + onProgress("Skipping \"$patchName\": incompatible with $packageName $packageVersion") + return@patchLoop + } } } @@ -344,7 +333,7 @@ object PatchEngine { } val isManuallyEnabled = patchName in enabledPatches - val isEnabledByDefault = !exclusiveMode && patch.use + val isEnabledByDefault = !exclusiveMode && patch.default if (!(isEnabledByDefault || isManuallyEnabled)) { return@patchLoop diff --git a/src/main/kotlin/app/morphe/engine/PatchExtensions.kt b/src/main/kotlin/app/morphe/engine/PatchExtensions.kt new file mode 100644 index 00000000..86f6e0f3 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/PatchExtensions.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.patcher.patch.Patch + +typealias VersionMap = LinkedHashMap +typealias CompatibleVersionsMap = Map + + +@Suppress("DEPRECATION") +fun Patch<*>.versionsFor( + packageName: String?, + includeExperimental: Boolean, +): List { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + val matchingEntries = compat.filter { entry -> + packageName == null || entry.packageName == null || entry.packageName == packageName + } + return matchingEntries.flatMap { entry -> + entry.targets + .filter { target -> includeExperimental || !target.isExperimental } + .mapNotNull { it.version } + } + } + + val legacyPackages = compatiblePackages ?: return emptyList() + val matchingPkgs = if (packageName == null) { + legacyPackages + } else { + legacyPackages.filter { (name, _) -> name == packageName } + } + return matchingPkgs.flatMap { (_, versions) -> versions?.toList() ?: emptyList() } +} + +@Suppress("DEPRECATION") +fun Patch<*>.isCompatibleWith( + packageName: String, + includeExperimental: Boolean, + includeUniversalPatches: Boolean, +): Boolean { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + return compat.any { entry -> + when { + entry.packageName == null -> includeUniversalPatches + entry.packageName != packageName -> false + else -> { + entry.targets.any { target -> + includeExperimental || !target.isExperimental + } + } + } + } + } + + val legacyPackages = compatiblePackages + ?: return includeUniversalPatches + + return legacyPackages.any { (name, _) -> name == packageName } +} + +@Suppress("DEPRECATION") +fun Patch<*>.supportedVersionsFor(packageName: String): List? { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + val matching = compat.filter { it.packageName == packageName } + if (matching.isEmpty()) { + val hasUniversalEntry = compat.any { it.packageName == null } + return if (hasUniversalEntry) null else emptyList() + } + val versions = matching.flatMap { entry -> entry.targets.mapNotNull { it.version } } + return versions.ifEmpty { null } + } + + val legacyPackages = compatiblePackages ?: return null + val match = legacyPackages.singleOrNull { (name, _) -> name == packageName } + ?: return emptyList() + val legacyVersions = match.second + return if (legacyVersions.isNullOrEmpty()) null else legacyVersions.toList() +} + +@Suppress("DEPRECATION") +fun Patch<*>.compatibleVersionsForDisplay( + includeExperimental: Boolean, +): List>> { + val compat = compatibility + + if (!compat.isNullOrEmpty()) { + return compat.map { entry -> + val versions = entry.targets + .filter { includeExperimental || !it.isExperimental } + .mapNotNull { it.version } + entry.packageName to versions + } + } + + val legacyPackages = compatiblePackages ?: return emptyList() + return legacyPackages.map { (name, versions) -> + name to (versions?.toList() ?: emptyList()) + } +} + +@Suppress("DEPRECATION") +fun Iterable>.mostCommonCompatibleVersions( + packageNames: Set?, + countUnusedPatches: Boolean, + includeExperimental: Boolean = false, +): CompatibleVersionsMap { + val allPackageNames: Set = buildSet { + for (patch in this@mostCommonCompatibleVersions) { + val compat = patch.compatibility + if (!compat.isNullOrEmpty()) { + for (entry in compat) { + entry.packageName?.let { add(it) } + } + } else { + @Suppress("DEPRECATION") + patch.compatiblePackages?.forEach { (name, _) -> add(name) } + } + } + } + + val targetPackages = if (packageNames != null) { + allPackageNames.intersect(packageNames) + } else { + allPackageNames + } + + val result: MutableMap = LinkedHashMap() + + for (pkgName in targetPackages) { + val versionCount = VersionMap() + + for (patch in this) { + if (!countUnusedPatches && !patch.use) continue + + val compat = patch.compatibility + + if (!compat.isNullOrEmpty()) { + val isUniversal = compat.all { it.packageName == null } + val matchingEntries = compat.filter { entry -> + entry.packageName == null || entry.packageName == pkgName + } + + if (matchingEntries.isEmpty()) continue + + val versions = matchingEntries.flatMap { entry -> + entry.targets + .filter { includeExperimental || !it.isExperimental } + .mapNotNull { it.version } + } + + if (versions.isEmpty() && !isUniversal) continue + + if (versions.isEmpty()) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + } else { + for (version in versions) { + versionCount[version] = (versionCount[version] ?: 0) + 1 + } + } + } else { + @Suppress("DEPRECATION") + val legacyPackages = patch.compatiblePackages + if (legacyPackages == null) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + continue + } + val matching = legacyPackages.filter { (name, _) -> name == pkgName } + if (matching.isEmpty()) continue + for ((_, versions) in matching) { + if (versions.isNullOrEmpty()) { + versionCount[""] = (versionCount[""] ?: 0) + 1 + } else { + for (version in versions) { + versionCount[version] = (versionCount[version] ?: 0) + 1 + } + } + } + } + } + + if (versionCount.isNotEmpty()) { + val sorted = versionCount.entries + .sortedByDescending { it.value } + .associateTo(LinkedHashMap()) { it.key to it.value } + sorted.remove("") + result[pkgName] = sorted + } + } + + return result.entries + .sortedBy { it.key } + .associateTo(LinkedHashMap()) { it.key to it.value } +} diff --git a/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt b/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt new file mode 100644 index 00000000..d83cd55e --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/PatchedAppStore.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.engine.model.PatchedAppRecord +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.logging.Logger + +/** + * Persistent store for [PatchedAppRecord]s — the patched-app history shared by + * the CLI and GUI (see `patched-app-recall-plan.md`). + * + * Lives in the engine layer (not GUI) so **both** pipelines can record into one + * file: `morphe-data/patched-apps.json`. Reads are cached in memory; writes go + * through a [Mutex] (in-process safety) and are atomic (temp file + move) so a + * crash mid-write can't corrupt the history. + * + * The [file] is injectable for testing; production code uses the default under + * [MorpheData.root]. + */ +class PatchedAppStore( + private val file: File = File(MorpheData.root, FILE_NAME), +) { + private val logger = Logger.getLogger(PatchedAppStore::class.java.name) + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val mutex = Mutex() + private var cache: List? = null + + private val _changes = MutableSharedFlow(extraBufferCapacity = 1) + + /** + * Emits once whenever the history changes (upsert/delete). Observe this to + * refresh UI the moment a patch completes or a record is forgotten, instead + * of waiting for a screen to be re-entered. + */ + val changes: SharedFlow = _changes.asSharedFlow() + + /** All records, most-recently-patched first. */ + suspend fun getAll(): List = withContext(Dispatchers.IO) { + mutex.withLock { load() } + } + + /** The record for [packageName], or null if the app was never patched. */ + suspend fun get(packageName: String): PatchedAppRecord? = withContext(Dispatchers.IO) { + mutex.withLock { load().firstOrNull { it.packageName == packageName } } + } + + /** Insert [record], replacing any existing record for the same package. */ + suspend fun upsert(record: PatchedAppRecord): Unit = withContext(Dispatchers.IO) { + mutex.withLock { + val others = load().filterNot { it.packageName == record.packageName } + persist(listOf(record) + others) + } + _changes.tryEmit(Unit) + } + + /** Remove the record for [packageName] if present. */ + suspend fun delete(packageName: String): Unit = withContext(Dispatchers.IO) { + val changed = mutex.withLock { + val remaining = load().filterNot { it.packageName == packageName } + if (remaining.size != cache?.size) { + persist(remaining) + true + } else { + false + } + } + if (changed) _changes.tryEmit(Unit) + } + + // --- internals (call only while holding [mutex]) --- + + private fun load(): List { + cache?.let { return it } + val records = if (file.exists()) { + try { + json.decodeFromString(file.readText()).records + } catch (e: Exception) { + // Corrupt/incompatible file: don't lose the user's ability to keep + // patching — start fresh in memory and let the next write heal it. + logger.warning("Could not read patched-app history (${e.message}); starting empty") + emptyList() + } + } else { + emptyList() + } + cache = records + return records + } + + private fun persist(records: List) { + try { + file.parentFile?.mkdirs() + val content = json.encodeToString(StoreFile.serializer(), StoreFile(SCHEMA_VERSION, records)) + // Atomic write: write a temp file, then move it over the target so a + // crash never leaves a half-written history behind. + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(content) + try { + Files.move( + tmp.toPath(), file.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE, + ) + } catch (_: Exception) { + // ATOMIC_MOVE isn't supported on every filesystem — fall back. + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + cache = records + } catch (e: Exception) { + logger.warning("Failed to write patched-app history: ${e.message}") + } + } + + @Serializable + private data class StoreFile( + val version: Int = SCHEMA_VERSION, + val records: List = emptyList(), + ) + + companion object { + const val FILE_NAME = "patched-apps.json" + + /** Bump when the on-disk shape changes incompatibly; add a migration in [load]. */ + const val SCHEMA_VERSION = 1 + + /** + * Process-wide shared instance — use this everywhere in production so the + * in-memory cache is coherent (two instances in one process could race + * and drop each other's records). Tests construct their own with a + * custom [file]. + */ + val shared: PatchedAppStore by lazy { PatchedAppStore() } + } +} diff --git a/src/main/kotlin/app/morphe/engine/PlatformCheck.kt b/src/main/kotlin/app/morphe/engine/PlatformCheck.kt index cdc40888..108a56ac 100644 --- a/src/main/kotlin/app/morphe/engine/PlatformCheck.kt +++ b/src/main/kotlin/app/morphe/engine/PlatformCheck.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.engine internal fun isWindows(): Boolean { diff --git a/src/main/kotlin/app/morphe/engine/UpdateChecker.kt b/src/main/kotlin/app/morphe/engine/UpdateChecker.kt index 1b702917..fd497849 100644 --- a/src/main/kotlin/app/morphe/engine/UpdateChecker.kt +++ b/src/main/kotlin/app/morphe/engine/UpdateChecker.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.engine import java.net.HttpURLConnection diff --git a/src/main/kotlin/app/morphe/engine/model/PatchedAppRecord.kt b/src/main/kotlin/app/morphe/engine/model/PatchedAppRecord.kt new file mode 100644 index 00000000..44c6c01e --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/model/PatchedAppRecord.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.model + +import kotlinx.serialization.Serializable + +/** + * A record of one app the user has patched — the "recall" data behind the + * patched-app history (see `patched-app-recall-plan.md`). + * + * Written by both the CLI and the GUI on a successful patch (the store lives in + * the shared engine layer so the two pipelines feed one history), and read back + * to surface "you've patched this before / an update is available" UX. + * + * Keyed by [packageName] — re-patching the same app overwrites its record. + * For apps whose package is renamed by a patch, this is the **original** + * (pre-patch) package name, for consistency with the rest of our schema. + */ +@Serializable +data class PatchedAppRecord( + /** Original (pre-patch) package name. Primary key; matches the supported-apps list. */ + val packageName: String, + /** + * Post-patch package as it installs on a device — differs from [packageName] + * when a rename patch was applied (e.g. `com.google.android.youtube` → + * `app.morphe.android.youtube`). Read from the output APK's manifest at patch + * time. Null/blank = no rename (same as [packageName]). Mirrors Manager's + * `currentPackageName`. + */ + val currentPackageName: String? = null, + /** Human-readable app name shown in UI. */ + val displayName: String, + /** APK version at patch time. */ + val apkVersion: String, + + /** Input APK path used. May no longer exist on disk. */ + val inputApkPath: String, + /** Output APK path we wrote. Its existence is the "is it still here" signal. */ + val outputApkPath: String, + + /** + * Baseline integrity fingerprint of the output APK, captured at patch time. + * Lets us later detect when the patched APK was changed outside Morphe + * (hash mismatch) vs untouched. Null only for records written before this + * field existed, or if hashing failed. See `patched-app-recall-plan.md` (Phase 6). + */ + val outputApkSha256: String? = null, + /** Size in bytes of the output APK at patch time. */ + val outputApkSize: Long = 0, + + /** Bundle source id → set of enabled patch unique ids (same shape as the selection state). */ + val patchSelectionByBundle: Map> = emptyMap(), + /** "patchName.optionKey" → raw value string (deserialized by patch type at apply time). */ + val patchOptionValues: Map = emptyMap(), + + /** + * Which sources + versions were enabled at patch time. "Update available" + * detection compares current source versions against this snapshot. + */ + val sourcesSnapshot: List = emptyList(), + + /** Epoch millis of when the patch completed. */ + val patchedAt: Long, + /** The Morphe (CLI/GUI) version that produced the patch — handy for debugging. */ + val patchedWithMorpheVersion: String, +) { + /** Package actually installed on a device (post-rename if applicable). */ + val installedPackageName: String get() = currentPackageName?.takeIf { it.isNotBlank() } ?: packageName + + @Serializable + data class PatchedSourceSnapshot( + val sourceId: String, + val sourceName: String, + /** `.mpp` release version, e.g. `v1.5.0`. */ + val version: String, + ) +} diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt b/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt new file mode 100644 index 00000000..6d05f9d1 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchCache.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-desktop + */ + +package app.morphe.engine.patches + +import app.morphe.engine.MorpheData +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import java.io.File + +/** + * Shared on-disk cache layout for downloaded `.mpp` patch files. + * + * Both the GUI ([app.morphe.gui.data.repository.PatchRepository]) and the CLI + * ([app.morphe.cli.command.PatchFileResolver]) resolve their cache paths through here, + * so a patch file downloaded by one side is reused by the other instead of each keeping its own copy in a different place. + * + * Layout: `/-/__.mpp` + */ +object PatchCache { + + /** Per-source cache directory, e.g. `morphe-data/patches/MorpheApp-morphe-patches/`. */ + fun sourceDir(repoPath: String): File = + File(MorpheData.patchesDir, repoPath.replace("/", "-")).also { it.mkdirs() } + + /** + * Per-release cache filename, prefixed with the release tag. + * + * Many sources name their `.mpp` asset the same string across versions + * (e.g. `morphe-patches.mpp`); prefixing with the tag keeps versions from + * overwriting each other in the cache. + */ + fun cachedFileName(release: Release, asset: ReleaseAsset): String = + "${release.tagName}__${asset.name}" +} diff --git a/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt b/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt new file mode 100644 index 00000000..a7d6c766 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +/** + * Builds the `pm` shell command lines that route web links ("open with") to a + * patched app and, optionally, stop the stock app from grabbing those same + * links. + * + * Pure argv construction only — no process execution. Each frontend runs these + * through its own adb path (the GUI's `AdbManager`, the CLI's installer), so the + * "what commands to run" decision lives in one tested place while "how to exec" + * stays per-frontend. + * + * Each returned entry is one `adb shell` invocation's arguments (i.e. everything + * after `adb -s shell`). Run them in order. + * + * Background: the patched app's web intent filters are NOT `autoVerify`, so + * Android's domain-verification commands no-op on them. The *user-selection* + * state, however, can be set for any host the app declares — that's the route + * used here (and the one Morphe's own docs prescribe). + */ +object AppLinkCommands { + + /** Default Android user. Primary user is always 0; `cur` also works. */ + const val DEFAULT_USER = "0" + + /** + * Approve every web host the patched app declares so it handles those links. + * + * `set-app-links-allowed true` flips the per-app "open supported links" + * master switch on; `set-app-links-user-selection true all` then approves + * all declared hosts (verified or not). Both are needed: the master switch + * alone doesn't approve unverified hosts. + */ + fun enablePatched(patchedPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", patchedPackage, "true"), + listOf("pm", "set-app-links-user-selection", "--user", user, "--package", patchedPackage, "true", "all"), + ) + + /** + * Stop the stock app from handling its web links, without disabling the app + * itself. The master "open supported links" switch off is surgical and + * reversible — far lighter than `pm disable-user`. + * + * Only meaningful when a rename patch was used (stock + patched coexist as + * different packages) and the stock package is actually installed. + */ + fun disableStock(stockPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", stockPackage, "false"), + ) + + /** + * Reverse [enablePatched]: revoke the patched app's host approvals so link + * routing returns to Android's defaults. + */ + fun restorePatched(patchedPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-user-selection", "--user", user, "--package", patchedPackage, "false", "all"), + ) + + /** + * Reverse [disableStock]: hand link handling back to the stock app. + */ + fun restoreStock(stockPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", stockPackage, "true"), + ) +} diff --git a/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt b/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt new file mode 100644 index 00000000..ed10df83 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/BundleFormats.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File + +/** + * Split-APK bundle container formats — ZIP archives that hold a base APK plus + * config/density/abi split APKs (`.apkm` from APKMirror, `.xapk` from APKPure, + * `.apks` from bundletool/SAI). + * + * Single source of truth for "is this a bundle?" so the engine, GUI, and CLI + * all agree. Lives in `engine.util` because it's a pure data check with no UI + * or CLI dependencies, and the GUI/CLI both depend on the engine (not the + * reverse). + */ +object BundleFormats { + /** Bundle file extensions, lowercase, without the leading dot. */ + val EXTENSIONS = setOf("apkm", "xapk", "apks") + + /** True if [file]'s extension is a split-APK bundle format. */ + fun isBundle(file: File): Boolean = file.extension.lowercase() in EXTENSIONS +} diff --git a/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt b/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt new file mode 100644 index 00000000..999095dd --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/FileChecksum.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File +import java.security.MessageDigest + +/** + * Canonical file-hashing used across the engine, CLI, and GUI. Lives in the + * engine layer so non-GUI callers (CLI patch recording, patched-app integrity + * checks) don't have to depend on `gui.util`. The GUI's `ChecksumUtils` + * delegates here so there's one implementation. + */ +object FileChecksum { + + /** Lowercase-hex SHA-256 of [file]. Streams the file — safe for large APKs. */ + fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + file.inputStream().use { stream -> + var read: Int + while (stream.read(buffer).also { read = it } != -1) { + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + /** + * Baseline fingerprint `(sha256, sizeBytes)` of the file at [path], or + * `(null, 0)` if it doesn't exist or hashing fails. Blocking IO — call off + * the main thread. Used to record patched-APK integrity at patch time. + */ + fun fingerprintOrNull(path: String): Pair = try { + val file = File(path) + if (file.exists()) sha256(file) to file.length() else null to 0L + } catch (_: Exception) { + null to 0L + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/JksKeyStoreParser.kt b/src/main/kotlin/app/morphe/engine/util/JksKeyStoreParser.kt new file mode 100644 index 00000000..9dc22780 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/JksKeyStoreParser.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + * + * Ported from morphe-manager — preserved verbatim so a key converted by + * Morphe Manager and one converted here produce byte-identical output. + * Manager carries the URV-compat learnings that this parser encodes. + */ + +package app.morphe.engine.util + +import java.io.DataInputStream +import java.io.InputStream +import java.security.MessageDigest + +/** + * Minimal JKS keystore parser that extracts private key + certificate chain entries. + * + * Android's security provider does not include a JKS KeyStoreSpi, so Manager parses + * the format manually. On desktop JVMs `KeyStore.getInstance("JKS")` typically works, + * but we keep this parser as the fallback for URV-style JKS variants that the stock + * SPI has historically choked on. The key protection algorithm is Sun's proprietary + * XOR scheme: + * keystream = SHA1(pwd || salt) || SHA1(pwd || SHA1(pwd || salt)) || ... + * plaintext = ciphertext XOR keystream + * integrity = SHA1(pwd || plaintext) (last 20 bytes of the protected-key blob) + * + * Reference: sun.security.provider.KeyProtector (OpenJDK source) + */ +object JksKeyStoreParser { + + private const val JKS_MAGIC = 0xFEEDFEED.toInt() + private const val TAG_PRIVATE_KEY = 1 + private const val TAG_TRUSTED_CERT = 2 + + data class JksEntry( + val alias: String, + val privateKeyDer: List, + val certificatesDer: List> + ) + + /** + * Parses a JKS [inputStream] and returns all private key entries. + * [password] is used both to decrypt each key entry and to verify keystore integrity. + * + * @throws IllegalArgumentException if the stream is not a valid JKS keystore. + * @throws SecurityException if key decryption fails (wrong password). + */ + fun parse(inputStream: InputStream, password: String): List { + val dis = DataInputStream(inputStream.buffered()) + + val magic = dis.readInt() + require(magic == JKS_MAGIC) { "Not a JKS keystore (magic=0x${magic.toString(16)})" } + + val version = dis.readInt() + require(version == 1 || version == 2) { "Unsupported JKS version: $version" } + + val count = dis.readInt() + val entries = mutableListOf() + + repeat(count) { + when (dis.readInt()) { + TAG_PRIVATE_KEY -> entries.add(readPrivateKeyEntry(dis, password)) + TAG_TRUSTED_CERT -> skipTrustedCertEntry(dis) + else -> error("Unknown JKS entry tag") + } + } + + return entries + } + + private fun readPrivateKeyEntry(dis: DataInputStream, password: String): JksEntry { + val alias = dis.readUTF() + dis.readLong() // timestamp - unused + + val protectedKeyLen = dis.readInt() + val protectedKey = ByteArray(protectedKeyLen).also { dis.readFully(it) } + val privateKeyDer = decryptKey(protectedKey, password).toList() + + val chainCount = dis.readInt() + val certs = (0 until chainCount).map { + dis.readUTF() + val certLen = dis.readInt() + ByteArray(certLen).also { dis.readFully(it) }.toList() + } + + return JksEntry(alias, privateKeyDer, certs) + } + + private fun skipTrustedCertEntry(dis: DataInputStream) { + dis.readUTF() // alias + dis.readLong() // timestamp + dis.readUTF() // cert type + val len = dis.readInt() + dis.skipBytes(len) + } + + /** + * Decrypts a Sun JKS protected key blob. + * + * Blob layout: [20 salt][ciphertext][20 sha1_check] + * Keystream: SHA1(pwd_utf16be || salt) || SHA1(pwd_utf16be || prev_hash) || ... + * Verify: SHA1(pwd_utf16be || plaintext) == sha1_check + */ + private fun decryptKey(protectedKey: ByteArray, password: String): ByteArray { + // The EncryptedPrivateKeyInfo DER wraps the actual protected blob. + // Structure: SEQUENCE { AlgorithmIdentifier(16 bytes from offset 4), OCTET STRING { blob } } + // AlgorithmIdentifier is always 16 bytes for OID 1.3.6.1.4.1.42.2.17.1.1 with NULL params. + val algoIdLen = 16 + val outerHeaderLen = 4 // 30 82 xx xx + var pos = outerHeaderLen + algoIdLen + + // OCTET STRING tag + length (may be 2 or 4 bytes depending on size) + pos++ // skip 0x04 tag + val lenByte = protectedKey[pos++].toInt() and 0xff + if (lenByte and 0x80 != 0) { + val n = lenByte and 0x7f + pos += n // skip multi-byte length + } + + val blob = protectedKey.copyOfRange(pos, protectedKey.size) + val salt = blob.copyOfRange(0, 20) + val ciphertext = blob.copyOfRange(20, blob.size - 20) + val check = blob.copyOfRange(blob.size - 20, blob.size) + + val pwdUtf16 = password.toByteArray(Charsets.UTF_16BE) + + val keystream = generateKeystream(pwdUtf16, salt, ciphertext.size) + val plaintext = ByteArray(ciphertext.size) { i -> (ciphertext[i].toInt() xor keystream[i].toInt()).toByte() } + + val sha1 = MessageDigest.getInstance("SHA-1") + sha1.update(pwdUtf16) + sha1.update(plaintext) + val verify = sha1.digest() + + if (!verify.contentEquals(check)) { + throw SecurityException("JKS key decryption failed - wrong password for key entry") + } + + return plaintext + } + + private fun generateKeystream(pwdUtf16: ByteArray, salt: ByteArray, length: Int): ByteArray { + val sha1 = MessageDigest.getInstance("SHA-1") + val result = ByteArray(length) + var pos = 0 + var digestInput = pwdUtf16 + salt + + while (pos < length) { + val hash = sha1.digest(digestInput) + val copy = minOf(hash.size, length - pos) + hash.copyInto(result, pos, 0, copy) + pos += copy + digestInput = pwdUtf16 + hash + } + return result + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/KeystoreConversionUtils.kt b/src/main/kotlin/app/morphe/engine/util/KeystoreConversionUtils.kt new file mode 100644 index 00000000..937bc816 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/KeystoreConversionUtils.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + * + * Ported from morphe-manager. Manager wrote this because morphe-patcher only + * speaks BKS but users (including URV cross-over users) often bring keystores + * in PKCS12, JKS, or BKS-with-misleading-extensions. Conversion is byte- + * sniffed, not extension-trusted — URV ships the same key bytes under + * different extensions, so trusting the suffix would mis-route. Keep this + * code as close to Manager's as possible so a key converted here and a key + * converted in Manager produce byte-identical BKS output. + */ + +package app.morphe.engine.util + +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.security.KeyFactory +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.spec.PKCS8EncodedKeySpec + +enum class KeystoreInputFormat( + val displayName: String, + val extensions: List, + val jcaType: String +) { + KEYSTORE(".keystore (BKS)", listOf("keystore"), "BKS"), + BKS(".bks (BKS)", listOf("bks"), "BKS"), + PKCS12(".p12 / .pfx (PKCS12)", listOf("p12", "pfx"), "PKCS12"), + JKS(".jks (JKS)", listOf("jks"), "JKS"); + + companion object { + fun fromExtension(ext: String): KeystoreInputFormat? = + entries.firstOrNull { ext.lowercase() in it.extensions } + + /** + * Sniff the keystore format from the first 4 bytes of the file. + * Returns null if the header is not recognized. + * + * PKCS12 - ASN.1 SEQUENCE: 0x30 + any BER length byte (0x80–0x84) + * JKS - magic: 0xFEEDFEED + * BKS - 4-byte big-endian version int, value 1 or 2 + */ + fun detectFromBytes(header: ByteArray): KeystoreInputFormat? { + if (header.size < 4) return null + return when { + header[0] == 0x30.toByte() && header[1] in byteArrayOf( + 0x80.toByte(), 0x81.toByte(), 0x82.toByte(), 0x83.toByte(), 0x84.toByte() + ) -> PKCS12 + header[0] == 0xFE.toByte() && header[1] == 0xED.toByte() && + header[2] == 0xFE.toByte() && header[3] == 0xED.toByte() -> JKS + header[0] == 0x00.toByte() && header[1] == 0x00.toByte() && + header[2] == 0x00.toByte() && + (header[3] == 0x01.toByte() || header[3] == 0x02.toByte()) -> BKS + else -> null + } + } + } +} + +sealed interface KeystoreConversionResult { + /** Conversion succeeded — [data] is a BKS keystore ready for the patcher. */ + data class Success(val data: List) : KeystoreConversionResult + /** Wrong password/alias, corrupt file, or unsupported format variant. */ + data class Error(val cause: Exception) : KeystoreConversionResult +} + +object KeystoreConversionUtils { + + /** + * Loads a keystore of [format] from [inputStream] and re-encodes all entries into a + * new BKS keystore, returning the raw bytes. The stream is read but not closed. + * + * When [alias] is blank all entries are transferred. Otherwise, the matching entry is + * looked up case-insensitively, falling back to transferring everything if not found. + * + * [password] is used for both the keystore and the individual key entries. + */ + fun convert( + inputStream: InputStream, + format: KeystoreInputFormat, + alias: String, + password: String + ): KeystoreConversionResult = runCatching { + val pass = password.toCharArray() + + // JKS requires manual parsing — see JksKeyStoreParser. Manager originally added + // this because Android's BC has no JKSKeyStoreSpi; we keep it as a fallback for + // URV-style JKS variants the stock SPI has historically choked on. + if (format == KeystoreInputFormat.JKS) { + val entries = JksKeyStoreParser.parse(inputStream, password) + check(entries.isNotEmpty()) { "No entries found in JKS keystore" } + + val jksTarget = KeyStore.getInstance("BKS").apply { load(null, pass) } + val cf = CertificateFactory.getInstance("X.509") + val kf = KeyFactory.getInstance("RSA") + + val filtered = if (alias.isBlank()) entries + else entries.filter { it.alias.equals(alias, ignoreCase = true) } + .ifEmpty { entries } + + filtered.forEach { entry -> + val privateKey = kf.generatePrivate(PKCS8EncodedKeySpec(entry.privateKeyDer.toByteArray())) + val certs = entry.certificatesDer.map { + cf.generateCertificate(it.toByteArray().inputStream()) + }.toTypedArray() + jksTarget.setKeyEntry(entry.alias, privateKey, pass, certs) + } + + val out = ByteArrayOutputStream() + jksTarget.store(out, pass) + return@runCatching KeystoreConversionResult.Success(out.toByteArray().toList()) + } + + // PKCS12 / BKS go through standard JCA. BKS source paths shouldn't normally + // reach here — callers should short-circuit on BKS detection — but kept as a + // safety net so calling convert() with BKS still produces a valid result. + val source = KeyStore.getInstance(format.jcaType).apply { load(inputStream, pass) } + + val entriesToMigrate = if (alias.isBlank()) { + source.aliases().toList() + } else { + source.aliases().toList() + .filter { it.equals(alias, ignoreCase = true) } + .ifEmpty { source.aliases().toList() } + } + + check(entriesToMigrate.isNotEmpty()) { "No entries found in keystore" } + + val target = KeyStore.getInstance("BKS").apply { load(null, pass) } + + for (entryAlias in entriesToMigrate) { + val protection = KeyStore.PasswordProtection(pass) + when { + source.isKeyEntry(entryAlias) -> { + val entry = source.getEntry(entryAlias, protection) ?: continue + target.setEntry(entryAlias, entry, protection) + } + source.isCertificateEntry(entryAlias) -> + target.setCertificateEntry(entryAlias, source.getCertificate(entryAlias)) + } + } + + val out = ByteArrayOutputStream() + target.store(out, pass) + KeystoreConversionResult.Success(out.toByteArray().toList()) + }.getOrElse { e -> + KeystoreConversionResult.Error(e as? Exception ?: RuntimeException(e)) + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/KeystoreImporter.kt b/src/main/kotlin/app/morphe/engine/util/KeystoreImporter.kt new file mode 100644 index 00000000..dfd784fc --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/KeystoreImporter.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File +import java.security.Security + +/** + * The user-facing entry point for "I have a keystore file, make it work with + * morphe-patcher" — which only loads BKS. Wraps [KeystoreConversionUtils.convert] + * with the safeguards we want everywhere: + * + * 1. **Byte-sniff, not extension-trust.** URV ships the same key bytes under + * different extensions; extensions lie. + * 2. **BKS short-circuit.** If the source is already BKS, return the original + * file untouched — never round-trip a working keystore through convert(), + * no risk of provider differences silently rewriting it. + * 3. **Never mutate the original.** Conversions write to a separate output + * file the caller provides; the user's source file is read-only. + * 4. **Register BouncyCastle on demand.** convert() needs `KeyStore.getInstance("BKS")` + * which requires the BC provider — ensure it's installed before calling. + */ +object KeystoreImporter { + + /** Outcome of [ensureBks]. */ + sealed interface Result { + /** The provided source was already BKS — no conversion needed. Use [file] directly. */ + data class AlreadyBks(val file: File) : Result + /** Source was a different format; converted bytes were written to [file]. */ + data class Converted(val file: File, val sourceFormat: KeystoreInputFormat) : Result + /** Detection or conversion failed. [reason] is safe to surface to users. */ + data class Failed(val reason: String, val cause: Throwable? = null) : Result + } + + /** + * Ensure [source] can be passed to morphe-patcher. If already BKS, returns + * [Result.AlreadyBks] pointing at the original. Otherwise converts to BKS + * and writes the bytes to [convertedOutput], returning [Result.Converted]. + * + * [alias] and [password] are used to read the source. Pass the user's + * configured values; an empty alias means "transfer every entry" (Manager's + * default behavior — usually what you want for unknown third-party keys). + */ + fun ensureBks( + source: File, + convertedOutput: File, + alias: String, + password: String, + ): Result { + if (!source.exists()) { + return Result.Failed("Keystore file not found: ${source.absolutePath}") + } + if (!source.canRead()) { + return Result.Failed("Keystore file is not readable: ${source.absolutePath}") + } + + val header = try { + source.inputStream().use { stream -> + val buf = ByteArray(4) + val read = stream.read(buf) + if (read < 4) return Result.Failed("Keystore file is too small to identify") + buf + } + } catch (e: Exception) { + return Result.Failed("Couldn't read keystore header: ${e.message ?: e.javaClass.simpleName}", e) + } + + val format = KeystoreInputFormat.detectFromBytes(header) + ?: return Result.Failed( + "Unrecognized keystore format. Supported: BKS, PKCS12 (.p12/.pfx), JKS." + ) + + if (format == KeystoreInputFormat.BKS || format == KeystoreInputFormat.KEYSTORE) { + // BKS short-circuit — never re-encode an already-BKS file. + return Result.AlreadyBks(source) + } + + ensureBouncyCastleProvider() + + val converted = source.inputStream().use { + KeystoreConversionUtils.convert(it, format, alias, password) + } + return when (converted) { + is KeystoreConversionResult.Error -> Result.Failed( + friendlyConversionError(converted.cause), + converted.cause, + ) + is KeystoreConversionResult.Success -> { + try { + convertedOutput.parentFile?.mkdirs() + convertedOutput.writeBytes(converted.data.toByteArray()) + Result.Converted(convertedOutput, format) + } catch (e: Exception) { + Result.Failed( + "Couldn't save converted keystore: ${e.message ?: e.javaClass.simpleName}", + e, + ) + } + } + } + } + + /** + * Register BouncyCastle if it isn't already — needed for + * `KeyStore.getInstance("BKS")`. The patcher already pulls BC transitively, + * so the class is always on the classpath; we just have to install it as a + * JCA provider. Idempotent. + */ + private fun ensureBouncyCastleProvider() { + if (Security.getProvider("BC") != null) return + try { + val provider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") + .getDeclaredConstructor() + .newInstance() as java.security.Provider + Security.addProvider(provider) + } catch (_: Exception) { + // If BC isn't on the classpath the patcher itself would fail anyway — + // let conversion try and surface its own error rather than guessing here. + } + } + + private fun friendlyConversionError(cause: Throwable): String { + val msg = cause.message.orEmpty() + return when { + cause is java.security.UnrecoverableKeyException || + msg.contains("password", ignoreCase = true) -> + "Couldn't decrypt keystore — wrong password or alias." + msg.contains("No entries found", ignoreCase = true) -> + "Keystore has no usable key entries." + else -> "Conversion failed: ${msg.ifBlank { cause.javaClass.simpleName }}" + } + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/SignatureIdentity.kt b/src/main/kotlin/app/morphe/engine/util/SignatureIdentity.kt new file mode 100644 index 00000000..499be162 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/SignatureIdentity.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File +import java.security.KeyStore +import java.security.Security +import java.security.cert.Certificate +import java.util.Arrays + +/** + * Identifies a signing certificate by the same value Android reports in + * `dumpsys package `: + * + * ``` + * signatures=PackageSignatures{… signatures:[a7001add], past signatures:[]} + * ``` + * + * That `a7001add` is `Integer.toHexString(Arrays.hashCode(certDER))` of the + * signing cert (Android's `Signature.hashCode()`). Computing the same value from + * Morphe's keystore cert lets us tell, from a connected device, whether an + * installed app was signed by Morphe — without pulling the APK. + * + * The `signatures:[HEX]` format has been stable across Android 7→15. If a device + * emits something we don't recognise, [parseDeviceSignatureId] returns null and + * callers fall back to the version-robust "installed?" check (no false verdicts). + */ +object SignatureIdentity { + + /** Android's signature id for [cert]: `toHexString(Arrays.hashCode(DER))`. */ + fun idForCert(cert: Certificate): String = + Integer.toHexString(Arrays.hashCode(cert.encoded)) + + /** + * Signature id of the cert under [alias] in the BKS [keystoreFile], or null + * if it can't be read. Only the public certificate is needed, so the key + * (entry) password is irrelevant — just the store password. + */ + fun idForKeystore(keystoreFile: File, storePassword: String?, alias: String): String? = try { + if (!keystoreFile.exists()) { + null + } else { + ensureBouncyCastle() + val ks = KeyStore.getInstance("BKS") + keystoreFile.inputStream().use { ks.load(it, storePassword?.toCharArray()) } + val realAlias = ks.aliases().toList().firstOrNull { it.equals(alias, ignoreCase = true) } ?: alias + ks.getCertificate(realAlias)?.let { idForCert(it) } + } + } catch (e: Exception) { + null + } + + /** + * Parse the signing-cert id from `dumpsys package` output. Matches the + * `signatures:[HEX]` form (lowercased). Null if not present/unrecognised. + */ + fun parseDeviceSignatureId(dumpsysOutput: String): String? = + Regex("""signatures:\[([0-9a-fA-F]+)\]""").find(dumpsysOutput)?.groupValues?.get(1)?.lowercase() + + private fun ensureBouncyCastle() { + if (Security.getProvider("BC") != null) return + try { + val provider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") + .getDeclaredConstructor().newInstance() as java.security.Provider + Security.addProvider(provider) + } catch (_: Exception) { + // BC ships transitively with the patcher; if absent, BKS load fails + // and idForKeystore returns null — graceful. + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt index 6e1b77cd..9a548bf7 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -78,7 +78,10 @@ fun launchGui(args: Array) = application { state = windowState, icon = appIcon ) { - window.minimumSize = java.awt.Dimension(600, 400) + // Min width keeps the single side-by-side Home layout viable — there is no + // narrow/stacked variant to fall back to (intentionally removed; one layout + // to maintain). 900 is the floor at which the two panes still read well. + window.minimumSize = java.awt.Dimension(900, 500) // macOS: hide the OS-drawn title bar so a Compose-rendered colored // band can take its place. Traffic lights stay where the OS draws diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index 4ef8ed1a..92165dcb 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -25,24 +25,57 @@ val DEFAULT_PATCH_SOURCE = PatchSource( deletable = false ) +/** + * How a patch source decides which release to load. + * + * - [FOLLOW_STABLE]: ride the newest **stable** (non-pre-release) — auto-updates + * as new stables ship. The default for an untouched source. + * - [FOLLOW_DEV]: ride the newest release **overall**, pre-releases included + * ("bleeding edge"). When a dev is newest you get the dev; when a stable is the + * newest thing out, you get that stable — without losing the dev track. + * - [PINNED]: stay frozen on one exact tag (chosen deliberately), ignoring newer + * releases. The version lives in [SourceVersionPref.pinnedTag]. + */ +@Serializable +enum class FollowMode { FOLLOW_STABLE, FOLLOW_DEV, PINNED } + +/** + * A source's version preference: which release-tracking [mode], plus the exact + * tag when [mode] is [FollowMode.PINNED] (null otherwise). + */ +@Serializable +data class SourceVersionPref( + val mode: FollowMode, + val pinnedTag: String? = null, +) + @Serializable data class AppConfig( val themePreference: String = ThemePreference.SYSTEM.name, val lastCliVersion: String? = null, /** - * LEGACY single-source version pin. Kept only for one-version migration into - * [lastPatchesVersionBySource] — read it on first load if the map is empty, - * then phase out. Do not read this directly anywhere new — go through - * [ConfigRepository.getLastPatchesVersionsBySource]. + * LEGACY single-source version pin. Kept only so it can be migrated (via + * [lastPatchesVersionBySource]) into [sourceVersionPrefs]. Do not read directly + * anywhere new — go through [ConfigRepository.getSourceVersionPrefs]. */ val lastPatchesVersion: String? = null, /** - * Per-source version pin: sourceId → release tag. Absence of a key means - * "no pin — use that source's latest stable". Replaces the legacy single - * [lastPatchesVersion] which silently contaminated other sources whose tag - * names happened to overlap. + * LEGACY per-source version pin: sourceId → release tag. Superseded by + * [sourceVersionPrefs]; kept only so existing configs can migrate (every old + * tag becomes a follow-track based on whether it was a dev tag). Do not read + * directly — go through [ConfigRepository.getSourceVersionPrefs]. */ val lastPatchesVersionBySource: Map = emptyMap(), + /** + * Per-source version preference: sourceId → [SourceVersionPref]. + * + * Absence of a key = follow the source's latest stable (the default for a + * brand-new, untouched source). Otherwise the stored [SourceVersionPref] + * decides whether the source rides the latest stable, the latest overall + * (dev/bleeding-edge), or stays frozen on a specific tag. See + * [ConfigRepository.getSourceVersionPrefs] / [setSourceVersionPref]. + */ + val sourceVersionPrefs: Map = emptyMap(), val preferredPatchChannel: String = PatchChannel.STABLE.name, val defaultOutputDirectory: String? = null, val autoCleanupTempFiles: Boolean = true, // Default ON @@ -82,6 +115,17 @@ data class AppConfig( // When ON, DeviceMonitor polls devices; if Morphe was the one that started // the daemon, it's killed on toggle-OFF and on window close. val autoStartAdb: Boolean = false, + // Which home apps tab the user last viewed ("ALL" or "YOURS"), restored on + // next launch. Stored as a string so this data layer stays free of UI enums. + val homeAppListFilter: String = "ALL", + // After an ADB install, automatically route the patched app's web links to it + // ("open with"). Default OFF — it changes how the device opens links, so it's + // opt-in. See AppLinkCommands / AdbManager.setLinkHandling. + val autoRouteLinksAfterInstall: Boolean = false, + // When auto-routing links, also stop the stock app from opening those links + // (only applies when a rename patch was used and stock is installed). Default + // OFF — it reaches into a stock app's behavior. + val disableStockLinksAfterInstall: Boolean = false, ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt index e2769a68..ff812549 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -5,6 +5,7 @@ package app.morphe.gui.data.model +import app.morphe.engine.model.PatchedAppRecord import app.morphe.patcher.resource.CpuArchitecture import kotlinx.serialization.Serializable @@ -91,5 +92,16 @@ data class PatchConfig( val patchOptions: Map = emptyMap(), val useExclusiveMode: Boolean = false, val keepArchitectures: Set = emptySet(), - val continueOnError: Boolean = false + val continueOnError: Boolean = false, + + // ── Recall metadata ── + // Carried from the selection screen down to the patching screen so the + // success path can record a PatchedAppRecord (see PatchedAppStore). All + // default-empty, so callers that don't populate them still work. + val packageName: String = "", + val appDisplayName: String = "", + /** Source name → set of selected patch unique ids. */ + val patchSelectionByBundle: Map> = emptyMap(), + /** Sources + versions enabled at patch time (drives "update available"). */ + val sourcesSnapshot: List = emptyList(), ) diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index a0477ffa..b9afc647 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -8,6 +8,8 @@ package app.morphe.gui.data.repository import app.morphe.engine.util.PortablePaths import app.morphe.gui.data.model.AppConfig import app.morphe.gui.data.model.DEFAULT_PATCH_SOURCE +import app.morphe.gui.data.model.FollowMode +import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.model.PatchChannel import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.UpdateChannelPreference @@ -102,40 +104,48 @@ class ConfigRepository { } /** - * LEGACY — kept so single-source callers don't break during the multi-source - * transition. New code should use [setLastPatchesVersionForSource]. + * Record a source's version preference (called by PatchesScreen when the user + * picks a release). Per-source, so sources with overlapping tag names don't + * contaminate each other. */ - @Deprecated("Use setLastPatchesVersionForSource", ReplaceWith("setLastPatchesVersionForSource(sourceId, version)")) - suspend fun setLastPatchesVersion(version: String) { + suspend fun setSourceVersionPref(sourceId: String, pref: SourceVersionPref) { val current = loadConfig() - saveConfig(current.copy(lastPatchesVersion = version)) + saveConfig(current.copy(sourceVersionPrefs = current.sourceVersionPrefs + (sourceId to pref))) } /** - * Pin a specific release tag for [sourceId]. Used by PatchesScreen when the - * user picks a version. Per-source = no cross-contamination across sources - * with overlapping tag names. + * Returns the per-source version preferences, with a one-time migration from + * the legacy tag-only fields ([AppConfig.lastPatchesVersionBySource] and the + * even-older single [AppConfig.lastPatchesVersion]). + * + * Migration intent: those legacy tags were auto-saved on every selection, not + * deliberate freezes, so we can't tell a real pin from "just used the latest." + * We therefore convert each old tag to a *follow track* based on whether it was + * a dev tag — `-dev` ⇒ [FollowMode.FOLLOW_DEV], else [FollowMode.FOLLOW_STABLE]. + * Net effect: everyone shifts onto the latest of their channel (the fix), at the + * cost of un-freezing anyone who had deliberately pinned an old version (they can + * re-pin). A dev user parked on a stable tag at migration looks like a stable + * user and is migrated as such — an accepted, self-healing one-time loss. */ - suspend fun setLastPatchesVersionForSource(sourceId: String, version: String) { + suspend fun getSourceVersionPrefs(): Map { val current = loadConfig() - val updated = current.lastPatchesVersionBySource + (sourceId to version) - saveConfig(current.copy(lastPatchesVersionBySource = updated)) - } + if (current.sourceVersionPrefs.isNotEmpty()) return current.sourceVersionPrefs + + // Build the legacy tag map (per-source map, or the single legacy field + // mapped onto the default source). + val legacyTags: Map = when { + current.lastPatchesVersionBySource.isNotEmpty() -> current.lastPatchesVersionBySource + current.lastPatchesVersion != null -> mapOf(DEFAULT_PATCH_SOURCE.id to current.lastPatchesVersion!!) + else -> emptyMap() + } + if (legacyTags.isEmpty()) return emptyMap() - /** - * Returns the per-source version pin map, with one-time migration from the - * legacy [AppConfig.lastPatchesVersion] field: if the map is empty and the - * legacy field is set, it's mapped to the default source. - */ - suspend fun getLastPatchesVersionsBySource(): Map { - val current = loadConfig() - if (current.lastPatchesVersionBySource.isNotEmpty()) { - return current.lastPatchesVersionBySource + val migrated = legacyTags.mapValues { (_, tag) -> + val mode = if (tag.contains("-dev", ignoreCase = true)) FollowMode.FOLLOW_DEV + else FollowMode.FOLLOW_STABLE + SourceVersionPref(mode = mode) } - val legacy = current.lastPatchesVersion ?: return emptyMap() - // Migrate: write the legacy pin onto the default source, return the new map. - val migrated = mapOf(DEFAULT_PATCH_SOURCE.id to legacy) - saveConfig(current.copy(lastPatchesVersionBySource = migrated)) + saveConfig(current.copy(sourceVersionPrefs = migrated)) return migrated } @@ -204,6 +214,22 @@ class ConfigRepository { saveConfig(current.copy(autoCleanupTempFiles = enabled)) } + /** + * Update the "route links to patched app after install" setting. + */ + suspend fun setAutoRouteLinksAfterInstall(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(autoRouteLinksAfterInstall = enabled)) + } + + /** + * Update the "also disable stock app's links" sub-setting. + */ + suspend fun setDisableStockLinksAfterInstall(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(disableStockLinksAfterInstall = enabled)) + } + /** * Update simplified mode setting. */ @@ -296,6 +322,22 @@ class ConfigRepository { saveConfig(current.copy(patchSource = updatedSources)) } + /** + * Persist a new source ordering given the ids in their desired order. Order + * only affects the app display-name tiebreak (first source wins) and UI + * presentation order — not which patches load — so any source, default + * included, may be reordered. Ignores the call unless [orderedIds] is a + * permutation of the existing sources (guards against a stale UI snapshot + * dropping or duplicating a source). + */ + suspend fun reorderPatchSources(orderedIds: List) { + val current = loadConfig() + val byId = current.patchSource.associateBy { it.id } + if (orderedIds.size != current.patchSource.size || orderedIds.toSet() != byId.keys) return + val reordered = orderedIds.map { byId.getValue(it) } + saveConfig(current.copy(patchSource = reordered)) + } + /** * Update whether Morphe auto-starts the ADB daemon at GUI launch. */ @@ -313,6 +355,14 @@ class ConfigRepository { saveConfig(current.copy(multiSourceHintDismissed = true)) } + /** Persist which home apps tab ("ALL"/"YOURS") the user is viewing. */ + suspend fun setHomeAppListFilter(value: String) { + val current = loadConfig() + if (current.homeAppListFilter == value) return + saveConfig(current.copy(homeAppListFilter = value)) + } + + /** * Toggle enablement of a patch source. Safety net: if disabling would leave zero * enabled sources, the default source is force-enabled (mirrors morphe-manager diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt index fcfc1fa4..15c8ce23 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -7,9 +7,9 @@ package app.morphe.gui.data.repository import app.morphe.engine.model.Release import app.morphe.engine.model.ReleaseAsset +import app.morphe.engine.patches.PatchCache import app.morphe.engine.patches.RemotePatchSource import app.morphe.engine.patches.findPatchAsset -import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -55,7 +55,7 @@ class PatchRepository( * to eyeball when grepping the cache directory than a single dash. */ fun cachedFileName(release: Release, asset: ReleaseAsset): String = - "${release.tagName}__${asset.name}" + PatchCache.cachedFileName(release, asset) } // In-memory cache so multiple callers don't re-fetch from the remote API @@ -124,8 +124,7 @@ class PatchRepository( Exception("No .mpp patch files found in release ${release.tagName}") ) - val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) - patchesDir.mkdirs() + val patchesDir = PatchCache.sourceDir(repoPath) val targetFile = File(patchesDir, cachedFileName(release, asset)) // Cache hit rules: @@ -155,7 +154,7 @@ class PatchRepository( /** Get cached patch file for a specific version. */ fun getCachedPatches(version: String): File? { - val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + val patchesDir = PatchCache.sourceDir(repoPath) return patchesDir.listFiles()?.find { it.name.contains(version) && isPatchFileName(it.name) } @@ -166,23 +165,19 @@ class PatchRepository( /** List all cached patch versions. */ fun listCachedPatches(): List { - val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + val patchesDir = PatchCache.sourceDir(repoPath) return patchesDir.listFiles()?.filter { isPatchFileName(it.name) } ?: emptyList() } /** Get the per-source cache directory for this repository. */ - fun getCacheDir(): File { - val dir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) - dir.mkdirs() - return dir - } + fun getCacheDir(): File = PatchCache.sourceDir(repoPath) /** Delete cached patches (both in-memory release list and on-disk files). */ fun clearCache(): Boolean { cachedReleases = null cacheTimestamp = 0L return try { - val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + val patchesDir = PatchCache.sourceDir(repoPath) var failedCount = 0 patchesDir.listFiles()?.forEach { file -> try { diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt index 496c62a4..9de335ad 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt @@ -266,6 +266,16 @@ class PatchSourceManager( _sourceVersion.value++ } + /** + * Persist a new source ordering. Order affects only the display-name + * tiebreak and UI presentation, not which patches load. + */ + suspend fun reorderSources(orderedIds: List) { + configRepository.reorderPatchSources(orderedIds) + refreshEnabledSources() + _sourceVersion.value++ + } + /** * Update an existing source (e.g. rename). Refuses non-deletable sources. */ diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index 9c2a68f5..004d2314 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -9,6 +9,7 @@ import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchPreferencesRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository +import app.morphe.engine.PatchedAppStore import app.morphe.gui.util.PatchService import io.ktor.client.* import io.ktor.client.engine.cio.* @@ -63,11 +64,12 @@ val appModule = module { single { PatchSourceManager(get(), get()) } single { PatchService() } single { UpdateCheckRepository(get()) } + single { PatchedAppStore.shared } // ViewModels (ScreenModels) // ViewModels observe PatchSourceManager.sourceVersion and reload on source changes. factory { - HomeViewModel(get(), get(), get(), get()) + HomeViewModel(get(), get(), get(), get(), get()) } factory { params -> val psm = get() @@ -96,12 +98,15 @@ val appModule = module { psm.getLocalFilePath(), params.get(), params.get(), + params.get(), + params.get(), ) } factory { params -> PatchingViewModel( params.get(), get(), + get(), get() ) } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ActionButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/ActionButton.kt new file mode 100644 index 00000000..3122fa8e --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/ActionButton.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.LocalMorpheCorners + +/** + * Outlined, full-width action button. Shared by [SettingsDialog]'s runtime-logs + * section and [ToolsDialog]'s action list — lifted to its own file so both + * surfaces use one definition. + */ +@Composable +internal fun ActionButton( + label: String, + icon: ImageVector, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + enabled: Boolean = true, + onClick: () -> Unit +) { + val corners = LocalMorpheCorners.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth().hoverable(hoverInteraction), + shape = RoundedCornerShape(corners.small), + border = BorderStroke( + 1.dp, + if (isHovered && enabled) contentColor.copy(alpha = 0.3f) + else borderColor + ), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = contentColor, + disabledContentColor = contentColor.copy(alpha = 0.4f) + ) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + label, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp, + modifier = Modifier.weight(1f) + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt new file mode 100644 index 00000000..df0474b5 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/LicensesDialog.kt @@ -0,0 +1,773 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.MorpheAccentColors +import app.morphe.gui.ui.theme.MorpheCornerStyle +import app.morphe.gui.util.Logger +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.aboutlibraries.entity.License +import java.awt.Desktop +import java.net.URI + +@Composable +internal fun LicensesDialog(onDismiss: () -> Unit) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) + + val libs = remember { + try { + val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("aboutlibraries.json") + val json = stream?.bufferedReader()?.use { it.readText() } + if (json != null) Libs.Builder().withJson(json).build() else null + } catch (e: Exception) { + Logger.error("Failed to load licenses", e) + null + } + } + + var searchQuery by remember { mutableStateOf("") } + var viewingLicense by remember { mutableStateOf(null) } + val listState = rememberLazyListState() + + val filtered = remember(libs, searchQuery) { + val all = libs?.libraries.orEmpty() + if (searchQuery.isBlank()) all + else { + val q = searchQuery.trim().lowercase() + all.filter { lib -> + lib.name.lowercase().contains(q) || + lib.uniqueId.lowercase().contains(q) || + (lib.description?.lowercase()?.contains(q) == true) || + lib.licenses.any { it.name.lowercase().contains(q) || (it.spdxId?.lowercase()?.contains(q) == true) } + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .widthIn(min = 640.dp, max = 960.dp) + .heightIn(min = 520.dp, max = 780.dp) + .fillMaxWidth(0.88f) + .fillMaxHeight(0.88f), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(corners.medium), + border = BorderStroke(1.dp, borderColor) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Header ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = "OPEN SOURCE LICENSES", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + letterSpacing = 1.8.sp, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "[${libs?.libraries?.size ?: 0}]", + fontFamily = mono, + fontSize = 11.sp, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + val closeHover = remember { MutableInteractionSource() } + val isCloseHovered by closeHover.collectIsHoveredAsState() + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .hoverable(closeHover) + .background( + if (isCloseHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) + else Color.Transparent + ) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (isCloseHovered) 0.85f else 0.55f + ), + modifier = Modifier.size(14.dp) + ) + } + } + + HorizontalDivider(color = dividerColor) + + // ── Search bar ── + Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp)) { + LicenseSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) + } + + HorizontalDivider(color = dividerColor) + + // ── List ── + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + when { + libs == null -> EmptyHint(text = "// failed to load licenses", mono = mono, isError = true) + filtered.isEmpty() -> EmptyHint(text = "// no matches", mono = mono, isError = false) + else -> { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 22.dp, vertical = 8.dp) + ) { + items(items = filtered, key = { it.uniqueId }) { library -> + LibraryRow( + library = library, + mono = mono, + accents = accents, + corners = corners, + borderColor = borderColor, + dividerColor = dividerColor, + onLicenseClick = { viewingLicense = it } + ) + } + } + + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 6.dp), + adapter = rememberScrollbarAdapter(listState), + style = morpheScrollbarStyle() + ) + } + } + } + + HorizontalDivider(color = dividerColor) + + // ── Footer ── + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (searchQuery.isBlank()) "${filtered.size} libraries" + else "${filtered.size} / ${libs?.libraries?.size ?: 0} matched", + fontFamily = mono, + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 0.8.sp + ) + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) + ) { + Text( + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + viewingLicense?.let { license -> + LicenseTextDialog(license = license, onDismiss = { viewingLicense = null }) + } +} + +@Composable +private fun LicenseSearchBar(query: String, onQueryChange: (String) -> Unit) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val searchFocused = remember { mutableStateOf(false) } + val searchBorderColor by animateColorAsState( + if (searchFocused.value) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + animationSpec = tween(150) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, searchBorderColor, RoundedCornerShape(corners.small)) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(16.dp) + ) + + Box(modifier = Modifier.weight(1f)) { + if (query.isEmpty()) { + Text( + text = "Search libraries, SPDX id, uniqueId…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) + ) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = LocalTextStyle.current.copy( + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(accents.primary), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { searchFocused.value = it.isFocused } + ) + } + + if (query.isNotEmpty()) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(corners.small)) + .clickable { onQueryChange("") }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(14.dp) + ) + } + } + } +} + +@Composable +private fun LibraryRow( + library: Library, + mono: androidx.compose.ui.text.font.FontFamily, + accents: MorpheAccentColors, + corners: MorpheCornerStyle, + borderColor: Color, + dividerColor: Color, + onLicenseClick: (License) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + animationSpec = tween(180) + ) + val bgAlpha by animateFloatAsState( + targetValue = when { + expanded -> 0.05f + isHovered -> 0.03f + else -> 0f + }, + animationSpec = tween(180) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .hoverable(hoverInteraction) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = bgAlpha)) + .clickable { expanded = !expanded } + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = library.name, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + library.artifactVersion?.takeIf { it.isNotBlank() }?.let { v -> + Text( + text = "v$v", + fontSize = 10.sp, + fontFamily = mono, + color = accents.secondary.copy(alpha = 0.9f), + letterSpacing = 0.3.sp + ) + } + } + Spacer(Modifier.height(2.dp)) + Text( + text = library.uniqueId, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (library.licenses.isEmpty()) { + LicenseChip( + label = "UNKNOWN", + mono = mono, + corners = corners, + accentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), + onClick = null + ) + } else { + library.licenses.forEach { license -> + LicenseChip( + label = licenseDisplayLabel(license), + mono = mono, + corners = corners, + accentColor = accents.primary, + onClick = { onLicenseClick(license) } + ) + } + } + } + + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.7f else 0.4f), + modifier = Modifier + .size(16.dp) + .graphicsLayer { rotationZ = rotation } + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically(expandFrom = Alignment.Top, animationSpec = tween(200)) + + fadeIn(animationSpec = tween(200)), + exit = shrinkVertically(shrinkTowards = Alignment.Top, animationSpec = tween(180)) + + fadeOut(animationSpec = tween(140)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 14.dp, top = 2.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + library.description?.trim()?.takeIf { it.isNotEmpty() }?.let { desc -> + Text( + text = desc, + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), + lineHeight = 17.sp + ) + } + + val devs = library.developers.mapNotNull { it.name?.takeIf { n -> n.isNotBlank() } } + val org = library.organization?.name?.takeIf { it.isNotBlank() } + if (devs.isNotEmpty() || org != null) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (devs.isNotEmpty()) { + MetaLine(label = "AUTHORS", value = devs.joinToString(", "), mono = mono) + } + org?.let { MetaLine(label = "ORG", value = it, mono = mono) } + } + } + + val website = library.website?.takeIf { it.isNotBlank() } + val source = library.scm?.url?.takeIf { it.isNotBlank() } + if (website != null || source != null) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + website?.let { + LinkPill(label = "WEBSITE", url = it, mono = mono, corners = corners, borderColor = borderColor) + } + source?.let { + LinkPill(label = "SOURCE", url = it, mono = mono, corners = corners, borderColor = borderColor) + } + } + } + } + } + + HorizontalDivider(color = dividerColor) + } +} + +@Composable +private fun MetaLine( + label: String, + value: String, + mono: androidx.compose.ui.text.font.FontFamily, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + letterSpacing = 1.sp, + modifier = Modifier.width(56.dp) + ) + Text( + text = value, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun LicenseChip( + label: String, + mono: androidx.compose.ui.text.font.FontFamily, + corners: MorpheCornerStyle, + accentColor: Color, + onClick: (() -> Unit)?, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val bg by animateColorAsState( + if (isHovered && onClick != null) accentColor.copy(alpha = 0.18f) + else accentColor.copy(alpha = 0.08f), + animationSpec = tween(140) + ) + Box( + modifier = Modifier + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .background(bg, RoundedCornerShape(corners.small)) + .border(1.dp, accentColor.copy(alpha = 0.35f), RoundedCornerShape(corners.small)) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 7.dp, vertical = 3.dp) + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accentColor, + letterSpacing = 0.8.sp, + maxLines = 1 + ) + } +} + +@Composable +private fun LinkPill( + label: String, + url: String, + mono: androidx.compose.ui.text.font.FontFamily, + corners: MorpheCornerStyle, + borderColor: Color, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Row( + modifier = Modifier + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (isHovered) borderColor.copy(alpha = 0.4f) else borderColor, + RoundedCornerShape(corners.small) + ) + .clickable { openUrl(url) } + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.9f else 0.6f), + letterSpacing = 1.sp + ) + @Suppress("DEPRECATION") + Icon( + imageVector = Icons.Default.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.75f else 0.45f), + modifier = Modifier.size(10.dp) + ) + } +} + +@Composable +private fun EmptyHint(text: String, mono: androidx.compose.ui.text.font.FontFamily, isError: Boolean) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = text, + fontFamily = mono, + fontSize = 12.sp, + color = if (isError) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + letterSpacing = 0.8.sp + ) + } +} + +@Composable +private fun LicenseTextDialog(license: License, onDismiss: () -> Unit) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + val content = license.licenseContent?.takeIf { it.isNotBlank() } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .widthIn(min = 540.dp, max = 820.dp) + .heightIn(min = 380.dp, max = 680.dp) + .fillMaxWidth(0.78f) + .fillMaxHeight(0.82f), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(corners.medium), + border = BorderStroke(1.dp, borderColor) + ) { + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + val chipLabel = licenseDisplayLabel(license) + Text( + text = chipLabel.uppercase(), + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.5.sp, + color = accents.primary + ) + if (license.name.isNotBlank() && !license.name.equals(chipLabel, ignoreCase = true)) { + Text( + text = license.name, + fontFamily = mono, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + } + } + + HorizontalDivider(color = borderColor) + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + if (content != null) { + val scrollState = rememberScrollState() + Text( + text = content, + fontSize = 11.sp, + fontFamily = mono, + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 22.dp, vertical = 16.dp) + ) + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 6.dp), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle() + ) + } else { + Column( + modifier = Modifier.fillMaxSize().padding(22.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "// full license text not bundled", + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + letterSpacing = 0.5.sp + ) + license.url?.takeIf { it.isNotBlank() }?.let { url -> + Text( + text = "Open the canonical license text:", + fontFamily = mono, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + LinkPill( + label = "OPEN LICENSE", + url = url, + mono = mono, + corners = corners, + borderColor = borderColor + ) + } + } + } + } + + HorizontalDivider(color = borderColor) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.End + ) { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) + ) { + Text( + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +private val MD5_HASH_REGEX = Regex("^[0-9a-f]{32}$") + +private fun licenseDisplayLabel(license: License): String { + license.spdxId?.takeIf { it.isNotBlank() }?.let { return it } + val hash = license.hash + if (hash.isNotBlank() && !MD5_HASH_REGEX.matches(hash)) return hash + return license.name.ifBlank { "—" } +} + +private fun openUrl(url: String) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI.create(url)) + } + } catch (e: Exception) { + Logger.error("Failed to open url: $url", e) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index daa15b8f..456b8cab 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -36,7 +36,6 @@ import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.UpdateChannelPreference import app.morphe.gui.data.repository.ConfigRepository -import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch @@ -48,7 +47,6 @@ import app.morphe.gui.ui.theme.LocalThemeState @Composable fun SettingsButton( modifier: Modifier = Modifier, - allowCacheClear: Boolean = true, isPatching: Boolean = false, onDismiss: () -> Unit = {}, /** @@ -63,7 +61,6 @@ fun SettingsButton( val modeState = LocalModeState.current val adbPreference = LocalAdbPreference.current val configRepository: ConfigRepository = koinInject() - val patchSourceManager: PatchSourceManager = koinInject() val updateCheckRepository: UpdateCheckRepository = koinInject() val scope = rememberCoroutineScope() @@ -77,6 +74,8 @@ fun SettingsButton( var keepArchitectures by remember { mutableStateOf>(emptySet()) } var collapsibleSectionStates by remember { mutableStateOf>(emptyMap()) } var updateChannelPreference by remember { mutableStateOf(UpdateChannelPreference.STABLE) } + var autoRouteLinksAfterInstall by remember { mutableStateOf(false) } + var disableStockLinksAfterInstall by remember { mutableStateOf(false) } LaunchedEffect(showSettingsDialog) { if (showSettingsDialog) { @@ -92,6 +91,8 @@ fun SettingsButton( keystoreEntryPassword = config.keystoreEntryPassword keepArchitectures = config.keepArchitectures collapsibleSectionStates = config.collapsibleSectionStates + autoRouteLinksAfterInstall = config.autoRouteLinksAfterInstall + disableStockLinksAfterInstall = config.disableStockLinksAfterInstall // Resolve the smart-default if the user has never picked a channel // (returns DEV when the running build is dev, STABLE otherwise). updateChannelPreference = configRepository.getOrInitUpdateChannelPreference( @@ -150,11 +151,7 @@ fun SettingsButton( showSettingsDialog = false onDismiss() }, - allowCacheClear = allowCacheClear, isPatching = isPatching, - onCacheCleared = { - patchSourceManager.notifyCacheCleared() - }, keystorePath = keystorePath, keystorePassword = keystorePassword, keystoreAlias = keystoreAlias, @@ -198,6 +195,16 @@ fun SettingsButton( }, autoStartAdb = adbPreference.enabled, onAutoStartAdbChange = { adbPreference.onChange(it) }, + autoRouteLinksAfterInstall = autoRouteLinksAfterInstall, + onAutoRouteLinksChange = { enabled -> + autoRouteLinksAfterInstall = enabled + scope.launch { configRepository.setAutoRouteLinksAfterInstall(enabled) } + }, + disableStockLinksAfterInstall = disableStockLinksAfterInstall, + onDisableStockLinksChange = { enabled -> + disableStockLinksAfterInstall = enabled + scope.launch { configRepository.setDisableStockLinksAfterInstall(enabled) } + }, collapsibleSectionStates = collapsibleSectionStates, onCollapsibleSectionToggle = { id, expanded -> collapsibleSectionStates = collapsibleSectionStates + (id to expanded) @@ -222,8 +229,8 @@ fun TopBarRow( verticalAlignment = Alignment.CenterVertically ) { DeviceIndicator() + ToolsButton(allowCacheClear = allowCacheClear) SettingsButton( - allowCacheClear = allowCacheClear, isPatching = isPatching, onUpdateChannelChanged = onUpdateChannelChanged, ) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index db4e5f10..388bcd08 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.unit.sp import app.morphe.engine.MorpheData import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD -import app.morphe.gui.data.constants.AppConstants +import app.morphe.engine.util.KeystoreImporter import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.ui.theme.LocalMorpheAccents @@ -58,9 +58,6 @@ import java.security.MessageDigest import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.UUID -import com.mikepenz.aboutlibraries.Libs -import com.mikepenz.aboutlibraries.entity.Library -import com.mikepenz.aboutlibraries.entity.License import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState @@ -69,19 +66,6 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.VerticalScrollbar -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import app.morphe.gui.ui.theme.MorpheAccentColors -import app.morphe.gui.ui.theme.MorpheCornerStyle -import java.net.URI @Composable fun SettingsDialog( @@ -94,9 +78,7 @@ fun SettingsDialog( useExpertMode: Boolean, onExpertModeChange: (Boolean) -> Unit, onDismiss: () -> Unit, - allowCacheClear: Boolean = true, isPatching: Boolean = false, - onCacheCleared: () -> Unit = {}, keystorePath: String? = null, keystorePassword: String? = null, keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, @@ -109,6 +91,10 @@ fun SettingsDialog( onUpdateChannelChange: (app.morphe.gui.data.model.UpdateChannelPreference) -> Unit = {}, autoStartAdb: Boolean = false, onAutoStartAdbChange: (Boolean) -> Unit = {}, + autoRouteLinksAfterInstall: Boolean = false, + onAutoRouteLinksChange: (Boolean) -> Unit = {}, + disableStockLinksAfterInstall: Boolean = false, + onDisableStockLinksChange: (Boolean) -> Unit = {}, collapsibleSectionStates: Map = emptyMap(), onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> } ) { @@ -117,10 +103,6 @@ fun SettingsDialog( val accents = LocalMorpheAccents.current val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) - var showClearCacheConfirm by remember { mutableStateOf(false) } - var showLicensesDialog by remember { mutableStateOf(false) } - var cacheCleared by remember { mutableStateOf(false) } - var cacheClearFailed by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, @@ -293,111 +275,44 @@ fun SettingsDialog( SettingsDivider(borderColor) - // ── Patched App Runtime Logs ── - PatchedAppRuntimeLogsSection( - mono = mono, + // ── Link handling ("open with") ── + SettingToggleRow( + label = "Route links to patched app", + description = "After installing via ADB, make the patched app open its supported web links instead of the browser or the stock/default app.", + checked = autoRouteLinksAfterInstall, + onCheckedChange = onAutoRouteLinksChange, accentColor = accents.primary, - borderColor = borderColor, - enabled = !isPatching, - expanded = collapsibleSectionStates["RUNTIME LOGS"] == true, - onExpandedChange = { onCollapsibleSectionToggle("RUNTIME LOGS", it) } - ) - - SettingsDivider(borderColor) - - // ── Actions ── - SectionLabel("ACTIONS", mono) - Spacer(Modifier.height(8.dp)) - - ActionButton( - label = "OPEN LOGS", - icon = Icons.Default.BugReport, mono = mono, - borderColor = borderColor, - onClick = { - try { - val logsDir = FileUtils.getLogsDir() - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(logsDir) - } - } catch (e: Exception) { - Logger.error("Failed to open logs folder", e) - } - } + enabled = !isPatching ) - - Spacer(Modifier.height(6.dp)) - - ActionButton( - label = "OPEN APP DATA", - icon = Icons.Default.FolderOpen, - mono = mono, - borderColor = borderColor, - onClick = { - try { - val appDataDir = FileUtils.getAppDataDir() - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(appDataDir) - } - } catch (e: Exception) { - Logger.error("Failed to open app data folder", e) - } + AnimatedVisibility(visible = autoRouteLinksAfterInstall) { + Column { + Spacer(Modifier.height(12.dp)) + SettingToggleRow( + label = "Disable stock app's links", + description = "Also stop the original app from opening these links (only when a " + + "rename patch was used and the stock app is installed). Reversible.", + checked = disableStockLinksAfterInstall, + onCheckedChange = onDisableStockLinksChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) } - ) + } - Spacer(Modifier.height(6.dp)) + SettingsDivider(borderColor) - ActionButton( - label = "VIEW LICENSES", - icon = Icons.Default.Description, + // ── Patched App Runtime Logs ── + PatchedAppRuntimeLogsSection( mono = mono, + accentColor = accents.primary, borderColor = borderColor, - onClick = { showLicensesDialog = true } - ) - - Spacer(Modifier.height(6.dp)) - - // Clear cache - val cacheColor = when { - cacheCleared -> MorpheColors.Teal - cacheClearFailed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.error - } - ActionButton( - label = when { - !allowCacheClear -> "CLEAR CACHE (DISABLED)" - cacheCleared -> "CACHE CLEARED" - cacheClearFailed -> "CLEAR FAILED" - else -> "CLEAR CACHE" - }, - icon = Icons.Default.Delete, - mono = mono, - borderColor = if (cacheCleared) MorpheColors.Teal.copy(alpha = 0.3f) - else MaterialTheme.colorScheme.error.copy(alpha = 0.2f), - contentColor = cacheColor, - enabled = allowCacheClear && !cacheCleared, - onClick = { showClearCacheConfirm = true } - ) - - Spacer(Modifier.height(4.dp)) - - val cacheSize = calculateCacheSize() - Text( - text = "Cache: $cacheSize (patches + logs)", - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + enabled = !isPatching, + expanded = collapsibleSectionStates["RUNTIME LOGS"] == true, + onExpandedChange = { onCollapsibleSectionToggle("RUNTIME LOGS", it) } ) - SettingsDivider(borderColor) - - // ── About ── - Text( - text = "${AppConstants.APP_NAME} ${AppConstants.APP_VERSION}", - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - ) } }, confirmButton = { @@ -417,784 +332,6 @@ fun SettingsDialog( } } ) - - // Clear cache confirmation - if (showClearCacheConfirm) { - AlertDialog( - onDismissRequest = { showClearCacheConfirm = false }, - shape = RoundedCornerShape(corners.medium), - containerColor = MaterialTheme.colorScheme.surface, - title = { - Text( - "CLEAR CACHE?", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.sp - ) - }, - text = { - Text( - "This will delete downloaded patches and log files. Patches will be re-downloaded when needed.", - fontFamily = mono, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 18.sp - ) - }, - confirmButton = { - Button( - onClick = { - val success = clearAllCache() - cacheCleared = success - cacheClearFailed = !success - showClearCacheConfirm = false - if (success) onCacheCleared() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ), - shape = RoundedCornerShape(corners.small) - ) { - Text( - "CLEAR", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - }, - dismissButton = { - TextButton(onClick = { showClearCacheConfirm = false }) { - Text( - "CANCEL", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - } - ) - } - - if (showLicensesDialog) { - LicensesDialog(onDismiss = { showLicensesDialog = false }) - } -} - -@Composable -private fun LicensesDialog(onDismiss: () -> Unit) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) - val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) - - val libs = remember { - try { - val stream = Thread.currentThread().contextClassLoader.getResourceAsStream("aboutlibraries.json") - val json = stream?.bufferedReader()?.use { it.readText() } - if (json != null) Libs.Builder().withJson(json).build() else null - } catch (e: Exception) { - Logger.error("Failed to load licenses", e) - null - } - } - - var searchQuery by remember { mutableStateOf("") } - var viewingLicense by remember { mutableStateOf(null) } - val listState = rememberLazyListState() - - val filtered = remember(libs, searchQuery) { - val all = libs?.libraries.orEmpty() - if (searchQuery.isBlank()) all - else { - val q = searchQuery.trim().lowercase() - all.filter { lib -> - lib.name.lowercase().contains(q) || - lib.uniqueId.lowercase().contains(q) || - (lib.description?.lowercase()?.contains(q) == true) || - lib.licenses.any { it.name.lowercase().contains(q) || (it.spdxId?.lowercase()?.contains(q) == true) } - } - } - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .widthIn(min = 640.dp, max = 960.dp) - .heightIn(min = 520.dp, max = 780.dp) - .fillMaxWidth(0.88f) - .fillMaxHeight(0.88f), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(corners.medium), - border = BorderStroke(1.dp, borderColor) - ) { - Column(modifier = Modifier.fillMaxSize()) { - // ── Header ── - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = "OPEN SOURCE LICENSES", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - letterSpacing = 1.8.sp, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "[${libs?.libraries?.size ?: 0}]", - fontFamily = mono, - fontSize = 11.sp, - color = accents.primary, - letterSpacing = 0.5.sp - ) - } - val closeHover = remember { MutableInteractionSource() } - val isCloseHovered by closeHover.collectIsHoveredAsState() - Box( - modifier = Modifier - .size(28.dp) - .clip(RoundedCornerShape(corners.small)) - .hoverable(closeHover) - .background( - if (isCloseHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) - else Color.Transparent - ) - .clickable(onClick = onDismiss), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = if (isCloseHovered) 0.85f else 0.55f - ), - modifier = Modifier.size(14.dp) - ) - } - } - - HorizontalDivider(color = dividerColor) - - // ── Search bar ── - Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp)) { - LicenseSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) - } - - HorizontalDivider(color = dividerColor) - - // ── List ── - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { - when { - libs == null -> EmptyHint(text = "// failed to load licenses", mono = mono, isError = true) - filtered.isEmpty() -> EmptyHint(text = "// no matches", mono = mono, isError = false) - else -> { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 22.dp, vertical = 8.dp) - ) { - items(items = filtered, key = { it.uniqueId }) { library -> - LibraryRow( - library = library, - mono = mono, - accents = accents, - corners = corners, - borderColor = borderColor, - dividerColor = dividerColor, - onLicenseClick = { viewingLicense = it } - ) - } - } - - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight() - .padding(vertical = 6.dp), - adapter = rememberScrollbarAdapter(listState), - style = morpheScrollbarStyle() - ) - } - } - } - - HorizontalDivider(color = dividerColor) - - // ── Footer ── - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (searchQuery.isBlank()) "${filtered.size} libraries" - else "${filtered.size} / ${libs?.libraries?.size ?: 0} matched", - fontFamily = mono, - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - letterSpacing = 0.8.sp - ) - OutlinedButton( - onClick = onDismiss, - shape = RoundedCornerShape(corners.small), - border = BorderStroke(1.dp, borderColor) - ) { - Text( - "CLOSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - - viewingLicense?.let { license -> - LicenseTextDialog(license = license, onDismiss = { viewingLicense = null }) - } -} - -@Composable -private fun LicenseSearchBar(query: String, onQueryChange: (String) -> Unit) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val searchFocused = remember { mutableStateOf(false) } - val searchBorderColor by animateColorAsState( - if (searchFocused.value) accents.primary.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), - animationSpec = tween(150) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .height(38.dp) - .clip(RoundedCornerShape(corners.small)) - .border(1.dp, searchBorderColor, RoundedCornerShape(corners.small)) - .padding(horizontal = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.size(16.dp) - ) - - Box(modifier = Modifier.weight(1f)) { - if (query.isEmpty()) { - Text( - text = "Search libraries, SPDX id, uniqueId…", - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) - ) - } - BasicTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - textStyle = LocalTextStyle.current.copy( - fontSize = 12.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurface - ), - cursorBrush = SolidColor(accents.primary), - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { searchFocused.value = it.isFocused } - ) - } - - if (query.isNotEmpty()) { - Box( - modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(corners.small)) - .clickable { onQueryChange("") }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.size(14.dp) - ) - } - } - } -} - -@Composable -private fun LibraryRow( - library: Library, - mono: androidx.compose.ui.text.font.FontFamily, - accents: MorpheAccentColors, - corners: MorpheCornerStyle, - borderColor: Color, - dividerColor: Color, - onLicenseClick: (License) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - val hoverInteraction = remember { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - val rotation by animateFloatAsState( - targetValue = if (expanded) 180f else 0f, - animationSpec = tween(180) - ) - val bgAlpha by animateFloatAsState( - targetValue = when { - expanded -> 0.05f - isHovered -> 0.03f - else -> 0f - }, - animationSpec = tween(180) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(corners.small)) - .hoverable(hoverInteraction) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = bgAlpha)) - .clickable { expanded = !expanded } - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = library.name, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - library.artifactVersion?.takeIf { it.isNotBlank() }?.let { v -> - Text( - text = "v$v", - fontSize = 10.sp, - fontFamily = mono, - color = accents.secondary.copy(alpha = 0.9f), - letterSpacing = 0.3.sp - ) - } - } - Spacer(Modifier.height(2.dp)) - Text( - text = library.uniqueId, - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (library.licenses.isEmpty()) { - LicenseChip( - label = "UNKNOWN", - mono = mono, - corners = corners, - accentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), - onClick = null - ) - } else { - library.licenses.forEach { license -> - LicenseChip( - label = licenseDisplayLabel(license), - mono = mono, - corners = corners, - accentColor = accents.primary, - onClick = { onLicenseClick(license) } - ) - } - } - } - - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = if (expanded) "Collapse" else "Expand", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.7f else 0.4f), - modifier = Modifier - .size(16.dp) - .graphicsLayer { rotationZ = rotation } - ) - } - - AnimatedVisibility( - visible = expanded, - enter = expandVertically(expandFrom = Alignment.Top, animationSpec = tween(200)) + - fadeIn(animationSpec = tween(200)), - exit = shrinkVertically(shrinkTowards = Alignment.Top, animationSpec = tween(180)) + - fadeOut(animationSpec = tween(140)) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, bottom = 14.dp, top = 2.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - library.description?.trim()?.takeIf { it.isNotEmpty() }?.let { desc -> - Text( - text = desc, - fontSize = 12.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), - lineHeight = 17.sp - ) - } - - val devs = library.developers.mapNotNull { it.name?.takeIf { n -> n.isNotBlank() } } - val org = library.organization?.name?.takeIf { it.isNotBlank() } - if (devs.isNotEmpty() || org != null) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (devs.isNotEmpty()) { - MetaLine(label = "AUTHORS", value = devs.joinToString(", "), mono = mono) - } - org?.let { MetaLine(label = "ORG", value = it, mono = mono) } - } - } - - val website = library.website?.takeIf { it.isNotBlank() } - val source = library.scm?.url?.takeIf { it.isNotBlank() } - if (website != null || source != null) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - website?.let { - LinkPill(label = "WEBSITE", url = it, mono = mono, corners = corners, borderColor = borderColor) - } - source?.let { - LinkPill(label = "SOURCE", url = it, mono = mono, corners = corners, borderColor = borderColor) - } - } - } - } - } - - HorizontalDivider(color = dividerColor) - } -} - -@Composable -private fun MetaLine( - label: String, - value: String, - mono: androidx.compose.ui.text.font.FontFamily, -) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = label, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), - letterSpacing = 1.sp, - modifier = Modifier.width(56.dp) - ) - Text( - text = value, - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -private fun LicenseChip( - label: String, - mono: androidx.compose.ui.text.font.FontFamily, - corners: MorpheCornerStyle, - accentColor: Color, - onClick: (() -> Unit)?, -) { - val hover = remember { MutableInteractionSource() } - val isHovered by hover.collectIsHoveredAsState() - val bg by animateColorAsState( - if (isHovered && onClick != null) accentColor.copy(alpha = 0.18f) - else accentColor.copy(alpha = 0.08f), - animationSpec = tween(140) - ) - Box( - modifier = Modifier - .hoverable(hover) - .clip(RoundedCornerShape(corners.small)) - .background(bg, RoundedCornerShape(corners.small)) - .border(1.dp, accentColor.copy(alpha = 0.35f), RoundedCornerShape(corners.small)) - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(horizontal = 7.dp, vertical = 3.dp) - ) { - Text( - text = label, - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accentColor, - letterSpacing = 0.8.sp, - maxLines = 1 - ) - } -} - -@Composable -private fun LinkPill( - label: String, - url: String, - mono: androidx.compose.ui.text.font.FontFamily, - corners: MorpheCornerStyle, - borderColor: Color, -) { - val hover = remember { MutableInteractionSource() } - val isHovered by hover.collectIsHoveredAsState() - Row( - modifier = Modifier - .hoverable(hover) - .clip(RoundedCornerShape(corners.small)) - .border( - 1.dp, - if (isHovered) borderColor.copy(alpha = 0.4f) else borderColor, - RoundedCornerShape(corners.small) - ) - .clickable { openUrl(url) } - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.9f else 0.6f), - letterSpacing = 1.sp - ) - @Suppress("DEPRECATION") - Icon( - imageVector = Icons.Default.OpenInNew, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.75f else 0.45f), - modifier = Modifier.size(10.dp) - ) - } -} - -@Composable -private fun EmptyHint(text: String, mono: androidx.compose.ui.text.font.FontFamily, isError: Boolean) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = text, - fontFamily = mono, - fontSize = 12.sp, - color = if (isError) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), - letterSpacing = 0.8.sp - ) - } -} - -@Composable -private fun LicenseTextDialog(license: License, onDismiss: () -> Unit) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) - val content = license.licenseContent?.takeIf { it.isNotBlank() } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .widthIn(min = 540.dp, max = 820.dp) - .heightIn(min = 380.dp, max = 680.dp) - .fillMaxWidth(0.78f) - .fillMaxHeight(0.82f), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(corners.medium), - border = BorderStroke(1.dp, borderColor) - ) { - Column(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - val chipLabel = licenseDisplayLabel(license) - Text( - text = chipLabel.uppercase(), - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.5.sp, - color = accents.primary - ) - if (license.name.isNotBlank() && !license.name.equals(chipLabel, ignoreCase = true)) { - Text( - text = license.name, - fontFamily = mono, - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } - Box( - modifier = Modifier - .size(28.dp) - .clip(RoundedCornerShape(corners.small)) - .clickable(onClick = onDismiss), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp) - ) - } - } - - HorizontalDivider(color = borderColor) - - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - if (content != null) { - val scrollState = rememberScrollState() - Text( - text = content, - fontSize = 11.sp, - fontFamily = mono, - lineHeight = 16.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(horizontal = 22.dp, vertical = 16.dp) - ) - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight() - .padding(vertical = 6.dp), - adapter = rememberScrollbarAdapter(scrollState), - style = morpheScrollbarStyle() - ) - } else { - Column( - modifier = Modifier.fillMaxSize().padding(22.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = "// full license text not bundled", - fontFamily = mono, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - letterSpacing = 0.5.sp - ) - license.url?.takeIf { it.isNotBlank() }?.let { url -> - Text( - text = "Open the canonical license text:", - fontFamily = mono, - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - LinkPill( - label = "OPEN LICENSE", - url = url, - mono = mono, - corners = corners, - borderColor = borderColor - ) - } - } - } - } - - HorizontalDivider(color = borderColor) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 22.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.End - ) { - OutlinedButton( - onClick = onDismiss, - shape = RoundedCornerShape(corners.small), - border = BorderStroke(1.dp, borderColor) - ) { - Text( - "CLOSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -private val MD5_HASH_REGEX = Regex("^[0-9a-f]{32}$") - -private fun licenseDisplayLabel(license: License): String { - license.spdxId?.takeIf { it.isNotBlank() }?.let { return it } - val hash = license.hash - if (hash.isNotBlank() && !MD5_HASH_REGEX.matches(hash)) return hash - return license.name.ifBlank { "—" } -} - -private fun openUrl(url: String) { - try { - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { - Desktop.getDesktop().browse(URI.create(url)) - } - } catch (e: Exception) { - Logger.error("Failed to open url: $url", e) - } } // ── Shared building blocks ── @@ -1602,53 +739,6 @@ private fun OutputFolderSection( } } -@Composable -private fun ActionButton( - label: String, - icon: androidx.compose.ui.graphics.vector.ImageVector, - mono: androidx.compose.ui.text.font.FontFamily, - borderColor: Color, - contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, - enabled: Boolean = true, - onClick: () -> Unit -) { - val corners = LocalMorpheCorners.current - val hoverInteraction = remember { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - - OutlinedButton( - onClick = onClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth().hoverable(hoverInteraction), - shape = RoundedCornerShape(corners.small), - border = BorderStroke( - 1.dp, - if (isHovered && enabled) contentColor.copy(alpha = 0.3f) - else borderColor - ), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = contentColor, - disabledContentColor = contentColor.copy(alpha = 0.4f) - ) - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(14.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - label, - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp, - modifier = Modifier.weight(1f) - ) - } -} - // ── Strip Libs Section ── @@ -1802,8 +892,41 @@ private fun SigningSection( val selected = File(dialog.directory, dialog.file) val validExtensions = listOf(".keystore", ".jks", ".bks", ".p12", ".pfx") if (validExtensions.any { selected.name.lowercase().endsWith(it) }) { - keystoreError = null - onKeystorePathChange(selected.absolutePath) + // Route the picked file through KeystoreImporter: + // BKS files pass through unchanged; PKCS12/JKS get + // converted to BKS and saved as MorpheData.importedKeystoreFile + // (original user file is never mutated). The config + // then points at whichever file is BKS — patcher + // only speaks BKS, so this is the only safe input. + val result = KeystoreImporter.ensureBks( + source = selected, + convertedOutput = MorpheData.importedKeystoreFile, + alias = keystoreAlias, + password = keystoreEntryPassword, + ) + when (result) { + is KeystoreImporter.Result.AlreadyBks -> { + keystoreError = null + onKeystorePathChange(result.file.absolutePath) + } + is KeystoreImporter.Result.Converted -> { + keystoreError = null + Logger.info( + "Converted ${result.sourceFormat.displayName} → BKS for ${selected.name}" + ) + onKeystorePathChange(result.file.absolutePath) + } + is KeystoreImporter.Result.Failed -> { + // Most common failure: wrong password. The + // user's configured entry password didn't + // match the source file. Surface inline so + // they can update it and re-import. + keystoreError = result.reason + result.cause?.let { + Logger.error("Keystore import failed for ${selected.name}", it) + } + } + } } else { keystoreError = "Invalid file type. Expected: ${validExtensions.joinToString(", ")}" } @@ -2555,45 +1678,6 @@ private fun ThemePreference.accentColor(): Color { } } -private fun calculateCacheSize(): String { - val patchesSize = FileUtils.getPatchesDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } - val logsSize = FileUtils.getLogsDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } - val totalSize = patchesSize + logsSize - - return when { - totalSize < 1024 -> "$totalSize B" - totalSize < 1024 * 1024 -> "%.1f KB".format(totalSize / 1024.0) - else -> "%.1f MB".format(totalSize / (1024.0 * 1024.0)) - } -} - -private fun clearAllCache(): Boolean { - return try { - var failedCount = 0 - FileUtils.getPatchesDir().listFiles()?.forEach { file -> - try { if (!file.deleteRecursively()) throw Exception("Could not delete") } - catch (e: Exception) { failedCount++; Logger.error("Failed to delete ${file.name}: ${e.message}") } - } - FileUtils.getLogsDir().listFiles()?.forEach { file -> - try { if (!file.deleteRecursively()) throw Exception("Could not delete") } - catch (e: Exception) { failedCount++; Logger.error("Failed to delete log ${file.name}: ${e.message}") } - } - - FileUtils.cleanupAllTempDirs() - if (failedCount > 0) { - Logger.error("Cache clear incomplete: $failedCount file(s) could not be deleted (may be locked)") - false - } else { - Logger.info("Cache cleared successfully") - true - } - } catch (e: Exception) { - Logger.error("Failed to clear cache", e) - false - } -} - - // ── Patched App Runtime Logs Section ── private sealed interface RuntimeLogsStatus { diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt index 759275d7..630ff67b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -21,7 +22,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DragIndicator import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -31,9 +35,14 @@ import androidx.compose.animation.core.tween import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.zIndex import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -43,6 +52,7 @@ import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheFont import java.io.File +import kotlin.math.roundToInt /** * Multi-source management sheet, summoned from the home header `+` button. @@ -61,6 +71,10 @@ import java.io.File */ enum class SourceSheetMode { MULTI_TOGGLE, SINGLE_SELECT } +/** 4-way move cursor shown over a source's drag handle so the grab affordance + * reads as "draggable", distinct from the plain hand used elsewhere. */ +private val DragMoveCursor = PointerIcon(java.awt.Cursor(java.awt.Cursor.MOVE_CURSOR)) + @Composable fun SourceManagementSheet( sources: List, @@ -70,6 +84,9 @@ fun SourceManagementSheet( onRemove: (id: String) -> Unit, onOpenPatches: (sourceId: String) -> Unit, onDismiss: () -> Unit, + /** Persist a new source ordering (ids in desired order). Order affects only + * the display-name tiebreak + UI presentation, not which patches load. */ + onReorder: (orderedIds: List) -> Unit = {}, enabled: Boolean = true, /** sourceId → resolved version label (e.g. "v1.27.0-dev.2"). Empty when not loaded. */ sourceVersions: Map = emptyMap(), @@ -94,6 +111,42 @@ fun SourceManagementSheet( var showAddDialog by remember { mutableStateOf(false) } var editingSource by remember { mutableStateOf(null) } + // ── Drag-to-reorder state ────────────────────────────────────────────── + // workingOrder is the live ordering the UI renders from; it reseeds only + // when the actual id sequence from config changes (List equals is + // structural), so a drag we just persisted doesn't get clobbered mid-flight. + val density = LocalDensity.current + val rowSpacingPx = with(density) { 8.dp.toPx() } + val sourcesById = remember(sources) { sources.associateBy { it.id } } + // Stable identity (no remember key) so the drag gesture's captured reference + // never goes stale. We instead adopt external order/membership changes via the + // effect below — but only while idle, so an in-flight drag is never clobbered. + var workingOrder by remember { mutableStateOf(sources.map { it.id }) } + val rowHeights = remember { mutableStateMapOf() } + var draggingId by remember { mutableStateOf(null) } + // Raw total cursor displacement since grab — never mutated mid-drag, so no + // drift accumulates. Visual offset is derived by subtracting the layout + // shift already applied via reordering (see dragOffsetY below). + var dragDeltaY by remember { mutableStateOf(0f) } + var dragStartIndex by remember { mutableStateOf(0) } + // Pull in source add/remove/rename/external-reorder — but never mid-drag, and + // only when the id sequence actually changed (keyed on the id list), so the + // order we just persisted from a drag doesn't trigger a snap-back. + LaunchedEffect(sources.map { it.id }) { + if (draggingId == null) workingOrder = sources.map { it.id } + } + val canReorder = enabled && sources.size > 1 + val orderedSources = workingOrder.mapNotNull { sourcesById[it] } + + fun commitMove(id: String, up: Boolean) { + val i = workingOrder.indexOf(id) + val target = if (up) i - 1 else i + 1 + if (i < 0 || target !in workingOrder.indices) return + val next = workingOrder.toMutableList().apply { add(target, removeAt(i)) } + workingOrder = next + onReorder(next) + } + AlertDialog( onDismissRequest = onDismiss, shape = RoundedCornerShape(corners.medium), @@ -138,7 +191,45 @@ fun SourceManagementSheet( Spacer(Modifier.height(4.dp)) - sources.forEach { source -> + orderedSources.forEachIndexed { index, source -> + // Key by source id (not list position) so a mid-drag reorder + // moves the same composable instead of rebinding slots — which + // would otherwise cancel the in-flight drag gesture. + key(source.id) { + val isDragging = source.id == draggingId + // One slot's pitch (row height + inter-row spacing). Rows vary + // slightly; the dragged row's own height is a fine unit and, + // crucially, the same value drives both the target-index pick + // and the visual offset, so they stay in lockstep. + val slotPitch = ((rowHeights[source.id] ?: 0) + rowSpacingPx).coerceAtLeast(1f) + val dragHandleModifier = if (canReorder) { + Modifier.pointerInput(source.id, canReorder) { + detectDragGestures( + onDragStart = { + draggingId = source.id + dragDeltaY = 0f + dragStartIndex = workingOrder.indexOf(source.id) + }, + onDragEnd = { draggingId = null; dragDeltaY = 0f; onReorder(workingOrder) }, + onDragCancel = { draggingId = null; dragDeltaY = 0f }, + onDrag = { change, dragAmount -> + change.consume() + dragDeltaY += dragAmount.y + val curIdx = workingOrder.indexOf(source.id) + val desired = (dragStartIndex + (dragDeltaY / slotPitch).roundToInt()) + .coerceIn(0, workingOrder.lastIndex) + if (desired != curIdx) { + workingOrder = workingOrder.toMutableList() + .apply { add(desired, removeAt(curIdx)) } + } + } + ) + } + } else Modifier + // Cursor displacement minus the layout shift already realised by + // reordering = the residual the row must translate to sit under + // the cursor. No running subtraction, so nothing drifts. + val dragOffsetY = if (isDragging) dragDeltaY - (index - dragStartIndex) * slotPitch else 0f SourceRow( source = source, version = sourceVersions[source.id], @@ -155,7 +246,18 @@ fun SourceManagementSheet( onEdit = { editingSource = source }, onRemove = { onRemove(source.id) }, onOpenPatches = { onOpenPatches(source.id) }, + canReorder = canReorder, + position = index + 1, + canMoveUp = index > 0, + canMoveDown = index < orderedSources.lastIndex, + onMoveUp = { commitMove(source.id, up = true) }, + onMoveDown = { commitMove(source.id, up = false) }, + dragHandleModifier = dragHandleModifier, + isDragging = isDragging, + dragOffsetY = dragOffsetY, + onMeasured = { h -> rowHeights[source.id] = h }, ) + } } Spacer(Modifier.height(2.dp)) @@ -251,6 +353,16 @@ private fun SourceRow( mode: SourceSheetMode, isActiveSelection: Boolean, onSelectSingle: () -> Unit, + canReorder: Boolean, + position: Int, + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + dragHandleModifier: Modifier, + isDragging: Boolean, + dragOffsetY: Float, + onMeasured: (heightPx: Int) -> Unit, ) { val corners = LocalMorpheCorners.current val hoverInteraction = remember(source.id) { MutableInteractionSource() } @@ -284,10 +396,17 @@ private fun SourceRow( Box( modifier = Modifier + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { translationY = dragOffsetY } + .onSizeChanged { onMeasured(it.height) } .fillMaxWidth() .clip(RoundedCornerShape(corners.medium)) - .border(1.dp, animatedBorder, RoundedCornerShape(corners.medium)) - .background(animatedBg) + .border( + 1.dp, + if (isDragging) accentColor.copy(alpha = 0.7f) else animatedBorder, + RoundedCornerShape(corners.medium) + ) + .background(if (isDragging) accentColor.copy(alpha = 0.10f) else animatedBg) .hoverable(hoverInteraction) .then( if (canInteract) Modifier @@ -298,6 +417,33 @@ private fun SourceRow( .padding(horizontal = 12.dp, vertical = 10.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { + if (canReorder) { + // Drag handle — the only grab point; the rest of the row stays + // click-to-open. Position number sits beside it for orientation. + Box( + modifier = Modifier + .pointerHoverIcon(DragMoveCursor) + .then(dragHandleModifier) + .size(18.dp), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.DragIndicator, + contentDescription = "Drag to reorder", + tint = if (isDragging) accentColor + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(15.dp) + ) + } + Text( + text = position.toString(), + fontSize = 10.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.padding(start = 3.dp, end = 7.dp) + ) + } // LED indicator — glows when enabled (MULTI) or selected (SINGLE). LedIndicator(isOn = isHighlighted, isHot = isHovered && canInteract, accentColor = accentColor) Spacer(Modifier.width(10.dp)) @@ -380,6 +526,17 @@ private fun SourceRow( } } + // Precise fallback to dragging — nudge one slot at a time. + if (canReorder) { + ReorderArrows( + canMoveUp = canMoveUp, + canMoveDown = canMoveDown, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + accentColor = accentColor, + ) + Spacer(Modifier.width(2.dp)) + } // Edit + delete are hidden for default; toggle is always shown if (!isDefault && enabled) { IconButton(onClick = onEdit, modifier = Modifier.size(28.dp)) { @@ -426,20 +583,73 @@ private fun SourceRow( } +/** + * Compact vertical up/down nudge control — a keyboard-free, precise fallback to + * drag reordering. Arrows dim out at the list ends. + */ +@Composable +private fun ReorderArrows( + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + accentColor: Color, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ReorderArrow(Icons.Default.KeyboardArrowUp, "Move up", canMoveUp, accentColor, onMoveUp) + ReorderArrow(Icons.Default.KeyboardArrowDown, "Move down", canMoveDown, accentColor, onMoveDown) + } +} + +@Composable +private fun ReorderArrow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + active: Boolean, + accentColor: Color, + onClick: () -> Unit, +) { + val interaction = remember { MutableInteractionSource() } + val isHovered by interaction.collectIsHoveredAsState() + Box( + modifier = Modifier + .size(width = 18.dp, height = 13.dp) + .then( + if (active) Modifier + .hoverable(interaction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + else Modifier + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = when { + !active -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + isHovered -> accentColor + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + modifier = Modifier.size(15.dp) + ) + } +} + @Composable private fun ChannelBadge( channel: app.morphe.gui.util.EnabledSourcesLoader.Channel?, mono: androidx.compose.ui.text.font.FontFamily, ) { val corners = LocalMorpheCorners.current - val accents = LocalMorpheAccents.current - val (label, color) = when (channel) { - app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_LATEST -> "STABLE LATEST" to accents.secondary - app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_OLDER -> "STABLE OLDER" to accents.warning - app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_LATEST -> "DEV LATEST" to androidx.compose.ui.graphics.Color(0xFFFFD43B) - app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_OLDER -> "DEV OLDER" to accents.warning - else -> "STABLE LATEST" to accents.secondary + val label = when (channel) { + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_LATEST -> "STABLE LATEST" + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_OLDER -> "STABLE OLDER" + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_LATEST -> "DEV LATEST" + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_OLDER -> "DEV OLDER" + else -> "STABLE LATEST" } + val color = app.morphe.gui.ui.theme.channelColor(channel) Box( modifier = Modifier .border(1.dp, color.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt index 7eb200e7..c47e5957 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.font.FontWeight @@ -36,11 +35,10 @@ import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.LocalMorpheFont -import app.morphe.gui.ui.theme.MorpheAccentColors import app.morphe.gui.util.EnabledSourcesLoader /** Per-source LED state surfaced in [SourcesCountPill]. */ -enum class SourceLedState { DISABLED, STABLE_LATEST, OLDER, DEV } +enum class SourceLedState { DISABLED, STABLE_LATEST, STABLE_OLDER, DEV_LATEST, DEV_OLDER } /** * Header pill showing source count + per-source channel LEDs + trailing "+". @@ -98,7 +96,7 @@ fun SourcesCountPill( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp), ) { - sourceStates.forEach { state -> SourceLed(state = state, accents = accents) } + sourceStates.forEach { state -> SourceLed(state = state) } } } if (interactive) { @@ -113,12 +111,13 @@ fun SourcesCountPill( } @Composable -private fun SourceLed(state: SourceLedState, accents: MorpheAccentColors) { +private fun SourceLed(state: SourceLedState) { val color = when (state) { SourceLedState.DISABLED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) - SourceLedState.STABLE_LATEST -> accents.primary - SourceLedState.OLDER -> accents.warning - SourceLedState.DEV -> Color(0xFFFFD43B) + SourceLedState.STABLE_LATEST -> app.morphe.gui.ui.theme.channelColor(EnabledSourcesLoader.Channel.STABLE_LATEST) + SourceLedState.STABLE_OLDER -> app.morphe.gui.ui.theme.channelColor(EnabledSourcesLoader.Channel.STABLE_OLDER) + SourceLedState.DEV_LATEST -> app.morphe.gui.ui.theme.channelColor(EnabledSourcesLoader.Channel.DEV_LATEST) + SourceLedState.DEV_OLDER -> app.morphe.gui.ui.theme.channelColor(EnabledSourcesLoader.Channel.DEV_OLDER) } Box( modifier = Modifier @@ -135,9 +134,9 @@ fun sourceLedState( if (!source.enabled) return SourceLedState.DISABLED return when (channel) { EnabledSourcesLoader.Channel.STABLE_LATEST -> SourceLedState.STABLE_LATEST - EnabledSourcesLoader.Channel.STABLE_OLDER -> SourceLedState.OLDER - EnabledSourcesLoader.Channel.DEV_LATEST, - EnabledSourcesLoader.Channel.DEV_OLDER -> SourceLedState.DEV + EnabledSourcesLoader.Channel.STABLE_OLDER -> SourceLedState.STABLE_OLDER + EnabledSourcesLoader.Channel.DEV_LATEST -> SourceLedState.DEV_LATEST + EnabledSourcesLoader.Channel.DEV_OLDER -> SourceLedState.DEV_OLDER // No load yet — assume latest until we know otherwise. null, EnabledSourcesLoader.Channel.UNKNOWN -> SourceLedState.STABLE_LATEST } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ToolsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/ToolsButton.kt new file mode 100644 index 00000000..0ff179ee --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/ToolsButton.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import app.morphe.gui.data.repository.PatchSourceManager +import app.morphe.gui.ui.theme.LocalMorpheCorners +import org.koin.compose.koinInject + +/** + * Tools button — peer of [SettingsButton]. Opens [ToolsDialog]. Wrench icon, + * same hover/border treatment as Settings. Sits to the LEFT of Settings in the + * top bar (actions left of preferences). + * + * @param allowCacheClear forwarded to [ToolsDialog] to gate the Clear Cache action. + */ +@Composable +fun ToolsButton( + modifier: Modifier = Modifier, + allowCacheClear: Boolean = true, +) { + val corners = LocalMorpheCorners.current + val patchSourceManager: PatchSourceManager = koinInject() + + var showToolsDialog by remember { mutableStateOf(false) } + + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + animationSpec = tween(150) + ) + + Box( + modifier = modifier + .size(34.dp) + .hoverable(hoverInteraction) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .clickable { showToolsDialog = true }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = "Tools", + tint = if (isHovered) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) + ) + } + + if (showToolsDialog) { + ToolsDialog( + onDismiss = { showToolsDialog = false }, + allowCacheClear = allowCacheClear, + onCacheCleared = { patchSourceManager.notifyCacheCleared() }, + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt new file mode 100644 index 00000000..a1a9dd15 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/ToolsDialog.kt @@ -0,0 +1,271 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.constants.AppConstants +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.engine.CacheManager +import app.morphe.gui.util.FileUtils +import app.morphe.gui.util.Logger +import java.awt.Desktop + +/** + * Tools dialog — peer of [SettingsDialog]. One-off actions (open logs, open app + * data, view licenses, clear cache) and reference info (version). Mirrors the + * [SettingsDialog] AlertDialog aesthetic. + * + * @param allowCacheClear when false the Clear Cache action is disabled (e.g. + * while patches are in use during selection). + * @param onCacheCleared invoked after a successful cache clear so hosts can + * refresh dependent state (e.g. patch source listings). + */ +@Composable +fun ToolsDialog( + onDismiss: () -> Unit, + allowCacheClear: Boolean = true, + onCacheCleared: () -> Unit = {}, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + + var showClearCacheConfirm by remember { mutableStateOf(false) } + var showLicensesDialog by remember { mutableStateOf(false) } + var cacheCleared by remember { mutableStateOf(false) } + var cacheClearFailed by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + text = "TOOLS", + fontWeight = FontWeight.Bold, + fontFamily = mono, + fontSize = 13.sp, + letterSpacing = 2.sp, + color = MaterialTheme.colorScheme.onSurface + ) + }, + text = { + Column( + modifier = Modifier.widthIn(min = 340.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + ActionButton( + label = "OPEN LOGS", + icon = Icons.Default.BugReport, + mono = mono, + borderColor = borderColor, + onClick = { + try { + val logsDir = FileUtils.getLogsDir() + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(logsDir) + } + } catch (e: Exception) { + Logger.error("Failed to open logs folder", e) + } + } + ) + + Spacer(Modifier.height(6.dp)) + + ActionButton( + label = "OPEN APP DATA", + icon = Icons.Default.FolderOpen, + mono = mono, + borderColor = borderColor, + onClick = { + try { + val appDataDir = FileUtils.getAppDataDir() + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(appDataDir) + } + } catch (e: Exception) { + Logger.error("Failed to open app data folder", e) + } + } + ) + + Spacer(Modifier.height(6.dp)) + + ActionButton( + label = "VIEW LICENSES", + icon = Icons.Default.Description, + mono = mono, + borderColor = borderColor, + onClick = { showLicensesDialog = true } + ) + + Spacer(Modifier.height(6.dp)) + + // Clear cache + val cacheColor = when { + cacheCleared -> MorpheColors.Teal + cacheClearFailed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.error + } + ActionButton( + label = when { + !allowCacheClear -> "CLEAR CACHE (DISABLED)" + cacheCleared -> "CACHE CLEARED" + cacheClearFailed -> "CLEAR FAILED" + else -> "CLEAR CACHE" + }, + icon = Icons.Default.Delete, + mono = mono, + borderColor = if (cacheCleared) MorpheColors.Teal.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.error.copy(alpha = 0.2f), + contentColor = cacheColor, + enabled = allowCacheClear && !cacheCleared, + onClick = { showClearCacheConfirm = true } + ) + + Spacer(Modifier.height(4.dp)) + + val cacheSize = calculateCacheSize() + Text( + text = "Cache: $cacheSize (patches + logs)", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + + Spacer(Modifier.height(14.dp)) + + // ── About ── + Text( + text = "${AppConstants.APP_NAME} ${AppConstants.APP_VERSION}", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + }, + confirmButton = { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) + ) { + Text( + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + + // Clear cache confirmation + if (showClearCacheConfirm) { + AlertDialog( + onDismissRequest = { showClearCacheConfirm = false }, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "CLEAR CACHE?", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Text( + "This will delete downloaded patches and log files. Patches will be re-downloaded when needed.", + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + }, + confirmButton = { + Button( + onClick = { + val success = clearAllCache() + cacheCleared = success + cacheClearFailed = !success + showClearCacheConfirm = false + if (success) onCacheCleared() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ), + shape = RoundedCornerShape(corners.small) + ) { + Text( + "CLEAR", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + }, + dismissButton = { + TextButton(onClick = { showClearCacheConfirm = false }) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) + } + + if (showLicensesDialog) { + LicensesDialog(onDismiss = { showLicensesDialog = false }) + } +} + +private fun calculateCacheSize(): String { + val patchesSize = FileUtils.getPatchesDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + val logsSize = FileUtils.getLogsDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + val totalSize = patchesSize + logsSize + + return when { + totalSize < 1024 -> "$totalSize B" + totalSize < 1024 * 1024 -> "%.1f KB".format(totalSize / 1024.0) + else -> "%.1f MB".format(totalSize / (1024.0 * 1024.0)) + } +} + +private fun clearAllCache(): Boolean { + val result = CacheManager.clearCaches() + if (result.success) { + Logger.info("Cache cleared successfully (${result.bytesFreed} bytes freed)") + } else { + Logger.error("Cache clear incomplete: ${result.failedFiles} file(s) could not be deleted (may be locked)") + } + return result.success +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 6a56799d..8e809ccd 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -85,11 +85,19 @@ import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.morpheScrollbarStyle import kotlinx.coroutines.launch import org.koin.compose.koinInject +import app.morphe.engine.model.PatchedAppRecord import app.morphe.gui.ui.screens.home.components.ApkInfoCard +import app.morphe.gui.ui.screens.home.components.AppListFilter +import app.morphe.gui.ui.screens.home.components.AppListFilterChips import app.morphe.gui.ui.screens.home.components.FullScreenDropZone +import app.morphe.gui.ui.screens.home.components.MorpheDialogButton +import app.morphe.gui.ui.screens.home.components.MorpheDialogCard +import app.morphe.gui.ui.screens.home.components.MorpheDialogText +import app.morphe.gui.ui.screens.home.components.PatchedAppDetailDialog +import app.morphe.gui.ui.screens.home.components.PatchedUpdatesBanner import app.morphe.gui.ui.screens.home.components.SupportedAppListRow +import app.morphe.gui.ui.screens.home.components.YourAppRow import app.morphe.gui.ui.components.MorpheErrorBar -import app.morphe.gui.ui.components.OfflineBanner import app.morphe.gui.ui.components.UpdateBanner import app.morphe.gui.ui.screens.patches.PatchesScreen import app.morphe.gui.ui.screens.patches.PatchSelectionScreen @@ -118,6 +126,270 @@ fun HomeScreenContent( val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() + // Device install-state is polled (adb), not streamed — so re-query each time + // Home (re)appears. Without this, an app installed on another screen (or while + // away) shows stale "NOT ON THIS DEVICE" until the next full reload. + LaunchedEffect(Unit) { viewModel.refreshDeviceInfo() } + + // One-click repatch: a patched-app row's "Re-patch" action. Jump straight to + // patch selection with the input APK + the record's saved selection, using + // the CURRENT resolved sources (so it repatches against current bundle versions). + var repatchMissingRecord by remember { mutableStateOf(null) } + // Launch patch selection for a record with explicit patch files (re-patch uses + // the current resolved set; Update passes freshly-resolved latest files). + fun launchPatch( + record: app.morphe.engine.model.PatchedAppRecord, + apkPath: String, + patchFilePaths: List, + sourceNames: List, + ) { + if (patchFilePaths.isEmpty()) return // patches not loaded yet + navigator.push( + PatchSelectionScreen( + apkPath = apkPath, + apkName = record.displayName, + patchesFilePath = patchFilePaths.first(), + packageName = record.packageName, + patchesFilePaths = patchFilePaths, + patchSourceNames = sourceNames, + initialSelectionByBundle = record.patchSelectionByBundle, + initialPatchOptions = record.patchOptionValues, + ) + ) + } + + fun repatchWithApk(record: app.morphe.engine.model.PatchedAppRecord, apkPath: String) { + launchPatch( + record, apkPath, + viewModel.getAllResolvedPatchFiles().map { it.absolutePath }, + viewModel.getAllResolvedPatchSourceNames(), + ) + } + val onRepatch: (String) -> Unit = onRepatch@{ pkg -> + val record = viewModel.getPatchedRecord(pkg) ?: return@onRepatch + if (java.io.File(record.inputApkPath).exists()) { + repatchWithApk(record, record.inputApkPath) + } else { + repatchMissingRecord = record + } + } + + // Explicit "Forget" recovery action — removes a record from the history. + var forgetConfirm by remember { mutableStateOf(null) } + val onForget: (String) -> Unit = { pkg -> forgetConfirm = viewModel.getPatchedRecord(pkg) } + forgetConfirm?.let { record -> + MorpheDialogCard(onDismiss = { forgetConfirm = null }, title = "Forget ${record.displayName}?") { + MorpheDialogText( + "This removes ${record.displayName} from your patched-app history. " + + "It doesn't touch any files — re-patching the app adds it back." + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + forgetConfirm = null + } + MorpheDialogButton("FORGET", Color(0xFFE0504D), filled = true) { + viewModel.forgetPatchedApp(record.packageName) + forgetConfirm = null + } + } + } + } + + // "Uninstall" — removes the patched app from the connected device. The dialog + // offers the keep-history vs delete-history choice via a checkbox. + var uninstallConfirm by remember { mutableStateOf(null) } + var uninstallAlsoForget by remember { mutableStateOf(false) } + val onUninstall: (String) -> Unit = { pkg -> + uninstallAlsoForget = false + uninstallConfirm = viewModel.getPatchedRecord(pkg) + } + uninstallConfirm?.let { record -> + MorpheDialogCard( + onDismiss = { uninstallConfirm = null }, + title = "Uninstall ${record.displayName}?", + ) { + MorpheDialogText( + "This removes ${record.displayName} from the connected device. " + + "The patched APK on disk and your history are kept unless you choose otherwise below." + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(LocalMorpheCorners.current.small)) + .clickable { uninstallAlsoForget = !uninstallAlsoForget } + .padding(vertical = 4.dp), + ) { + Checkbox( + checked = uninstallAlsoForget, + onCheckedChange = { uninstallAlsoForget = it }, + colors = CheckboxDefaults.colors(checkedColor = Color(0xFFE0504D)), + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + "Also remove from Your Apps", + fontSize = 12.sp, + fontFamily = LocalMorpheFont.current, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + uninstallConfirm = null + } + MorpheDialogButton("UNINSTALL", Color(0xFFE0504D), filled = true) { + viewModel.uninstallPatchedApp(record.packageName, alsoForget = uninstallAlsoForget) + uninstallConfirm = null + } + } + } + } + + repatchMissingRecord?.let { record -> + MorpheDialogCard(onDismiss = { repatchMissingRecord = null }, title = "Original APK not found") { + MorpheDialogText( + "The input APK for ${record.displayName} is no longer at:\n" + + "${record.inputApkPath}\n\nSelect the APK again to re-patch with your saved settings." + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + repatchMissingRecord = null + } + MorpheDialogButton("SELECT APK…", LocalMorpheAccents.current.primary, filled = true) { + val fd = FileDialog(null as Frame?, "Select APK to re-patch", FileDialog.LOAD) + fd.isVisible = true + val picked = fd.file?.let { File(fd.directory, it) } + repatchMissingRecord = null + if (picked != null && picked.exists()) repatchWithApk(record, picked.absolutePath) + } + } + } + } + + // Phase 7 — tap a "Your apps" row to see the full recall breakdown. + var detailRecord by remember { mutableStateOf(null) } + val onShowDetail: (PatchedAppRecord) -> Unit = { detailRecord = it } + val onUpdate: (String) -> Unit = { pkg -> + viewModel.getPatchedRecord(pkg)?.let { viewModel.prepareUpdate(it) } + } + detailRecord?.let { record -> + val updateInfo = remember(record) { viewModel.recallUpdateInfo(record) } + PatchedAppDetailDialog( + record = record, + state = uiState.patchedStates[record.packageName] ?: PatchedAppState.PATCHED, + deviceInfo = uiState.deviceAppInfo[record.packageName], + updateInfo = updateInfo, + onDismiss = { detailRecord = null }, + onRepatch = { onRepatch(record.packageName) }, + onUpdate = { viewModel.prepareUpdate(record) }, + onForget = { onForget(record.packageName) }, + onOpenFolder = { + runCatching { + val parent = java.io.File(record.outputApkPath).parentFile + if (parent != null && parent.exists()) java.awt.Desktop.getDesktop().open(parent) + } + }, + onInstall = { viewModel.installPatchedApp(record.packageName) }, + onUninstall = { onUninstall(record.packageName) }, + installing = uiState.installingPackage == record.packageName, + uninstalling = uiState.uninstallingPackage == record.packageName, + ) + } + + // ── Update flow (Phase 7, issue 2c): resolve latest → maybe pick a newer APK ── + val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current + when (val prep = uiState.updatePrep) { + is UpdatePrep.Preparing -> MorpheDialogCard( + onDismiss = { viewModel.clearUpdatePrep() }, + title = "Preparing update…", + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = LocalMorpheAccents.current.primary, + ) + Spacer(Modifier.width(12.dp)) + MorpheDialogText("Resolving the latest patches…") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + viewModel.clearUpdatePrep() + } + } + } + is UpdatePrep.Failed -> MorpheDialogCard( + onDismiss = { viewModel.clearUpdatePrep() }, + title = "Update failed", + ) { + MorpheDialogText(prep.message) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("OK", LocalMorpheAccents.current.primary, filled = true) { + viewModel.clearUpdatePrep() + } + } + } + is UpdatePrep.Ready -> { + val record = viewModel.getPatchedRecord(prep.packageName) + if (record == null) { + viewModel.clearUpdatePrep() + } else { + // Patch with the latest files using either an existing or a picked APK. + fun launchWith(apkPath: String) { + viewModel.clearUpdatePrep() + if (File(apkPath).exists()) { + launchPatch(record, apkPath, prep.patchFilePaths, prep.sourceNames) + } else { + val fd = FileDialog(null as Frame?, "Select APK to patch", FileDialog.LOAD) + fd.isVisible = true + fd.file?.let { File(fd.directory, it) }?.takeIf { it.exists() } + ?.let { launchPatch(record, it.absolutePath, prep.patchFilePaths, prep.sourceNames) } + } + } + if (!prep.needsNewerApk) { + // APK still satisfies the latest patches → patch straight away. + LaunchedEffect(prep) { launchWith(record.inputApkPath) } + } else { + val targetV = prep.targetVersion?.removePrefix("v") ?: "newer" + MorpheDialogCard( + onDismiss = { viewModel.clearUpdatePrep() }, + title = "Update ${record.displayName}", + ) { + val usedV = record.apkVersion.removePrefix("v") + MorpheDialogText( + if (prep.currentSupported) { + "The latest patches add support for a newer app version (v$targetV). " + + "You can grab it, or keep using your v$usedV — your call." + } else { + "Your v$usedV is no longer supported by the latest patches. " + + "Get v$targetV to keep patching." + } + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("USE MY APK", LocalMorpheAccents.current.secondary, filled = false) { + launchWith(record.inputApkPath) + } + MorpheDialogButton("GET v$targetV", LocalMorpheAccents.current.primary, filled = true) { + val url = prep.downloadUrl + val r = record + val files = prep.patchFilePaths + val names = prep.sourceNames + viewModel.clearUpdatePrep() + if (url != null) uriHandler.openUri(url) + val fd = FileDialog(null as Frame?, "Select the v$targetV APK", FileDialog.LOAD) + fd.isVisible = true + fd.file?.let { File(fd.directory, it) }?.takeIf { it.exists() } + ?.let { launchPatch(r, it.absolutePath, files, names) } + } + } + } + } + } + } + null -> {} + } + val patchSourceManager: PatchSourceManager = koinInject() val allSources by patchSourceManager.allSources.collectAsState() val coroutineScope = rememberCoroutineScope() @@ -176,6 +448,14 @@ fun HomeScreenContent( onRemove = { id -> coroutineScope.launch { patchSourceManager.removeSource(id) } }, + onReorder = { orderedIds -> + coroutineScope.launch { + patchSourceManager.reorderSources(orderedIds) + // Reload so the union app list + display-name tiebreak reflect + // the new source priority. + viewModel.retryLoadPatches() + } + }, onOpenPatches = { sourceId -> // Hide sheet immediately so it doesn't ride the push animation. // Mark it as pending-reopen so it returns smoothly after pop. @@ -205,21 +485,13 @@ fun HomeScreenContent( modifier = Modifier .fillMaxSize() ) { - // Side-by-side layout: drop zone / APK info on the left, vertical - // supported-apps list on the right. Falls back to top/bottom on - // narrower windows. Hysteresis (switch up at 920dp, down at 880dp) - // prevents flicker when the user resizes near the threshold. - var splitLayoutState by remember { mutableStateOf(maxWidth >= 900.dp) } - splitLayoutState = when { - maxWidth >= 920.dp -> true - maxWidth < 880.dp -> false - else -> splitLayoutState - } - val useSplitLayout = splitLayoutState - val isCompact = maxWidth < 500.dp + // Single side-by-side layout: APK drop zone on one side, supported-apps + // list on the other. The window enforces a minimum width wide enough for + // it (see GuiMain), so there's no narrow/stacked variant to maintain. + // isSmall is kept for spacing only (short windows), not a separate layout. + val isCompact = false val isSmall = maxHeight < 600.dp - val padding = if (isCompact) 16.dp else 24.dp - val outerMaxWidth = maxWidth + val padding = 24.dp // Version warning dialog state var showVersionWarningDialog by remember { mutableStateOf(false) } @@ -248,8 +520,6 @@ fun HomeScreenContent( ) } - val useHorizontalHeader = maxWidth >= 600.dp - val pinSupportedAppsToBottom = useHorizontalHeader && maxHeight >= 760.dp val patchesLoaded = !uiState.isLoadingPatches && viewModel.getCachedPatchesFile() != null val onChangePatchesClick: () -> Unit = { navigator.push(PatchesScreen( @@ -314,8 +584,9 @@ fun HomeScreenContent( val sourceStates: List = allSources.map { src -> sourceLedState(src, channelsBySource[src.id]) } - val headerContent: @Composable ColumnScope.() -> Unit = { - if (useHorizontalHeader) { + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Pinned header (not scrollable) ── HeaderBar( uiState = uiState, isSmall = isSmall, @@ -325,145 +596,10 @@ fun HomeScreenContent( onManageSourcesClick = { showSourceManagementSheet = true }, sourceStates = sourceStates, ) - } else { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) - BrandingSection(isCompact = isCompact) - - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = uiState.patchesVersion!!, - latestLabel = uiState.latestPatchesLabel, - onChangePatchesClick = onChangePatchesClick, - patchSourceName = uiState.patchSourceName, - isCompact = isCompact - ) - } else if (uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesLoadingIndicator() - } else if (uiState.patchLoadError != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = "NOT LOADED", - latestLabel = null, - onChangePatchesClick = onChangePatchesClick, - isCompact = isCompact - ) - } - - if (uiState.isOffline && !uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - OfflineBanner( - onRetry = onRetry, - modifier = Modifier - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - ) - } - } - } - - val workspaceContent: @Composable (Modifier) -> Unit = { modifier -> - Box( - modifier = modifier - .fillMaxWidth() - .padding(padding), - contentAlignment = Alignment.Center - ) { - MiddleContent( - uiState = uiState, - isCompact = isCompact, - patchesLoaded = patchesLoaded, - onClearClick = onClearClick, - onChangeClick = onChangeClick, - onContinueClick = onContinueClick, - patchSourceNames = patchSourcesForSelectedApk, - ) - } - } - - val supportedAppsContent: @Composable () -> Unit = { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding( - start = padding, - end = padding, - bottom = if (isSmall) 8.dp else 16.dp - ) - ) { - SupportedAppsSection( - isCompact = isCompact, - maxWidth = this@BoxWithConstraints.maxWidth, - isLoading = uiState.isLoadingPatches, - isDefaultSource = uiState.isDefaultSource, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = onRetry, - sourceNamesByPackage = sourceNamesByPackage, - ) - } - } - - Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { - // ── Pinned header (not scrollable) ── - if (useHorizontalHeader) { - HeaderBar( - uiState = uiState, - isSmall = isSmall, - onChangePatchesClick = onChangePatchesClick, - onRetry = onRetry, - onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, - onManageSourcesClick = { showSourceManagementSheet = true }, - sourceStates = sourceStates, - ) - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) - BrandingSection(isCompact = isCompact) - - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = uiState.patchesVersion!!, - latestLabel = uiState.latestPatchesLabel, - onChangePatchesClick = onChangePatchesClick, - isCompact = isCompact - ) - } else if (uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesLoadingIndicator() - } else if (uiState.patchLoadError != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = "NOT LOADED", - latestLabel = null, - onChangePatchesClick = onChangePatchesClick, - isCompact = isCompact - ) - } - - // Offline banner - if (uiState.isOffline && !uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - OfflineBanner( - onRetry = onRetry, - modifier = Modifier - .widthIn(max = 400.dp) - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - ) - } - } - } - // ── Body ── - if (useSplitLayout) { - // Side-by-side: drop zone / APK info on the left, - // vertical supported-apps list on the right. The list pane - // owns its own scroll; the rest stays static. - Column(modifier = Modifier.weight(1f).fillMaxWidth()) { + // ── Body: drop zone / APK info on one side, supported-apps + // list on the other. The list pane owns its own scroll. ── + Column(modifier = Modifier.weight(1f).fillMaxWidth()) { if (uiState.showUpdateBanner) { UpdateBanner( info = uiState.updateInfo!!, @@ -500,6 +636,20 @@ fun HomeScreenContent( // Left: browse/discover supported apps (wizard step 1). SupportedAppsListPane( supportedApps = uiState.supportedApps, + patchedStates = uiState.patchedStates, + patchedRecords = uiState.patchedRecords, + deviceAppInfo = uiState.deviceAppInfo, + updateInfoByPackage = uiState.updateInfoByPackage, + onRepatch = onRepatch, + onForget = onForget, + onUpdate = onUpdate, + onInstall = { viewModel.installPatchedApp(it) }, + installingPackage = uiState.installingPackage, + onUninstall = onUninstall, + uninstallingPackage = uiState.uninstallingPackage, + onShowDetail = onShowDetail, + filter = uiState.appListFilter, + onFilterChange = { viewModel.setAppListFilter(it) }, sourceNamesByPackage = sourceNamesByPackage, isLoading = uiState.isLoadingPatches, loadError = uiState.patchLoadError, @@ -537,105 +687,9 @@ fun HomeScreenContent( } } } - } - } else { - // ── Scrollable top/bottom body (narrow windows) ── - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) { - val bodyMaxHeight = this.maxHeight - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .heightIn(min = bodyMaxHeight), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = if (pinSupportedAppsToBottom) Arrangement.SpaceBetween else Arrangement.Top, - ) { - if (uiState.showUpdateBanner) { - UpdateBanner( - info = uiState.updateInfo!!, - onDismissForSession = { viewModel.dismissUpdateForSession() }, - onDismissForVersion = { viewModel.dismissUpdateForVersion() }, - modifier = Modifier - .fillMaxWidth() - .padding(start = padding, end = padding, top = 8.dp), - ) - } - if (uiState.showMultiSourceHint) { - MultiSourceHintBanner( - onDismiss = { viewModel.dismissMultiSourceHint() }, - modifier = Modifier - .fillMaxWidth() - .padding(start = padding, end = padding, top = 8.dp), - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(padding), - contentAlignment = Alignment.Center, - ) { - MiddleContent( - uiState = uiState, - isCompact = isCompact, - patchesLoaded = patchesLoaded, - onClearClick = onClearClick, - onChangeClick = onChangeClick, - onContinueClick = onContinueClick, - patchSourceNames = patchSourcesForSelectedApk, - ) - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding( - start = padding, - end = padding, - bottom = if (isSmall) 8.dp else 16.dp, - ), - ) { - SupportedAppsSection( - isCompact = isCompact, - maxWidth = outerMaxWidth, - isLoading = uiState.isLoadingPatches, - isDefaultSource = uiState.isDefaultSource, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = onRetry, - sourceNamesByPackage = sourceNamesByPackage, - ) - } - } - - if (scrollState.maxValue > 0) { - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter(scrollState), - style = morpheScrollbarStyle(), - ) - } - } } } - // Top bar — only floated when not using horizontal header - if (!useHorizontalHeader) { - TopBarRow( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = padding, end = padding), - allowCacheClear = true, - onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, - ) - } - // Error/warning bar — custom Morphe-styled, avoids Material3 // SnackbarHost (whose internal SnackbarKt invocation path the // shadow `minimize` analyzer can't trace, causing runtime @@ -1278,6 +1332,20 @@ private fun AnalyzingSection(isCompact: Boolean = false) { @Composable private fun SupportedAppsListPane( supportedApps: List, + patchedStates: Map = emptyMap(), + patchedRecords: List = emptyList(), + deviceAppInfo: Map = emptyMap(), + updateInfoByPackage: Map = emptyMap(), + onRepatch: (String) -> Unit = {}, + onForget: (String) -> Unit = {}, + onUpdate: (String) -> Unit = {}, + onInstall: (String) -> Unit = {}, + installingPackage: String? = null, + onUninstall: (String) -> Unit = {}, + uninstallingPackage: String? = null, + onShowDetail: (PatchedAppRecord) -> Unit = {}, + filter: AppListFilter = AppListFilter.ALL, + onFilterChange: (AppListFilter) -> Unit = {}, sourceNamesByPackage: Map>, isLoading: Boolean, loadError: String?, @@ -1297,6 +1365,12 @@ private fun SupportedAppsListPane( it.displayName.contains(searchQuery, ignoreCase = true) || it.packageName.contains(searchQuery, ignoreCase = true) } + val filteredRecords = if (searchQuery.isBlank()) patchedRecords + else patchedRecords.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + } + val activeCount = if (filter == AppListFilter.YOURS) patchedRecords.size else supportedApps.size // Collapse if the currently expanded app filters out. LaunchedEffect(searchQuery, filtered) { @@ -1313,34 +1387,22 @@ private fun SupportedAppsListPane( .wrapContentHeight() .align(Alignment.Center), ) { - // ── Header row: SUPPORTED APPS · count ── - // end = 12.dp matches the LazyColumn's right padding so "X apps" - // visually aligns with the right edge of the cards. - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(end = 12.dp, bottom = 4.dp), - ) { - Text( - text = "SUPPORTED APPS", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - letterSpacing = 1.5.sp, - color = homeMutedTextColor(0.4f), - ) - Spacer(Modifier.weight(1f)) - if (!isLoading && supportedApps.isNotEmpty()) { - Text( - text = "${supportedApps.size} apps", - fontSize = 9.sp, - fontFamily = mono, - color = homeMutedTextColor(0.4f), - ) - } + // ── On-open update notice: jumps to "Your apps" where each is badged ── + val updateCount = patchedStates.values.count { it == PatchedAppState.PATCHED_WITH_UPDATES } + if (filter == AppListFilter.ALL && updateCount > 0) { + PatchedUpdatesBanner(updateCount) { onFilterChange(AppListFilter.YOURS) } } + // ── Filter: ALL APPS · YOUR APPS ── + AppListFilterChips( + filter = filter, + onSelect = onFilterChange, + allCount = supportedApps.size, + yourCount = patchedRecords.size, + ) + // ── Search field ── - if (supportedApps.size > 4) { + if (activeCount > 4) { // Match the LazyColumn's right padding so the field aligns with cards. // Dp.Unspecified disables the default 340dp cap so the field fills // the pane width like the cards below it. @@ -1357,7 +1419,26 @@ private fun SupportedAppsListPane( Spacer(modifier = Modifier.height(10.dp)) } - when { + if (filter == AppListFilter.YOURS) { + YourAppsListBody( + patchedRecords = patchedRecords, + filteredRecords = filteredRecords, + searchQuery = searchQuery, + patchedStates = patchedStates, + deviceAppInfo = deviceAppInfo, + updateInfoByPackage = updateInfoByPackage, + onShowDetail = onShowDetail, + onRepatch = onRepatch, + onUpdate = onUpdate, + onForget = onForget, + onInstall = onInstall, + installingPackage = installingPackage, + onUninstall = onUninstall, + uninstallingPackage = uninstallingPackage, + paneMaxHeight = paneMaxHeight, + showSearch = activeCount > 4, + ) + } else when { isLoading -> { Column( modifier = Modifier.fillMaxWidth().padding(end = 12.dp), @@ -1458,6 +1539,8 @@ private fun SupportedAppsListPane( else app.packageName }, patchSourceNames = sourceNamesByPackage[app.packageName] ?: emptyList(), + patchedState = patchedStates[app.packageName] ?: PatchedAppState.NEVER_PATCHED, + deviceInfo = deviceAppInfo[app.packageName], ) } } @@ -1483,186 +1566,107 @@ private fun SupportedAppsListPane( } } +/** + * "Your apps" list body — the patched-app history (Phase 7). Same scroll/scrollbar + * treatment as the supported-apps list, but rows are [YourAppRow]s sourced from the + * records (not the supported-apps list), so apps patched via a since-removed source + * still appear. Tapping a row opens the detail dialog. + */ @Composable -private fun SupportedAppsSection( - isCompact: Boolean = false, - maxWidth: Dp = 800.dp, - isLoading: Boolean = false, - isDefaultSource: Boolean = true, - supportedApps: List = emptyList(), - loadError: String? = null, - onRetry: () -> Unit = {}, - /** packageName → source display names contributing patches. Used to badge - * cards with their source attribution in multi-source mode. */ - sourceNamesByPackage: Map> = emptyMap(), +private fun YourAppsListBody( + patchedRecords: List, + filteredRecords: List, + searchQuery: String, + patchedStates: Map, + deviceAppInfo: Map, + updateInfoByPackage: Map, + onShowDetail: (PatchedAppRecord) -> Unit, + onRepatch: (String) -> Unit, + onUpdate: (String) -> Unit, + onForget: (String) -> Unit, + onInstall: (String) -> Unit, + installingPackage: String?, + onUninstall: (String) -> Unit, + uninstallingPackage: String?, + paneMaxHeight: Dp, + showSearch: Boolean, ) { - val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val useVerticalLayout = maxWidth < 400.dp + when { + patchedRecords.isEmpty() -> YourAppsEmptyHint( + title = "NO PATCHED APPS YET", + subtitle = "Patch an app and it shows up here.", + mono = mono, + ) + filteredRecords.isEmpty() -> YourAppsEmptyHint( + title = "NO MATCHES", + subtitle = "Nothing matches \"$searchQuery\".", + mono = mono, + ) + else -> { + val listState = rememberLazyListState() + val headerSearchAllowance = if (showSearch) 80.dp else 34.dp + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = (paneMaxHeight - headerSearchAllowance).coerceAtLeast(120.dp)), + ) { + androidx.compose.foundation.lazy.LazyColumn( + state = listState, + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(items = filteredRecords, key = { it.packageName }) { record -> + YourAppRow( + record = record, + state = patchedStates[record.packageName] ?: PatchedAppState.PATCHED, + deviceInfo = deviceAppInfo[record.packageName], + updateInfo = updateInfoByPackage[record.packageName], + onClick = { onShowDetail(record) }, + onRepatch = { onRepatch(record.packageName) }, + onUpdate = { onUpdate(record.packageName) }, + onForget = { onForget(record.packageName) }, + onInstall = { onInstall(record.packageName) }, + installing = installingPackage == record.packageName, + onUninstall = { onUninstall(record.packageName) }, + uninstalling = uninstallingPackage == record.packageName, + ) + } + } + Box(modifier = Modifier.matchParentSize(), contentAlignment = Alignment.CenterEnd) { + VerticalScrollbar( + modifier = Modifier.fillMaxHeight(), + adapter = rememberScrollbarAdapter(listState), + style = morpheScrollbarStyle(), + ) + } + } + } + } +} +@Composable +private fun YourAppsEmptyHint(title: String, subtitle: String, mono: androidx.compose.ui.text.font.FontFamily) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), ) { Text( - text = "SUPPORTED APPS", - fontSize = if (isCompact) 10.sp else 11.sp, + text = title, + fontSize = 11.sp, fontWeight = FontWeight.Bold, fontFamily = mono, - color = homeMutedTextColor(0.7f), - letterSpacing = 3.sp + letterSpacing = 1.sp, + color = homeMutedTextColor(0.55f), ) - - Spacer(modifier = Modifier.height(6.dp)) - + Spacer(Modifier.height(6.dp)) Text( - text = if (isDefaultSource) "Download the exact version from APKMirror and drop it here." - else "Drop the APK for a supported app here.", - fontSize = if (isCompact) 10.sp else 11.sp, + text = subtitle, + fontSize = 11.sp, fontFamily = mono, - fontWeight = FontWeight.Normal, - color = homeMutedTextColor(0.5f), + color = homeMutedTextColor(0.4f), textAlign = TextAlign.Center, - modifier = Modifier - .widthIn(max = if (useVerticalLayout) 280.dp else 500.dp) - .padding(horizontal = 16.dp) ) - - Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) - - when { - isLoading -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(32.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = "Loading patches...", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.5f) - ) - } - } - loadError != null -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(16.dp) - ) { - Text( - text = "LOAD FAILED", - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.error, - letterSpacing = 1.sp - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = loadError, - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.6f), - textAlign = TextAlign.Center - ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton( - onClick = onRetry, - shape = RoundedCornerShape(corners.small), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) - ) { - Text( - "RETRY", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 1.sp - ) - } - } - } - supportedApps.isEmpty() -> { - Text( - text = "No supported apps found", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.5f) - ) - } - else -> { - val focusManager = LocalFocusManager.current - var searchQuery by remember { mutableStateOf("") } - val filteredApps = if (searchQuery.isBlank()) supportedApps - else supportedApps.filter { - it.displayName.contains(searchQuery, ignoreCase = true) || - it.packageName.contains(searchQuery, ignoreCase = true) - } - - if (supportedApps.size > 4) { - SlimSearchField( - value = searchQuery, - onValueChange = { searchQuery = it }, - mono = mono, - corners = corners, - accents = accents - ) - Spacer(modifier = Modifier.height(12.dp)) - } - - var selectedApp by remember { mutableStateOf(null) } - // Clear selection if the selected app is filtered out - LaunchedEffect(searchQuery, filteredApps) { - if (selectedApp != null && filteredApps.none { it.packageName == selectedApp?.packageName }) { - selectedApp = null - } - } - - if (filteredApps.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "No matching apps", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.3f) - ) - } - } else { - SupportedAppsMasterDetail( - apps = filteredApps, - selectedApp = selectedApp, - onSelect = { app -> - selectedApp = if (selectedApp?.packageName == app.packageName) null else app - }, - onClose = { selectedApp = null }, - isDefaultSource = isDefaultSource, - useVerticalLayout = useVerticalLayout, - sourceNamesByPackage = sourceNamesByPackage, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { focusManager.clearFocus() } - ) - } - } - } } } @@ -1694,89 +1698,6 @@ private fun homeAccentTextColor(accent: Color): Color { return accent } -@Composable -private fun PatchesVersionCard( - patchesVersion: String, - latestLabel: String?, - onChangePatchesClick: () -> Unit, - patchSourceName: String? = null, - isCompact: Boolean = false, - modifier: Modifier = Modifier -) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - val hoverInteraction = remember { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - val borderColor by animateColorAsState( - if (isHovered) accents.primary.copy(alpha = 0.4f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), - animationSpec = tween(200) - ) - - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(corners.medium)) - .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) - .background(MaterialTheme.colorScheme.surface) - .hoverable(hoverInteraction) - .clickable(onClick = onChangePatchesClick) - ) { - // Source name + version + badge — single row - Row( - modifier = Modifier - .padding(vertical = if (isCompact) 8.dp else 10.dp) - .padding(start = 12.dp, end = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = patchSourceName?.uppercase() ?: "PATCHES", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - letterSpacing = 1.5.sp - ) - Text( - text = " · ", - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) - ) - Text( - text = patchesVersion, - fontSize = if (isCompact) 12.sp else 13.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = mono, - color = accents.primary - ) - if (latestLabel != null) { - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = Modifier - .background(accents.secondary.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) - .border(1.dp, accents.secondary.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text( - text = latestLabel, - fontSize = 8.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accents.secondary, - letterSpacing = 1.sp - ) - } - } - } - } - } -} - @Composable private fun VersionWarningDialog( versionStatus: VersionStatus, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 3ee85b97..039bc2dd 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -7,8 +7,13 @@ package app.morphe.gui.ui.screens.home import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import app.morphe.engine.MorpheData +import app.morphe.engine.PatchedAppStore import app.morphe.engine.UpdateInfo +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.util.SignatureIdentity import app.morphe.gui.data.model.Patch +import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository @@ -20,10 +25,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import app.morphe.engine.util.ApkManifestReader +import app.morphe.gui.util.AdbManager +import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger @@ -39,6 +48,8 @@ class HomeViewModel( private val patchService: PatchService, private val configRepository: ConfigRepository, private val updateCheckRepository: UpdateCheckRepository, + private val patchedAppStore: PatchedAppStore, + private val adbManager: AdbManager = AdbManager(), ) : ScreenModel { private var patchRepository: PatchRepository = patchSourceManager.getActiveRepositorySync() @@ -82,9 +93,28 @@ class HomeViewModel( updateInfo = info, dismissedUpdateVersion = dismissed, showMultiSourceHint = multiSourceShouldShow, + appListFilter = runCatching { + app.morphe.gui.ui.screens.home.components.AppListFilter.valueOf(config.homeAppListFilter) + }.getOrDefault(app.morphe.gui.ui.screens.home.components.AppListFilter.ALL), ) } + // React to history changes (a patch just completed, a record forgotten) + // so badges + device state update immediately — no leave-and-return needed. + screenModelScope.launch { + patchedAppStore.changes.collect { refreshPatchedState() } + } + + // Optional device layer: when the selected ADB device changes (connect, + // disconnect, authorize), refresh which patched apps are installed on it. + // distinctUntilChanged on (id, ready) avoids re-querying on noisy emits. + screenModelScope.launch { + DeviceMonitor.state + .map { it.selectedDevice?.id to (it.selectedDevice?.isReady == true) } + .distinctUntilChanged() + .collect { refreshDeviceInfo() } + } + // Load patches whenever EXPERT becomes the active mode. StateFlow // emits its current value on subscribe, so this also covers the // "VM was just created while EXPERT is active" case — replaces the @@ -167,6 +197,140 @@ class HomeViewModel( } } + /** + * Begin an "Update" for [record]: resolve the LATEST patch files (ignoring any + * pinned version — this run only, leaving global config untouched), then work + * out whether the user's patched APK version still satisfies what the latest + * patches target. Result lands in [HomeUiState.updatePrep] for the screen to act on. + */ + fun prepareUpdate(record: PatchedAppRecord) { + _uiState.value = _uiState.value.copy(updatePrep = UpdatePrep.Preparing(record.packageName)) + screenModelScope.launch { + try { + val enabled = patchSourceManager.getEnabledRepositories() + // emptyMap() preferred versions → each source resolves to its latest + // release (the pin override is scoped to this call; config is untouched). + val result = EnabledSourcesLoader.loadAll(enabled, patchService, emptyMap()) + val resolvedOk = result.resolved.filter { it.patchFile != null } + val files = resolvedOk.mapNotNull { it.patchFile?.absolutePath } + if (files.isEmpty()) { + _uiState.value = _uiState.value.copy( + updatePrep = UpdatePrep.Failed(record.packageName, "Couldn't resolve the latest patches (offline?)."), + ) + return@launch + } + val names = resolvedOk.map { it.source.name } + val apps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + // Use the LATEST patch's supported versions to pick the channel-appropriate + // target — so a newer experimental app version a newer patch introduces is + // offered, even though the old version has rolled off the experimental list. + val app = apps.find { it.packageName == record.packageName } + val (target, _) = suggestedAppVersion(app, record.apkVersion) + val needsNewerApk = isNewerVersion(target, record.apkVersion) + val currentSupported = app == null || app.recommendedVersion == null || + app.supportedVersions.any { it.equals(record.apkVersion, ignoreCase = true) } || + app.experimentalVersions.any { it.equals(record.apkVersion, ignoreCase = true) } + val downloadUrl = if (needsNewerApk && target != null && app != null) { + app.let { SupportedApp.getDownloadUrl(it.packageName, target) } + } else null + _uiState.value = _uiState.value.copy( + updatePrep = UpdatePrep.Ready( + packageName = record.packageName, + patchFilePaths = files, + sourceNames = names, + targetVersion = target, + needsNewerApk = needsNewerApk, + currentSupported = currentSupported, + downloadUrl = downloadUrl, + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + updatePrep = UpdatePrep.Failed(record.packageName, e.message ?: "Update preparation failed"), + ) + } + } + } + + fun clearUpdatePrep() { + if (_uiState.value.updatePrep != null) _uiState.value = _uiState.value.copy(updatePrep = null) + } + + /** + * Install the already-patched output APK for [packageName] onto the selected + * device (no re-patch needed). On completion, refresh the device layer so the + * "install pending" badge clears the moment the device reports the new version. + */ + fun installPatchedApp(packageName: String) { + val record = patchedRecordsByPackage[packageName] ?: return + val device = DeviceMonitor.state.value.selectedDevice ?: return + if (!device.isReady || _uiState.value.installingPackage != null) return + _uiState.value = _uiState.value.copy(installingPackage = packageName) + screenModelScope.launch { + // Always record a non-Play installer so the Play Store won't clobber + // the patched app with an official update. + val installer = adbManager.resolveSpoofInstaller(device.id) + val result = adbManager.installApk(record.outputApkPath, device.id, installerPackage = installer) + + // Mirror ResultScreen: if the user opted into auto-routing links, + // point the patched app at its web links right after a good install. + if (result.isSuccess) { + val config = configRepository.loadConfig() + if (config.autoRouteLinksAfterInstall) { + adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = record.installedPackageName, + stockPackage = if (config.disableStockLinksAfterInstall) record.packageName else null, + enable = true, + ) + } + } + + _uiState.value = _uiState.value.copy( + installingPackage = null, + error = result.exceptionOrNull()?.let { "Install failed: ${it.message}" } ?: _uiState.value.error, + ) + refreshDeviceInfo() + } + } + + /** + * Uninstall the patched app for [packageName] from the selected device. When + * [alsoForget] is true, the recall record is removed afterward (uninstall + + * delete history); otherwise the record is kept (uninstall + keep history) so + * the card stays as a not-installed entry the user can re-install/re-patch. + * + * Removing through Morphe (vs the launcher) keeps our device-state tracking + * accurate — [refreshDeviceInfo] runs on completion so the card flips to + * not-installed immediately. + */ + fun uninstallPatchedApp(packageName: String, alsoForget: Boolean) { + val record = patchedRecordsByPackage[packageName] ?: return + val device = DeviceMonitor.state.value.selectedDevice ?: return + if (!device.isReady || _uiState.value.uninstallingPackage != null) return + _uiState.value = _uiState.value.copy(uninstallingPackage = packageName) + screenModelScope.launch { + val result = adbManager.uninstallApk(record.installedPackageName, device.id) + if (result.isSuccess && alsoForget) { + patchedAppStore.delete(packageName) + } + _uiState.value = _uiState.value.copy( + uninstallingPackage = null, + error = result.exceptionOrNull()?.let { "Uninstall failed: ${it.message}" } ?: _uiState.value.error, + ) + refreshDeviceInfo() + } + } + + /** Switch the home apps tab (ALL/YOURS) and remember it for next launch. */ + fun setAppListFilter(filter: app.morphe.gui.ui.screens.home.components.AppListFilter) { + if (_uiState.value.appListFilter == filter) return + _uiState.value = _uiState.value.copy(appListFilter = filter) + screenModelScope.launch { configRepository.setHomeAppListFilter(filter.name) } + } + /** * Hide the update banner persistently for the current available version. * The banner will reappear automatically when an even newer version becomes @@ -184,7 +348,7 @@ class HomeViewModel( private var lastLoadedVersion: String? = null // Snapshot of per-source pinned versions used in the last load — drives // refreshPatchesIfNeeded so we reload when ANY source's pin changes. - private var lastLoadedVersionsBySource: Map = emptyMap() + private var lastLoadedVersionsBySource: Map = emptyMap() /** * Load patches from all enabled sources via [EnabledSourcesLoader] and build @@ -209,9 +373,9 @@ class HomeViewModel( // Per-source pinned versions (with one-time migration from legacy // single-source field). Each source's resolver looks up its own pin; // no cross-source contamination. - val preferredVersions = configRepository.getLastPatchesVersionsBySource() - lastLoadedVersionsBySource = preferredVersions - val result = EnabledSourcesLoader.loadAll(enabled, patchService, preferredVersions) + val prefs = configRepository.getSourceVersionPrefs() + lastLoadedVersionsBySource = prefs + val result = EnabledSourcesLoader.loadAll(enabled, patchService, prefs) if (!result.anyLoaded) { val firstError = result.resolved.firstNotNullOfOrNull { it.error } @@ -260,17 +424,24 @@ class HomeViewModel( "${result.resolved.count { it.patchFile != null }} sources" } + val patchedStates = computePatchedStates(supportedApps) + latestResolvedApps = null // fresh load — drop any stale eager-resolved apps _uiState.value = _uiState.value.copy( isLoadingPatches = false, isOffline = isOffline, supportedApps = supportedApps, + patchedStates = patchedStates, + patchedRecords = sortedPatchedRecords(), + updateInfoByPackage = buildUpdateInfoMap(supportedApps), patchesVersion = displayVersion, latestPatchesVersion = displayVersion, latestDevPatchesVersion = null, patchSourceName = sourceName, patchLoadError = null ) + refreshDeviceInfo() // records just (re)loaded — refresh the optional device layer reanalyzeSelectedApk() + eagerlyResolveLatestApps() // upgrade update-info to the LATEST patch's app versions } catch (e: CancellationException) { // Cancellation is normal coroutine bookkeeping (a newer load // superseded this one, or the screen left composition). Do NOT @@ -287,6 +458,298 @@ class HomeViewModel( } } + /** + * Cross-reference the patched-app history with the supported-apps list to + * compute a per-package recall state for home-screen badges. v1 distinguishes + * "never patched / patched / patched-but-output-APK-missing"; "update + * available" detection is a later phase. Best-effort — failures yield no badges. + */ + /** Last-loaded patched-app records, keyed by package. Powers one-click repatch. */ + private var patchedRecordsByPackage: Map = emptyMap() + + /** The patched-app record for [packageName], or null if never patched. */ + fun getPatchedRecord(packageName: String): PatchedAppRecord? = + patchedRecordsByPackage[packageName] + + /** + * Compute per-source patch-file freshness + app-version freshness for [record], + * comparing the snapshot it was patched with against the currently resolved + * sources and the supported app's recommended/experimental versions. The app + * suggestion stays in the channel the user patched on (stable vs experimental). + */ + fun recallUpdateInfo(record: PatchedAppRecord): RecallUpdateInfo = + recallUpdateInfo(record, _uiState.value.supportedApps) + + /** All records → their update info; precomputed for the list/cards (avoids + * recomputing per recomposition). [apps] passed explicitly so it can be built + * from a freshly-loaded list before it lands in uiState. */ + private fun buildUpdateInfoMap(apps: List): Map = + patchedRecordsByPackage.values.associate { it.packageName to recallUpdateInfo(it, apps) } + + // supportedApps parsed from the LATEST patches (eagerly resolved when a newer + // patch exists), so the UI shows the real future app version without tapping Update. + private var latestResolvedApps: List? = null + + /** + * When a newer patch than the loaded one exists, resolve+download the latest + * patches in the background, parse their supported app versions, and rebuild + * [HomeUiState.updateInfoByPackage] against them — so the card/dialog can show + * "App vX → vY" up front. Best-effort; failures keep the loaded-patch info. + */ + private fun eagerlyResolveLatestApps() { + val anyBehind = cachedSourcesResult?.resolved?.any { + it.patchFile != null && it.resolvedVersion != null && + isNewerVersion(it.latestAvailableVersion ?: it.resolvedVersion, it.resolvedVersion) + } == true + if (!anyBehind || patchedRecordsByPackage.isEmpty()) return + screenModelScope.launch { + try { + val enabled = patchSourceManager.getEnabledRepositories() + val result = EnabledSourcesLoader.loadAll(enabled, patchService, emptyMap()) + val apps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + latestResolvedApps = apps + _uiState.value = _uiState.value.copy(updateInfoByPackage = buildUpdateInfoMap(apps)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.error("Eager latest-patch resolve failed", e) + } + } + } + + private fun recallUpdateInfo( + record: PatchedAppRecord, + apps: List, + ): RecallUpdateInfo { + val resolvedBySource = resolvedVersionBySource() // what Re-patch will use right now + val latestBySource = latestAvailableBySource() // newest available (may need downloading) + val sources = record.sourcesSnapshot + // Only sources that actually contributed patches. The selection map has an + // (empty) entry per enabled bundle, so an enabled-but-unused source has an + // empty set → drop it. Null = key mismatch/old record → keep (don't hide). + .filter { snap -> + val sel = record.patchSelectionByBundle[snap.sourceName] + sel == null || sel.isNotEmpty() + } + .map { snap -> + val latest = latestBySource[snap.sourceName] + RecallUpdateInfo.SourceUpdate( + name = snap.sourceName, + usedVersion = snap.version, + resolvedVersion = resolvedBySource[snap.sourceName], + latestAvailableVersion = latest, + outdated = isNewerVersion(latest, snap.version), + ) + } + val app = apps.find { it.packageName == record.packageName } + val used = record.apkVersion + val (suggested, channel) = suggestedAppVersion(app, used) + val latestStable = app?.recommendedVersion + // Supported if the patch targets any version (recommendedVersion null), or the + // used version is in its stable/experimental lists. Unknown app → assume yes. + val usedSupported = app == null || app.recommendedVersion == null || + app.supportedVersions.any { it.equals(used, ignoreCase = true) } || + app.experimentalVersions.any { it.equals(used, ignoreCase = true) } + return RecallUpdateInfo( + sources = sources, + appUsedVersion = used, + appChannel = channel, + appSuggestedVersion = suggested, + appOutdated = isNewerVersion(suggested, used), + appUsedSupported = usedSupported, + latestStableVersion = latestStable, + stableUpdateAvailable = isNewerVersion(latestStable, used), + ) + } + + /** + * The app version a re-patch should aim for, staying on the channel the user + * patched on. **Experimental track** = the patched version is in the experimental + * list OR is already newer than the latest stable (i.e. they're ahead of stable). + * Returns (targetVersion, channel). + * + * Crucially this keys off the channel, not exact membership of the OLD version in + * the NEW patch's lists — so when a newer patch introduces a newer experimental + * app version (e.g. patch 1.30 adds YouTube 21.21.80) it's still suggested even + * though the user's 21.20.400 has rolled off the experimental list. + */ + private fun suggestedAppVersion( + app: app.morphe.gui.data.model.SupportedApp?, + used: String, + ): Pair { + if (app == null) return null to RecallUpdateInfo.AppChannel.UNKNOWN + val latestStable = app.recommendedVersion + val latestExperimental = app.experimentalVersions.firstOrNull() + val onExperimental = app.experimentalVersions.any { it.equals(used, ignoreCase = true) } || + (latestStable != null && isNewerVersion(used, latestStable)) + return if (onExperimental) { + (latestExperimental ?: latestStable) to RecallUpdateInfo.AppChannel.EXPERIMENTAL + } else { + (latestStable ?: latestExperimental) to RecallUpdateInfo.AppChannel.STABLE + } + } + + /** + * Explicitly remove [packageName] from the patched-app history and refresh + * the badges. The only way a record leaves the store — we never auto-delete. + * Touches no files; re-patching the app recreates the record. + */ + fun forgetPatchedApp(packageName: String) { + // delete() emits a change → the store observer refreshes badges/device state. + screenModelScope.launch { patchedAppStore.delete(packageName) } + } + + /** + * Recompute badges + device state from the current store contents, reusing the + * already-loaded supported-apps list. Cheap (reads the in-memory store cache) — + * this is the live-refresh path, distinct from a full patches reload. + */ + private fun refreshPatchedState() { + screenModelScope.launch { + val states = computePatchedStates(_uiState.value.supportedApps) + _uiState.value = _uiState.value.copy( + patchedStates = states, + patchedRecords = sortedPatchedRecords(), + // Reuse the eagerly-resolved latest apps if we have them, so a store + // change (patch/forget) doesn't drop the accurate future versions. + updateInfoByPackage = buildUpdateInfoMap(latestResolvedApps ?: _uiState.value.supportedApps), + ) + refreshDeviceInfo() + } + } + + /** The history as a list, most-recently-patched first (for the "Your apps" surface). */ + private fun sortedPatchedRecords(): List = + patchedRecordsByPackage.values.sortedByDescending { it.patchedAt } + + /** source name → version currently resolved/downloaded (what Re-patch uses now). */ + private fun resolvedVersionBySource(): Map = + cachedSourcesResult?.resolved + ?.filter { it.patchFile != null } + ?.associate { it.source.name to it.resolvedVersion } + ?: emptyMap() + + /** source name → newest available version (falls back to resolved when unknown/offline). */ + private fun latestAvailableBySource(): Map = + cachedSourcesResult?.resolved + ?.filter { it.patchFile != null } + ?.associate { it.source.name to (it.latestAvailableVersion ?: it.resolvedVersion) } + ?: emptyMap() + + private suspend fun computePatchedStates( + apps: List, + ): Map = try { + val records = patchedAppStore.getAll().associateBy { it.packageName } + patchedRecordsByPackage = records + // Compare each record's patch-time snapshot against the LATEST AVAILABLE + // source version (not just what's currently downloaded) so "update + // available" surfaces without the user first selecting the newer file. + val latestBySource = latestAvailableBySource() + apps.associate { app -> + val record = records[app.packageName] + val output = record?.let { File(it.outputApkPath) } + // "Update available" = a newer patch-source version (vs the snapshot) OR a + // newer recommended stable app version than what was patched. Either is + // worth re-patching, so both surface the same badge/notification. + val sourceUpdate = record?.hasAvailableUpdate(latestBySource) == true + val appUpdate = record != null && + app.recommendedVersion?.let { isNewerVersion(it, record.apkVersion) } == true + app.packageName to when { + record == null -> PatchedAppState.NEVER_PATCHED + output?.exists() != true -> PatchedAppState.APK_MISSING + // Cheap integrity check: a re-signed/re-built APK changes size. + // (The stored sha256 is kept for certain on-demand + device verify.) + record.outputApkSize > 0 && output.length() != record.outputApkSize -> + PatchedAppState.MODIFIED_EXTERNALLY + sourceUpdate || appUpdate -> PatchedAppState.PATCHED_WITH_UPDATES + else -> PatchedAppState.PATCHED + } + } + } catch (e: Exception) { + Logger.error("Failed to compute patched-app states", e) + emptyMap() + } + + /** + * Refresh the optional device layer: for each patched record, ask the + * connected device whether it's installed and at what version. Reliable + + * version-robust (`pm list packages` / `versionName=`). No device / not + * ready → clears the info (the offline JSON view stands on its own). + */ + fun refreshDeviceInfo() { + screenModelScope.launch { + val device = DeviceMonitor.state.value.selectedDevice + if (device == null || !device.isReady) { + if (_uiState.value.deviceAppInfo.isNotEmpty()) { + _uiState.value = _uiState.value.copy(deviceAppInfo = emptyMap()) + } + return@launch + } + val records = patchedRecordsByPackage.values + if (records.isEmpty()) return@launch + val installed = adbManager.listInstalledPackages(device.id).getOrNull() ?: return@launch + val ourSignatureIds = morpheSignatureIds() + // Keyed by ORIGINAL package (matches the supported-apps row lookup), but + // queried by the INSTALLED package (post-rename) so renamed apps match. + val info = records.associate { record -> + val devicePkg = record.installedPackageName + val outputExists = File(record.outputApkPath).exists() + record.packageName to if (devicePkg !in installed) { + // Not on device — but the patched APK is on disk, so it can be installed. + DeviceAppInfo(installed = false, installedVersion = null, installPending = outputExists) + } else { + val (version, sigId) = adbManager.getInstalledPackageInfo(device.id, devicePkg) ?: (null to null) + val signed = if (sigId == null || ourSignatureIds.isEmpty()) null else sigId in ourSignatureIds + // Device is behind the version we already patched → install pending. + val pending = outputExists && version != null && isNewerVersion(record.apkVersion, version) + DeviceAppInfo(installed = true, installedVersion = version, signedByMorphe = signed, installPending = pending) + } + } + _uiState.value = _uiState.value.copy(deviceAppInfo = info) + } + } + + /** + * Signature ids of Morphe's signing certs — the shared default keystore plus + * the user's configured keystore (if any). An installed app whose device + * signature id is in this set was signed by Morphe. + */ + private suspend fun morpheSignatureIds(): Set = buildSet { + SignatureIdentity.idForKeystore( + MorpheData.defaultKeystoreFile, + storePassword = null, + alias = app.morphe.engine.PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS, + )?.let { add(it) } + val config = configRepository.loadConfig() + config.resolvedKeystorePath()?.let { ks -> + SignatureIdentity.idForKeystore(ks, config.keystorePassword, config.keystoreAlias)?.let { add(it) } + } + } + + /** True if any source the app was patched with now resolves to a newer version. */ + private fun PatchedAppRecord.hasAvailableUpdate(currentVersionBySource: Map): Boolean = + sourcesSnapshot.any { snap -> isNewerVersion(currentVersionBySource[snap.sourceName], snap.version) } + + /** + * Coarse "is [current] newer than [baseline]" — tolerant of `v` prefixes and + * `-dev`/prerelease suffixes (compares the numeric x.y.z core). Update + * detection accepts a few false positives, so exact prerelease ordering + * isn't needed; missing/"unknown" versions never flag an update. + */ + private fun isNewerVersion(current: String?, baseline: String?): Boolean { + if (current.isNullOrBlank() || baseline.isNullOrBlank()) return false + if (current.equals("unknown", true) || baseline.equals("unknown", true)) return false + fun core(v: String) = v.trim().removePrefix("v").removePrefix("V") + .substringBefore('-') + .split('.').map { it.toIntOrNull() ?: 0 } + val c = core(current); val b = core(baseline) + for (i in 0 until maxOf(c.size, b.size)) { + val cv = c.getOrElse(i) { 0 }; val bv = b.getOrElse(i) { 0 } + if (cv != bv) return cv > bv + } + return false + } + /** * Snapshot of the most recent multi-source load. Used by 9d's * PatchSelectionViewModel migration to render badged per-source patches. @@ -318,7 +781,7 @@ class HomeViewModel( */ fun refreshPatchesIfNeeded() { screenModelScope.launch { - val saved = configRepository.getLastPatchesVersionsBySource() + val saved = configRepository.getSourceVersionPrefs() if (saved != lastLoadedVersionsBySource) { Logger.info("Patches versions changed across sources: $lastLoadedVersionsBySource -> $saved, reloading...") loadPatchesAndSupportedApps(forceRefresh = true) @@ -665,6 +1128,90 @@ class HomeViewModel( // compareVersions and VersionStatus moved to app.morphe.gui.util.VersionUtils } +/** Home-screen recall state per supported app (drives the row badge). */ +enum class PatchedAppState { + NEVER_PATCHED, + PATCHED, + PATCHED_WITH_UPDATES, + /** Output APK present but no longer matches what Morphe produced (changed outside Morphe). */ + MODIFIED_EXTERNALLY, + APK_MISSING, +} + +/** + * Update guidance for a patched app's detail view: per-source patch-file freshness + * plus app-version freshness within the channel the user patched on (stable vs + * experimental). Drives the "newer version available — re-patch" hints. + */ +data class RecallUpdateInfo( + val sources: List, + val appUsedVersion: String, + val appChannel: AppChannel, + /** Latest version in [appChannel], or null if unknown. */ + val appSuggestedVersion: String?, + val appOutdated: Boolean, + /** Whether the patched app version is still supported by the evaluated patch. */ + val appUsedSupported: Boolean = true, + /** Latest stable app version the evaluated patch supports, if any. */ + val latestStableVersion: String? = null, + /** A later STABLE version exists than what was patched (recommended to take, + * regardless of which channel the user is on). */ + val stableUpdateAvailable: Boolean = false, +) { + data class SourceUpdate( + val name: String, + /** Version this app was patched with (from the record snapshot). */ + val usedVersion: String, + /** Version currently resolved/downloaded — what a plain Re-patch will use. */ + val resolvedVersion: String?, + /** Newest available version (an "Update" would move to this). */ + val latestAvailableVersion: String?, + /** True when [latestAvailableVersion] is newer than [usedVersion]. */ + val outdated: Boolean, + ) + + enum class AppChannel { STABLE, EXPERIMENTAL, UNKNOWN } +} + +/** + * Async state for the "Update" action: resolve the LATEST patch files (ignoring + * any pin, for this run only), then decide whether the user's APK still satisfies + * what the latest patches target. The screen reacts to each state. + */ +sealed interface UpdatePrep { + val packageName: String + + data class Preparing(override val packageName: String) : UpdatePrep + data class Failed(override val packageName: String, val message: String) : UpdatePrep + data class Ready( + override val packageName: String, + /** Latest resolved patch-file paths to patch with. */ + val patchFilePaths: List, + val sourceNames: List, + /** App version the latest patches recommend (channel-aware), if known. */ + val targetVersion: String?, + /** True when [targetVersion] is newer than the version the user patched. */ + val needsNewerApk: Boolean, + /** Whether the user's current APK version is still supported by the latest + * patch (→ "your call" wording vs "no longer supported"). */ + val currentSupported: Boolean, + /** Download link for [targetVersion] (supported-apps style), if applicable. */ + val downloadUrl: String?, + ) : UpdatePrep +} + +/** What the connected device reports about a patched app (optional device layer). */ +data class DeviceAppInfo( + val installed: Boolean, + val installedVersion: String?, + /** true = installed copy is Morphe-signed; false = re-signed/replaced externally; + * null = couldn't determine (unrecognised dumpsys format / no keystore). */ + val signedByMorphe: Boolean? = null, + /** The patched output APK is newer than what's on the device (or not installed at + * all) and exists on disk — so it can be installed without re-patching. */ + val installPending: Boolean = false, +) + data class HomeUiState( val selectedApk: File? = null, val apkInfo: ApkInfo? = null, @@ -677,6 +1224,23 @@ data class HomeUiState( val isOffline: Boolean = false, val isDefaultSource: Boolean = true, val supportedApps: List = emptyList(), + /** Per-package recall state for home-screen badges. */ + val patchedStates: Map = emptyMap(), + /** Patched-app history, most-recent-first — drives the "Your apps" surface. */ + val patchedRecords: List = emptyList(), + /** Per-package update info (patch-file + app freshness) for the list/cards. */ + val updateInfoByPackage: Map = emptyMap(), + /** Which home apps tab is active (ALL/YOURS); restored from config on launch. */ + val appListFilter: app.morphe.gui.ui.screens.home.components.AppListFilter = + app.morphe.gui.ui.screens.home.components.AppListFilter.ALL, + /** In-flight "Update" preparation (resolve latest → decide APK), or null. */ + val updatePrep: UpdatePrep? = null, + /** Package currently being installed to the device from its stored output APK. */ + val installingPackage: String? = null, + /** Package currently being uninstalled from the device. */ + val uninstallingPackage: String? = null, + /** Per-package device install info (optional layer; empty when no device connected). */ + val deviceAppInfo: Map = emptyMap(), val patchesVersion: String? = null, val latestPatchesVersion: String? = null, val latestDevPatchesVersion: String? = null, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt index e3ee6afe..fee6e345 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt @@ -37,7 +37,10 @@ import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import app.morphe.gui.ui.screens.home.PatchedAppState +import app.morphe.gui.ui.theme.MorpheColors import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -64,6 +67,11 @@ fun SupportedAppListRow( /** Source display names whose patches target [app.packageName]. Rendered as * the FROM chips inside the expanded body. Empty hides the FROM section. */ patchSourceNames: List = emptyList(), + /** Recall state — renders a badge in the header for PATCHED / APK_MISSING. */ + patchedState: PatchedAppState = PatchedAppState.NEVER_PATCHED, + /** Optional device-layer info (installed? + version). Null = no device / not patched. + * Recall ACTIONS (Re-patch/Forget) live on the "Your apps" card, not here. */ + deviceInfo: app.morphe.gui.ui.screens.home.DeviceAppInfo? = null, modifier: Modifier = Modifier, ) { val corners = LocalMorpheCorners.current @@ -145,6 +153,10 @@ fun SupportedAppListRow( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + if (patchedState != PatchedAppState.NEVER_PATCHED) { + Spacer(Modifier.width(8.dp)) + PatchedStateBadge(patchedState, mono) + } } // ── Row 2: STABLE LATEST + EXPERIMENTAL LATEST chips ── @@ -183,17 +195,73 @@ fun SupportedAppListRow( exit = shrinkVertically(animationSpec = tween(180), shrinkTowards = Alignment.Top) + fadeOut(animationSpec = tween(120)), ) { - ExpandedBody( - app = app, - patchSourceNames = patchSourceNames, - accents = accents, - mono = mono, - cornerSmall = corners.small, - ) + Column { + ExpandedBody( + app = app, + patchSourceNames = patchSourceNames, + accents = accents, + mono = mono, + cornerSmall = corners.small, + ) + deviceInfo?.let { DeviceInfoLine(it, mono) } + } } } } +/** Optional device-layer line: whether the app is installed on the connected device. */ +@Composable +private fun DeviceInfoLine(info: app.morphe.gui.ui.screens.home.DeviceAppInfo, mono: FontFamily) { + val version = info.installedVersion?.let { " · v${it.removePrefix("v")}" } ?: "" + val (text, color) = when { + !info.installed -> "NOT ON THIS DEVICE" to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + // Installed but signed by a different cert → replaced/re-signed outside Morphe. + info.signedByMorphe == false -> "ON DEVICE$version · NOT MORPHE-SIGNED" to Color(0xFFE0504D) // red + else -> "ON DEVICE$version" to MorpheColors.Teal // ours, or signature undetermined + } + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = color, + letterSpacing = 0.5.sp, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 2.dp, bottom = 4.dp), + ) +} + +/** + * Small recall badge shown in the row header. Renders nothing for + * [PatchedAppState.NEVER_PATCHED] (callers gate on that before calling). + */ +@Composable +internal fun PatchedStateBadge(state: PatchedAppState, mono: FontFamily) { + val (label, color) = when (state) { + PatchedAppState.PATCHED -> "PATCHED" to MorpheColors.Teal + PatchedAppState.PATCHED_WITH_UPDATES -> "UPDATE AVAILABLE" to MorpheColors.Blue + PatchedAppState.MODIFIED_EXTERNALLY -> "MODIFIED" to Color(0xFFE0504D) // red + PatchedAppState.APK_MISSING -> "APK MISSING" to Color(0xFFE0A030) // amber + PatchedAppState.NEVER_PATCHED -> return + } + val corners = LocalMorpheCorners.current + Box( + modifier = Modifier + .clip(RoundedCornerShape(corners.small)) + .background(color.copy(alpha = 0.12f)) + .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(corners.small)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = label, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = color, + letterSpacing = 0.5.sp, + ) + } +} + /** * Channel label + version pair. When [downloadUrl] is non-null and [version] is * present, the chip becomes a clickable quick-download (with hand cursor + open- diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt new file mode 100644 index 00000000..97bb7d32 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt @@ -0,0 +1,1093 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.screens.home.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.gui.ui.screens.home.DeviceAppInfo +import app.morphe.gui.ui.screens.home.PatchedAppState +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** Which list the home pane is showing: all supported apps, or only patched ("yours"). */ +enum class AppListFilter { ALL, YOURS } + +/** + * Segmented filter at the top of the apps pane: ALL APPS · YOUR APPS. Replaces the + * old static "SUPPORTED APPS" header. The "Your apps" tab carries a count badge so + * the history is discoverable even before it's selected. + */ +@Composable +fun AppListFilterChips( + filter: AppListFilter, + onSelect: (AppListFilter) -> Unit, + allCount: Int, + yourCount: Int, +) { + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val corners = LocalMorpheCorners.current + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().padding(end = 12.dp, bottom = 6.dp), + ) { + FilterChip( + label = "ALL APPS", + count = if (allCount > 0) allCount else null, + selected = filter == AppListFilter.ALL, + accent = accents.primary, + mono = mono, + corner = corners.small, + onClick = { onSelect(AppListFilter.ALL) }, + ) + FilterChip( + label = "YOUR APPS", + count = if (yourCount > 0) yourCount else null, + selected = filter == AppListFilter.YOURS, + accent = accents.primary, + mono = mono, + corner = corners.small, + onClick = { onSelect(AppListFilter.YOURS) }, + ) + } +} + +/** + * On-open update notice (Phase 7 QoL, mirrors Manager). Shown above the apps list + * when one or more patched apps have a newer app version or patch-source version + * available. Tapping jumps to the "Your apps" list where each is badged. + */ +@Composable +fun PatchedUpdatesBanner(count: Int, onView: () -> Unit) { + val mono = LocalMorpheFont.current + val corners = LocalMorpheCorners.current + val blue = app.morphe.gui.ui.theme.MorpheColors.Blue + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(end = 12.dp, bottom = 8.dp) + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, blue.copy(alpha = if (isHovered) 0.55f else 0.35f), RoundedCornerShape(corners.medium)) + .background(blue.copy(alpha = if (isHovered) 0.14f else 0.09f)) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onView) + .padding(horizontal = 12.dp, vertical = 9.dp), + ) { + Icon(Icons.Default.Refresh, contentDescription = null, tint = blue, modifier = Modifier.size(15.dp)) + Text( + text = if (count == 1) "1 patched app has an update available" + else "$count patched apps have updates available", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = blue, + modifier = Modifier.weight(1f), + ) + Text("VIEW →", fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = blue, letterSpacing = 1.sp) + } +} + +@Composable +private fun FilterChip( + label: String, + count: Int?, + selected: Boolean, + accent: Color, + mono: FontFamily, + corner: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val border by animateColorAsState( + when { + selected -> accent.copy(alpha = 0.6f) + isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + }, + tween(150), label = "chip", + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier + .clip(RoundedCornerShape(corner)) + .border(1.dp, border, RoundedCornerShape(corner)) + .background(if (selected) accent.copy(alpha = 0.12f) else Color.Transparent) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.sp, + color = if (selected) accent + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + ) + if (count != null) { + Text( + text = count.toString(), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (selected) accent else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + } + } +} + +/** + * Compact summary row for the "Your apps" list — one per [PatchedAppRecord]. + * Tapping opens [PatchedAppDetailDialog] for the full breakdown. + */ +@Composable +fun YourAppRow( + record: PatchedAppRecord, + state: PatchedAppState, + deviceInfo: DeviceAppInfo?, + updateInfo: app.morphe.gui.ui.screens.home.RecallUpdateInfo?, + onClick: () -> Unit, + onRepatch: () -> Unit, + onUpdate: () -> Unit, + onForget: () -> Unit, + onInstall: () -> Unit = {}, + onUninstall: () -> Unit = {}, + installing: Boolean = false, + uninstalling: Boolean = false, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hover = remember(record.packageName) { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val border by animateColorAsState( + if (isHovered) accents.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + tween(150), label = "yourRow", + ) + val bg by animateColorAsState( + if (isHovered) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + else MaterialTheme.colorScheme.surface, + tween(150), label = "yourRowBg", + ) + val initial = record.displayName.firstOrNull()?.uppercase() ?: "?" + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, border, RoundedCornerShape(corners.medium)) + .background(bg) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accents.primary.copy(alpha = 0.35f), RoundedCornerShape(corners.small)) + .background(accents.primary.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + Text(initial, fontSize = 12.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = accents.primary) + } + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = record.displayName, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "v${record.apkVersion.removePrefix("v")} · ${relativeOrShortDate(record.patchedAt)}", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (deviceInfo?.installPending == true) { + Spacer(Modifier.width(8.dp)) + MiniBadge("INSTALL READY", app.morphe.gui.ui.theme.MorpheColors.Teal, mono) + } + if (state != PatchedAppState.NEVER_PATCHED) { + Spacer(Modifier.width(8.dp)) + PatchedStateBadge(state, mono) + } + } + deviceInfo?.let { DeviceLine(it, mono) } + // Patch source + version, with "→ vNew" when a newer patch file is available. + updateInfo?.sources?.firstOrNull()?.let { s -> + val more = updateInfo.sources.size - 1 + VersionBumpText( + label = "${s.name} ", + oldVersion = s.usedVersion, + newVersion = if (s.outdated) s.latestAvailableVersion else null, + newColor = app.morphe.gui.ui.theme.MorpheColors.Blue, + mono = mono, + suffix = if (more > 0) " +$more" else null, + ) + } + // App version bump (amber if recommended/unsupported, blue if optional), or + // a heads-up when a newer patch exists but its app version isn't resolved yet. + val cardAdvice = updateInfo?.let { appAdvice(it) } + if (cardAdvice != null && updateInfo.appSuggestedVersion != null) { + VersionBumpText( + label = "App ", + oldVersion = record.apkVersion, + newVersion = updateInfo.appSuggestedVersion, + newColor = if (cardAdvice.second) Color(0xFFE0A030) else app.morphe.gui.ui.theme.MorpheColors.Blue, + mono = mono, + ) + } else if (updateInfo != null && updateInfo.sources.any { it.outdated }) { + Text( + text = "ⓘ Newer patch may bump the app — tap Update to check", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + letterSpacing = 0.3.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Already-patched APK is newer than what's on the device → offer to install + // it directly (no re-patch needed). Streams away once the device catches up. + if (deviceInfo?.installPending == true) { + val teal = app.morphe.gui.ui.theme.MorpheColors.Teal + Text( + text = if (deviceInfo.installed) + "⤓ Patched v${record.apkVersion.removePrefix("v")} ready — device on v${deviceInfo.installedVersion?.removePrefix("v") ?: "?"} (no re-patch needed)" + else + "⤓ Patched v${record.apkVersion.removePrefix("v")} ready to install (no re-patch needed)", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = teal, + letterSpacing = 0.3.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Actions live directly on the card. Clicks are consumed, so they don't + // also open the detail dialog. + val hasUpdate = updateInfo != null && (updateInfo.appOutdated || updateInfo.sources.any { it.outdated }) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 2.dp), + ) { + if (deviceInfo?.installPending == true) { + DetailActionPill( + if (installing) "INSTALLING…" else "INSTALL", + Icons.Default.Download, + app.morphe.gui.ui.theme.MorpheColors.Teal, mono, corners.small, + onClick = if (installing) ({}) else onInstall, + ) + } + if (hasUpdate) { + DetailActionPill( + "UPDATE", Icons.Default.Refresh, + app.morphe.gui.ui.theme.MorpheColors.Blue, mono, corners.small, onClick = onUpdate, + ) + } + DetailActionPill("RE-PATCH", Icons.Default.Refresh, accents.primary, mono, corners.small, onClick = onRepatch) + // Only offer uninstall when the app is actually on the connected device. + if (deviceInfo?.installed == true) { + DetailActionPill( + if (uninstalling) "UNINSTALLING…" else "UNINSTALL", + Icons.Default.Delete, + Color(0xFFE0504D), mono, corners.small, + onClick = if (uninstalling) ({}) else onUninstall, + ) + } + DetailActionPill( + "FORGET", Icons.Default.Delete, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), mono, corners.small, + onClick = onForget, + ) + } + } +} + +/** + * Full recall breakdown for one patched app. Everything is already on the record + * (date, versions, per-source snapshot, selection, options, integrity); this is a + * read surface plus the Re-patch / Open folder / Forget actions. + */ +@Composable +fun PatchedAppDetailDialog( + record: PatchedAppRecord, + state: PatchedAppState, + deviceInfo: DeviceAppInfo?, + updateInfo: app.morphe.gui.ui.screens.home.RecallUpdateInfo?, + onDismiss: () -> Unit, + onRepatch: () -> Unit, + onUpdate: () -> Unit, + onForget: () -> Unit, + onOpenFolder: () -> Unit, + onInstall: () -> Unit = {}, + onUninstall: () -> Unit = {}, + installing: Boolean = false, + uninstalling: Boolean = false, +) { + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val corners = LocalMorpheCorners.current + val patchCount = record.patchSelectionByBundle.values.sumOf { it.size } + val hasUpdate = updateInfo != null && (updateInfo.appOutdated || updateInfo.sources.any { it.outdated }) + val installPending = deviceInfo?.installPending == true + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + // Tap outside the card to dismiss (the card swallows its own taps below). + .pointerInput(Unit) { detectTapGestures { onDismiss() } }, + contentAlignment = Alignment.Center, + ) { + // Grow with content, but cap at ~90% of the window height so the dialog + // can use a tall screen like Settings does, instead of the old fixed + // 560dp cap — while still wrapping shorter content. + val maxDialogHeight = maxHeight * 0.9f + Surface( + shape = RoundedCornerShape(corners.large), + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke( + 1.dp, accents.primary.copy(alpha = 0.25f), + ), + modifier = Modifier + .widthIn(max = 480.dp) + .pointerInput(Unit) { detectTapGestures { } }, + ) { + Column( + modifier = Modifier + .heightIn(max = maxDialogHeight) + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // ── Header ── + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = record.displayName, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = record.packageName, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + if (record.currentPackageName != null && + record.currentPackageName.isNotBlank() && + record.currentPackageName != record.packageName + ) { + Text( + text = "→ ${record.currentPackageName}", + fontSize = 10.sp, + fontFamily = mono, + color = accents.primary.copy(alpha = 0.8f), + ) + } + } + if (state != PatchedAppState.NEVER_PATCHED) { + PatchedStateBadge(state, mono) + } + } + + deviceInfo?.let { DeviceLine(it, mono) } + + Divider(accents.primary) + + // ── Key facts ── + DetailRow("PATCHED", fullDate(record.patchedAt), mono) + DetailRow("APP VERSION", "v${record.apkVersion.removePrefix("v")}", mono) + val appAdviceMsg = updateInfo?.let { appAdvice(it) } + if (appAdviceMsg != null) { + UpdateHint(appAdviceMsg.first, mono, recommended = appAdviceMsg.second) + } else if (updateInfo != null && updateInfo.sources.any { it.outdated }) { + // Newer patch exists but its app versions aren't resolved yet + // (offline / mid-fetch) — UPDATE fetches them. + InfoNote("A newer patch is available and may support a newer app version. Tap Update to check.", mono) + } + DetailRow("MORPHE", record.patchedWithMorpheVersion, mono) + + // ── Sources + per-source patch-file freshness ── + val sourceRows = updateInfo?.sources + if (!sourceRows.isNullOrEmpty()) { + Divider(accents.primary) + SectionHeader("SOURCES", accents.secondary, mono) + sourceRows.forEach { SourceUpdateRow(it, mono) } + } else if (record.sourcesSnapshot.isNotEmpty()) { + Divider(accents.primary) + SectionHeader("SOURCES", accents.secondary, mono) + record.sourcesSnapshot.forEach { src -> + DetailRow(src.sourceName, "v${src.version.removePrefix("v")}", mono) + } + } + + // ── Patches applied (expandable + searchable) ── + Divider(accents.primary) + var patchesExpanded by remember { mutableStateOf(false) } + var patchSearch by remember { mutableStateOf("") } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .clickable { patchesExpanded = !patchesExpanded } + .padding(vertical = 4.dp, horizontal = 4.dp), + ) { + SectionHeader("PATCHES APPLIED", accents.primary, mono) + Spacer(Modifier.weight(1f)) + Text( + text = "$patchCount ${if (patchesExpanded) "▾" else "▸"}", + fontSize = 11.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + color = accents.primary, + ) + } + if (patchesExpanded) { + if (patchCount > 5) { + PatchSearchField(patchSearch, { patchSearch = it }, mono, corners.small, accents.primary) + } + record.patchSelectionByBundle.forEach { (bundle, patches) -> + val shown = (if (patchSearch.isBlank()) patches + else patches.filter { it.contains(patchSearch, ignoreCase = true) }).sorted() + if (shown.isNotEmpty()) { + Text( + text = bundle, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 6.dp, bottom = 2.dp), + ) + shown.forEach { uid -> + Text( + text = "• $uid", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.padding(start = 8.dp, top = 1.dp), + ) + } + } + } + if (record.patchOptionValues.isNotEmpty() && patchSearch.isBlank()) { + Text( + text = "OPTIONS", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp), + ) + record.patchOptionValues.forEach { (k, v) -> + Text( + text = "• $k = $v", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.padding(start = 8.dp, top = 1.dp), + ) + } + } + } + + // ── Output ── + Divider(accents.primary) + DetailRow("OUTPUT SIZE", humanSize(record.outputApkSize), mono) + record.outputApkSha256?.let { + DetailRow("SHA-256", it.take(16) + "…", mono) + } + Text( + text = record.outputApkPath, + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + ) + + // ── Actions: full-width buttons that state what they'll do ── + Divider(accents.primary) + val repatchSub = updateInfo?.sources?.mapNotNull { it.resolvedVersion }?.firstOrNull() + ?.let { "uses v${it.removePrefix("v")}" } + val updateSub = updateInfo?.let { updateSummary(it) } + // Already-patched APK ready to install (no re-patch) — primary action. + if (installPending) { + val sub = if (deviceInfo?.installed == true) + "v${record.apkVersion.removePrefix("v")} ready · device on v${deviceInfo.installedVersion?.removePrefix("v") ?: "?"}" + else "v${record.apkVersion.removePrefix("v")} ready — no re-patch needed" + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + WideActionButton( + if (installing) "INSTALLING…" else "INSTALL", + sub, Icons.Default.Download, + app.morphe.gui.ui.theme.MorpheColors.Teal, mono, corners.small, + onClick = if (installing) ({}) else ({ onInstall() }), + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + if (hasUpdate) { + WideActionButton( + "UPDATE", updateSub, Icons.Default.Refresh, + app.morphe.gui.ui.theme.MorpheColors.Blue, mono, corners.small, + ) { onDismiss(); onUpdate() } + } + WideActionButton("RE-PATCH", repatchSub, Icons.Default.Refresh, accents.primary, mono, corners.small) { + onDismiss(); onRepatch() + } + } + // Uninstall from the connected device — only when it's actually installed. + if (deviceInfo?.installed == true) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + WideActionButton( + if (uninstalling) "UNINSTALLING…" else "UNINSTALL", + "remove from device", Icons.Default.Delete, + Color(0xFFE0504D), mono, corners.small, + onClick = if (uninstalling) ({}) else ({ onDismiss(); onUninstall() }), + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + WideActionButton("FOLDER", null, Icons.AutoMirrored.Filled.OpenInNew, accents.secondary, mono, corners.small, onClick = onOpenFolder) + WideActionButton( + "FORGET", null, Icons.Default.Delete, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), mono, corners.small, + ) { onDismiss(); onForget() } + } + } + } + } + } +} + +/** Full-width action button (used in the detail dialog): icon + label, plus an + * optional sub-line stating the version it acts on. Stretches via [RowScope.weight]. */ +@Composable +private fun androidx.compose.foundation.layout.RowScope.WideActionButton( + label: String, + sublabel: String?, + icon: androidx.compose.ui.graphics.vector.ImageVector, + color: Color, + mono: FontFamily, + corner: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Column( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(corner)) + .border(1.dp, color.copy(alpha = if (isHovered) 0.6f else 0.35f), RoundedCornerShape(corner)) + .background(color.copy(alpha = if (isHovered) 0.14f else 0.08f)) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Text(label, fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = color, letterSpacing = 0.5.sp) + } + if (sublabel != null) { + Text( + text = sublabel, + fontSize = 8.sp, + fontFamily = mono, + color = color.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** One-line summary of what an UPDATE will move to (patch + app versions). */ +private fun updateSummary(u: app.morphe.gui.ui.screens.home.RecallUpdateInfo): String? { + val parts = mutableListOf() + val outdated = u.sources.filter { it.outdated && it.latestAvailableVersion != null } + outdated.firstOrNull()?.let { s -> + val more = outdated.size - 1 + parts += "→ patches v${s.latestAvailableVersion!!.removePrefix("v")}" + if (more > 0) " +$more" else "" + } + if (u.appOutdated && u.appSuggestedVersion != null) { + parts += "app v${u.appSuggestedVersion.removePrefix("v")}" + } + return parts.joinToString(" · ").ifBlank { null } +} + +/** + * Morphe-styled modal card (Dialog + Surface) — the house replacement for stock + * Material `AlertDialog`s. Sharp corners, accent border, mono title. + */ +@Composable +fun MorpheDialogCard( + onDismiss: () -> Unit, + title: String, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + val accents = LocalMorpheAccents.current + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(corners.large), + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke(1.dp, accents.primary.copy(alpha = 0.25f)), + modifier = Modifier.widthIn(max = 440.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = title, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp, + ) + content() + } + } + } +} + +/** Body paragraph for a [MorpheDialogCard]. */ +@Composable +fun MorpheDialogText(text: String) { + Text( + text = text, + fontSize = 12.sp, + fontFamily = LocalMorpheFont.current, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 17.sp, + ) +} + +/** Full-width dialog action button. [filled] = primary emphasis (solid fill). */ +@Composable +fun androidx.compose.foundation.layout.RowScope.MorpheDialogButton( + label: String, + color: Color, + filled: Boolean, + onClick: () -> Unit, +) { + val mono = LocalMorpheFont.current + val corner = LocalMorpheCorners.current.small + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(corner)) + .then( + if (filled) { + Modifier.background(color.copy(alpha = if (isHovered) 1f else 0.85f)) + } else { + Modifier + .border(1.dp, color.copy(alpha = if (isHovered) 0.6f else 0.35f), RoundedCornerShape(corner)) + .background(color.copy(alpha = if (isHovered) 0.12f else 0.06f)) + } + ) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(vertical = 9.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = if (filled) MaterialTheme.colorScheme.surface else color, + ) + } +} + +/** Slim search field for filtering the applied-patches list. */ +@Composable +private fun PatchSearchField( + value: String, + onValueChange: (String) -> Unit, + mono: FontFamily, + corner: androidx.compose.ui.unit.Dp, + accent: Color, +) { + androidx.compose.foundation.text.BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle( + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(accent), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 4.dp), + decorationBox = { inner -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + // Fixed height + centered content so the field doesn't grow/shift + // when typing, and the placeholder/cursor sit at the same spot. + .height(32.dp) + .clip(RoundedCornerShape(corner)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(corner)) + .padding(horizontal = 8.dp), + ) { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text( + "Search patches…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + } + inner() + } + } + }, + ) +} + +@Composable +private fun DetailRow(label: String, value: String, mono: FontFamily) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.width(120.dp), + ) + Text( + text = value, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } +} + +/** One source row showing the patched version + an "↑ vX available" hint if outdated. */ +@Composable +private fun SourceUpdateRow(s: app.morphe.gui.ui.screens.home.RecallUpdateInfo.SourceUpdate, mono: FontFamily) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Text( + text = s.name, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.width(120.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "v${s.usedVersion.removePrefix("v")}", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + ) + if (s.outdated && s.latestAvailableVersion != null) { + Text( + text = "↑ v${s.latestAvailableVersion.removePrefix("v")} available", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = app.morphe.gui.ui.theme.MorpheColors.Blue, + ) + } + } + } +} + +/** "↑ …" advice line. recommended = amber (take it), optional = blue (your call). */ +@Composable +private fun UpdateHint(text: String, mono: FontFamily, recommended: Boolean = false) { + Text( + text = "↑ $text", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (recommended) Color(0xFFE0A030) else app.morphe.gui.ui.theme.MorpheColors.Blue, + lineHeight = 14.sp, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + ) +} + +/** + * App-version advice for a patched app, or null if current. Returns (message, + * recommended): recommended=true (amber) when the version is unsupported or a newer + * stable is out; false (blue) for an optional experimental bump. + */ +private fun appAdvice(u: app.morphe.gui.ui.screens.home.RecallUpdateInfo): Pair? { + if (!u.appOutdated || u.appSuggestedVersion == null) return null + val target = u.appSuggestedVersion.removePrefix("v") + val used = u.appUsedVersion.removePrefix("v") + return when { + !u.appUsedSupported -> "v$used is no longer supported. Please update to v$target" to true + u.appChannel == app.morphe.gui.ui.screens.home.RecallUpdateInfo.AppChannel.EXPERIMENTAL -> + "Newer experimental v$target available." to false + else -> "Update recommended. Newer stable v$target available" to true + } +} + +/** + * "label vOld → vNew" with distinct colors: muted label/old/arrow, highlighted new. + * Reads far better than a single flat accent. [newColor] signals tone (blue = optional, + * amber = recommended). When [newVersion] is null, just shows "label vOld". + */ +@Composable +private fun VersionBumpText( + label: String, + oldVersion: String, + newVersion: String?, + newColor: Color, + mono: FontFamily, + suffix: String? = null, +) { + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) + val muted = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + val arrow = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + val text = buildAnnotatedString { + withStyle(SpanStyle(color = labelColor, fontWeight = FontWeight.Bold)) { append(label) } + withStyle(SpanStyle(color = muted)) { append("v${oldVersion.removePrefix("v")}") } + if (newVersion != null) { + withStyle(SpanStyle(color = arrow)) { append(" → ") } + withStyle(SpanStyle(color = newColor, fontWeight = FontWeight.Bold)) { append("v${newVersion.removePrefix("v")}") } + } + if (suffix != null) withStyle(SpanStyle(color = muted)) { append(suffix) } + } + Text( + text = text, + fontSize = 9.sp, + fontFamily = mono, + letterSpacing = 0.3.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +/** Small pill badge (matches PatchedStateBadge styling) for ad-hoc states. */ +@Composable +private fun MiniBadge(label: String, color: Color, mono: FontFamily) { + val corner = LocalMorpheCorners.current.small + Box( + modifier = Modifier + .clip(RoundedCornerShape(corner)) + .background(color.copy(alpha = 0.12f)) + .border(1.dp, color.copy(alpha = 0.4f), RoundedCornerShape(corner)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text(label, fontSize = 8.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = color, letterSpacing = 0.5.sp) + } +} + +/** Muted informational note (ⓘ) — full width, wraps. */ +@Composable +private fun InfoNote(text: String, mono: FontFamily) { + Text( + text = "ⓘ $text", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + lineHeight = 14.sp, + modifier = Modifier.fillMaxWidth().padding(top = 2.dp), + ) +} + +@Composable +private fun SectionHeader(text: String, color: Color, mono: FontFamily) { + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.2.sp, + color = color.copy(alpha = 0.85f), + ) +} + +@Composable +private fun Divider(color: Color) { + Box( + modifier = Modifier + .fillMaxWidth() + .size(1.dp) + .background(color.copy(alpha = 0.12f)), + ) +} + +@Composable +private fun DetailActionPill( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + color: Color, + mono: FontFamily, + corner: androidx.compose.ui.unit.Dp, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Row( + modifier = Modifier + .clip(RoundedCornerShape(corner)) + .border(1.dp, color.copy(alpha = if (isHovered) 0.6f else 0.35f), RoundedCornerShape(corner)) + .background(color.copy(alpha = if (isHovered) 0.14f else 0.08f)) + .hoverable(hover) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(icon, contentDescription = null, tint = color, modifier = Modifier.size(13.dp)) + Text(label, fontSize = 9.sp, fontWeight = FontWeight.Bold, fontFamily = mono, color = color, letterSpacing = 0.5.sp) + } +} + +/** Shared device-install line (mirrors the supported-row variant). */ +@Composable +private fun DeviceLine(info: DeviceAppInfo, mono: FontFamily) { + val version = info.installedVersion?.let { " · v${it.removePrefix("v")}" } ?: "" + val (text, color) = when { + !info.installed -> "NOT ON THIS DEVICE" to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + info.signedByMorphe == false -> "ON DEVICE$version · NOT MORPHE-SIGNED" to Color(0xFFE0504D) + else -> "ON DEVICE$version" to app.morphe.gui.ui.theme.MorpheColors.Teal + } + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = color, + letterSpacing = 0.5.sp, + ) +} + +private fun fullDate(millis: Long): String = + SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.US).format(Date(millis)) + +/** "today / yesterday / 3d ago / MMM d" — compact for the list row. */ +private fun relativeOrShortDate(millis: Long): String { + val now = System.currentTimeMillis() + val days = ((now - millis) / 86_400_000L).toInt() + return when { + days <= 0 -> "today" + days == 1 -> "yesterday" + days < 7 -> "${days}d ago" + else -> SimpleDateFormat("MMM d", Locale.US).format(Date(millis)) + } +} + +private fun humanSize(bytes: Long): String { + if (bytes <= 0) return "—" + val mb = bytes / 1_048_576.0 + return if (mb >= 1) "%.1f MB".format(mb) else "%.0f KB".format(bytes / 1024.0) +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt index 9f7bbc59..5df6dc90 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.gui.ui.screens.patches import androidx.compose.animation.AnimatedVisibility @@ -58,6 +63,7 @@ import app.morphe.gui.ui.components.ErrorDialog import app.morphe.gui.ui.components.DeviceIndicator import app.morphe.gui.ui.components.MorpheSwitch import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.ToolsButton import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage @@ -91,13 +97,21 @@ data class PatchSelectionScreen( /** Parallel to [patchesFilePaths] — display name per source. Drives badging * in the patch list. Empty disables badging (legacy single-source). */ val patchSourceNames: List = emptyList(), + /** One-click repatch seed (source/bundle name → patch uniqueIds). Empty = + * normal flow. Set when entering from a "Your apps" / patched-row Repatch. */ + val initialSelectionByBundle: Map> = emptyMap(), + /** One-click repatch option seed ("patchName.optionKey" → value). */ + val initialPatchOptions: Map = emptyMap(), ) : Screen { @Composable override fun Content() { val effectiveList = patchesFilePaths.takeIf { it.isNotEmpty() } ?: listOf(patchesFilePath) val viewModel = koinScreenModel { - parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures, effectiveList, patchSourceNames) + parametersOf( + apkPath, apkName, patchesFilePath, packageName, apkArchitectures, + effectiveList, patchSourceNames, initialSelectionByBundle, initialPatchOptions, + ) } PatchSelectionScreenContent(viewModel = viewModel) } @@ -322,8 +336,9 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { DeviceIndicator() Spacer(modifier = Modifier.width(6.dp)) + ToolsButton(allowCacheClear = false) + Spacer(modifier = Modifier.width(6.dp)) SettingsButton( - allowCacheClear = false, onDismiss = { viewModel.refreshStripLibsStatus() } ) } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt index 5ec69c7b..e82c48ad 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -25,6 +25,8 @@ import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File /** @@ -63,6 +65,11 @@ class PatchSelectionViewModel( /** Parallel to [patchesFilePaths] — display name of each source. Used as the * per-bundle label AND persistence key. */ private val patchSourceNames: List = emptyList(), + /** One-click repatch seed: source/bundle name → set of patch uniqueIds to + * pre-select. Empty = normal flow (saved prefs / .mpp defaults). */ + private val initialSelectionByBundle: Map> = emptyMap(), + /** One-click repatch seed: "patchName.optionKey" → option value. */ + private val initialPatchOptions: Map = emptyMap(), ) : ScreenModel { // Actual path to use for the primary file — may differ from patchesFilePath @@ -93,8 +100,18 @@ class PatchSelectionViewModel( // Store the resolved absolute path so the lookup at line ~487 can // pass it straight into File(...) without re-resolving. defaultOutputDirectory = config.resolvedDefaultOutputDirectory()?.absolutePath + // Architectures arrive empty from a repatch/update (the caller didn't + // pre-analyze the APK) — derive them from the APK itself so the strip-libs + // option isn't lost. Also correct for an Update's freshly-downloaded APK. + val arches = apkArchitectures.ifEmpty { + withContext(Dispatchers.IO) { + runCatching { app.morphe.gui.util.FileUtils.extractArchitectures(File(apkPath)) } + .getOrDefault(emptyList()) + } + } _uiState.value = _uiState.value.copy( - stripLibsStatus = computeStripLibsStatus(apkArchitectures, config.keepArchitectures) + apkArchitectures = arches, + stripLibsStatus = computeStripLibsStatus(arches, config.keepArchitectures), ) } } @@ -151,24 +168,40 @@ class PatchSelectionViewModel( val savedByBundle = mutableMapOf>() val initialOptions = mutableMapOf() var anyBundleHasSaved = false - for (bundle in bundles) { - val saved = preferencesRepository.get(bundle.bundleName, packageName) - if (saved != null) { - anyBundleHasSaved = true - val byName = bundle.patches.associateBy { it.name } - val selected = saved.patches - .filter { (_, entry) -> entry.enabled } - .keys - .mapNotNull { byName[it]?.uniqueId } - .toSet() - savedByBundle[bundle.bundleId] = selected - // Materialize saved option values ("patchName.optionKey" → string). - // Options are per-patch-name so they're naturally global here; - // identical patches in two bundles share option values, which - // is fine — same option means same thing. - for ((patchName, entry) in saved.patches) { - for ((optKey, jsonValue) in entry.options) { - initialOptions["$patchName.$optKey"] = jsonValue.toString().trim('"') + + if (initialSelectionByBundle.isNotEmpty()) { + // One-click repatch: seed selection + options from the + // PatchedAppRecord (keyed by source/bundle name). Takes + // precedence over saved prefs; keep only ids that still + // exist in the current (possibly newer) bundle. + anyBundleHasSaved = true + for (bundle in bundles) { + val seed = initialSelectionByBundle[bundle.bundleName] ?: continue + val validIds = bundle.patches.mapTo(mutableSetOf()) { it.uniqueId } + savedByBundle[bundle.bundleId] = seed.intersect(validIds) + } + initialOptions.putAll(initialPatchOptions) + Logger.info("Repatch: seeded selection for $packageName from record") + } else { + for (bundle in bundles) { + val saved = preferencesRepository.get(bundle.bundleName, packageName) + if (saved != null) { + anyBundleHasSaved = true + val byName = bundle.patches.associateBy { it.name } + val selected = saved.patches + .filter { (_, entry) -> entry.enabled } + .keys + .mapNotNull { byName[it]?.uniqueId } + .toSet() + savedByBundle[bundle.bundleId] = selected + // Materialize saved option values ("patchName.optionKey" → string). + // Options are per-patch-name so they're naturally global here; + // identical patches in two bundles share option values, which + // is fine — same option means same thing. + for ((patchName, entry) in saved.patches) { + for ((optKey, jsonValue) in entry.options) { + initialOptions["$patchName.$optKey"] = jsonValue.toString().trim('"') + } } } } @@ -227,7 +260,7 @@ class PatchSelectionViewModel( BundlePatches( bundleId = "bundle-$idx-${File(path).nameWithoutExtension}", bundleName = displayName, - patches = patches, + patches = patches.sortedBy { it.name.lowercase() }, ) } @@ -501,6 +534,21 @@ class PatchSelectionViewModel( ?.toSet() ?: emptySet() + // Recall metadata: capture per-bundle selection and the source+version + // snapshot so the patching success path can record a PatchedAppRecord. + val state = _uiState.value + val selectionByBundle = state.bundles.associate { bundle -> + bundle.bundleName to state.selectedByBundle[bundle.bundleId].orEmpty() + } + val sourcesSnapshot = actualPatchesFilePaths.mapIndexed { i, path -> + val name = patchSourceNames.getOrNull(i) ?: File(path).nameWithoutExtension + app.morphe.engine.model.PatchedAppRecord.PatchedSourceSnapshot( + sourceId = name, + sourceName = name, + version = extractPatchesVersion(File(path).name) ?: "unknown", + ) + } + return PatchConfig( inputApkPath = apkPath, outputApkPath = outputPath, @@ -511,6 +559,10 @@ class PatchSelectionViewModel( useExclusiveMode = true, keepArchitectures = keepArches, continueOnError = continueOnError, + packageName = packageName, + appDisplayName = apkName, + patchSelectionByBundle = selectionByBundle, + sourcesSnapshot = sourcesSnapshot, ) } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt index 480c4778..7d33101d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt @@ -48,6 +48,7 @@ import cafe.adriel.voyager.koin.koinScreenModel import app.morphe.gui.ui.components.ErrorDialog import app.morphe.gui.ui.components.DeviceIndicator import app.morphe.gui.ui.components.SettingsButton +import app.morphe.gui.ui.components.ToolsButton import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage @@ -221,7 +222,9 @@ fun PatchesScreenContent(viewModel: PatchesViewModel) { DeviceIndicator() Spacer(modifier = Modifier.width(6.dp)) - SettingsButton(allowCacheClear = true) + ToolsButton(allowCacheClear = true) + Spacer(modifier = Modifier.width(6.dp)) + SettingsButton() } // ── Content area ── diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt index f03ec7e7..8ca2f1f6 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt @@ -10,6 +10,8 @@ import app.morphe.patcher.patch.loadPatchesFromJar import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import app.morphe.engine.model.Release +import app.morphe.gui.data.model.FollowMode +import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager @@ -81,10 +83,16 @@ class PatchesViewModel( val stableReleases = releases.filter { !it.isDevRelease() } val devReleases = releases.filter { it.isDevRelease() } - // Check config for previously selected version FOR THIS SOURCE + // Resolve this source's version preference to a concrete tag + // to pre-select: a pin → its tag; follow-stable → newest stable; + // follow-dev → newest overall. val activeSourceId = patchSourceManager?.getActiveSource()?.id - val savedVersion = activeSourceId?.let { - configRepository.getLastPatchesVersionsBySource()[it] + val pref = activeSourceId?.let { configRepository.getSourceVersionPrefs()[it] } + val savedVersion = when (pref?.mode) { + FollowMode.PINNED -> pref.pinnedTag + FollowMode.FOLLOW_STABLE -> stableReleases.firstOrNull()?.tagName + FollowMode.FOLLOW_DEV -> releases.firstOrNull()?.tagName + null -> null } // Find the saved release, or fall back to latest stable @@ -137,8 +145,12 @@ class PatchesViewModel( .fold(0L) { acc, part -> acc * 10000 + part } } val activeSourceId = patchSourceManager?.getActiveSource()?.id - val savedVersion = activeSourceId?.let { - configRepository.getLastPatchesVersionsBySource()[it] + val pref = activeSourceId?.let { configRepository.getSourceVersionPrefs()[it] } + val savedVersion = when (pref?.mode) { + FollowMode.PINNED -> pref.pinnedTag + FollowMode.FOLLOW_STABLE -> offlineReleases.firstOrNull { !it.isDevRelease() }?.tagName + FollowMode.FOLLOW_DEV -> offlineReleases.firstOrNull()?.tagName + null -> null } // Pre-select the saved version, or fall back to the first (most recent) @@ -306,12 +318,13 @@ class PatchesViewModel( ) Logger.info("Patches downloaded: ${patchFile.absolutePath}") - // Save the selected version PER SOURCE so HomeScreen can pick it up - // without contaminating other enabled sources. + // Save the version preference PER SOURCE so HomeScreen can pick + // it up without contaminating other enabled sources. val activeSourceId = patchSourceManager?.getActiveSource()?.id if (activeSourceId != null) { - configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) - Logger.info("Saved selected patches version for source '$activeSourceId': ${release.tagName}") + val pref = versionPrefFor(release) + configRepository.setSourceVersionPref(activeSourceId, pref) + Logger.info("Saved version pref for source '$activeSourceId': $pref") } }, onFailure = { e -> @@ -338,12 +351,33 @@ class PatchesViewModel( screenModelScope.launch { val activeSourceId = patchSourceManager?.getActiveSource()?.id if (activeSourceId != null) { - configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) - Logger.info("Confirmed patches selection for source '$activeSourceId': ${release.tagName}") + val pref = versionPrefFor(release) + configRepository.setSourceVersionPref(activeSourceId, pref) + Logger.info("Confirmed version pref for source '$activeSourceId': $pref") } } } + /** + * Turn a user-selected release into a [SourceVersionPref]: + * - the newest stable → [FollowMode.FOLLOW_STABLE] (ride latest stable) + * - the newest dev → [FollowMode.FOLLOW_DEV] (ride newest overall) + * - anything older → [FollowMode.PINNED] to that exact tag + * + * "Picked the latest of a channel" is read as "stay on that channel's latest," + * which is what auto-updates the source going forward. + */ + private fun versionPrefFor(release: Release): SourceVersionPref { + val newestStableTag = _uiState.value.stableReleases.firstOrNull()?.tagName + val newestDevTag = _uiState.value.devReleases.firstOrNull()?.tagName + val mode = when { + release.isDevRelease() && release.tagName == newestDevTag -> FollowMode.FOLLOW_DEV + !release.isDevRelease() && release.tagName == newestStableTag -> FollowMode.FOLLOW_STABLE + else -> FollowMode.PINNED + } + return SourceVersionPref(mode = mode, pinnedTag = release.tagName.takeIf { mode == FollowMode.PINNED }) + } + /** * Export patch options from the downloaded .mpp file to a JSON file. */ diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt index d0443513..94ca687a 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -15,14 +15,22 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import app.morphe.engine.MorpheData +import app.morphe.engine.PatchedAppStore +import app.morphe.engine.UpdateChecker +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.util.ApkManifestReader +import app.morphe.engine.util.FileChecksum import app.morphe.gui.util.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import app.morphe.gui.util.PatchService import java.io.File class PatchingViewModel( private val config: PatchConfig, private val patchService: PatchService, - private val configRepository: ConfigRepository + private val configRepository: ConfigRepository, + private val patchedAppStore: PatchedAppStore, ) : ScreenModel { private val _uiState = MutableStateFlow(PatchingUiState()) @@ -119,6 +127,7 @@ class PatchingViewModel( progress = 1f ) Logger.info("Patching completed: ${config.outputApkPath}") + recordPatchedApp(patchResult) } else { val reason = patchResult.failureReason ?: if (patchResult.failedPatches.isNotEmpty()) @@ -154,6 +163,47 @@ class PatchingViewModel( Logger.info("Patching cancelled by user") } + /** + * Record this patch in the shared patched-app history (see [PatchedAppStore]). + * Best-effort: a history-write failure must never disrupt the success UX. + */ + private suspend fun recordPatchedApp(patchResult: app.morphe.gui.util.PatchResult) { + try { + val pkg = config.packageName.ifEmpty { patchResult.packageName } + if (pkg.isEmpty()) return // nothing useful to key on + val (sha, size) = withContext(Dispatchers.IO) { + FileChecksum.fingerprintOrNull(config.outputApkPath) + } + // Read the output APK's manifest once: post-rename package (for device + // matching) + versionName (fallback so the APK version number always shows). + val manifest = withContext(Dispatchers.IO) { + runCatching { ApkManifestReader.read(File(config.outputApkPath)) }.getOrNull() + } + patchedAppStore.upsert( + PatchedAppRecord( + packageName = pkg, + currentPackageName = manifest?.packageName, + displayName = config.appDisplayName.ifEmpty { pkg }, + // Prefer the manifest's versionName (e.g. "21.20.400") — the patch + // result's packageVersion can be the numeric versionCode, which breaks + // version comparisons for update detection. + apkVersion = manifest?.versionName?.takeIf { it.isNotBlank() } ?: patchResult.packageVersion, + inputApkPath = config.inputApkPath, + outputApkPath = config.outputApkPath, + outputApkSha256 = sha, + outputApkSize = size, + patchSelectionByBundle = config.patchSelectionByBundle, + patchOptionValues = config.patchOptions, + sourcesSnapshot = config.sourcesSnapshot, + patchedAt = System.currentTimeMillis(), + patchedWithMorpheVersion = UpdateChecker.currentVersion() ?: "unknown", + ) + ) + } catch (e: Exception) { + Logger.error("Failed to record patched app", e) + } + } + private fun addLog(message: String, level: LogLevel) { val entry = LogEntry(message, level) _uiState.value = _uiState.value.copy( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 08684763..06377a3a 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -39,6 +39,7 @@ import cafe.adriel.voyager.core.screen.Screen import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light +import app.morphe.engine.PatchedAppStore import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp @@ -128,6 +129,7 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { onAdd = { src -> pickerScope.launch { patchSourceManager.addSource(src) } }, onEdit = { src -> pickerScope.launch { patchSourceManager.updateSource(src) } }, onRemove = { id -> pickerScope.launch { patchSourceManager.removeSource(id) } }, + onReorder = { orderedIds -> pickerScope.launch { patchSourceManager.reorderSources(orderedIds) } }, onOpenPatches = { /* unused in SINGLE_SELECT mode */ }, onDismiss = { showSourcePicker = false }, enabled = uiState.phase != QuickPatchPhase.DOWNLOADING && @@ -1191,6 +1193,7 @@ private fun CompletedContent( val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } + val configRepository: ConfigRepository = koinInject() val monitorState by DeviceMonitor.state.collectAsState() val adbPreference = LocalAdbPreference.current val isAdbDisabledByUser = !adbPreference.enabled @@ -1443,7 +1446,23 @@ private fun CompletedContent( deviceId = device.id ) result.fold( - onSuccess = { installSuccess = true }, + onSuccess = { + installSuccess = true + // Parity with ResultScreen: auto-route links when opted in. + val config = configRepository.loadConfig() + if (config.autoRouteLinksAfterInstall) { + val record = PatchedAppStore.shared.getAll() + .firstOrNull { it.outputApkPath == outputPath } + record?.let { + adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = it.installedPackageName, + stockPackage = if (config.disableStockLinksAfterInstall) it.packageName else null, + enable = true, + ) + } + } + }, onFailure = { installError = it.message } ) isInstalling = false diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index cb076971..bbdf8ea0 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -12,13 +12,20 @@ import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchConfig import app.morphe.gui.data.model.SupportedApp import app.morphe.engine.MorpheData +import app.morphe.engine.PatchedAppStore +import app.morphe.engine.UpdateChecker import app.morphe.engine.UpdateInfo +import app.morphe.engine.model.PatchedAppRecord +import app.morphe.engine.util.ApkOutputNaming +import app.morphe.engine.util.FileChecksum import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -44,6 +51,7 @@ class QuickPatchViewModel( private val patchService: PatchService, private val configRepository: ConfigRepository, private val updateCheckRepository: UpdateCheckRepository, + private val patchedAppStore: PatchedAppStore = PatchedAppStore.shared, ) : ScreenModel { private var patchRepository: PatchRepository = patchSourceManager.getActiveRepositorySync() @@ -526,6 +534,7 @@ class QuickPatchViewModel( statusMessage = "Patching complete! Applied ${result.appliedPatches.size} patches." ) Logger.info("Quick mode: Patching completed - $outputPath (${result.appliedPatches.size} patches)") + recordPatchedApp(result, apkFile.absolutePath, outputPath, apkInfo.displayName) } else { val errorMsg = if (result.failedPatches.isNotEmpty()) { "Patching had failures: ${result.failedPatches.joinToString(", ")}" @@ -551,6 +560,55 @@ class QuickPatchViewModel( /** * Parse progress from CLI output. */ + /** + * Record this quick-mode patch in the shared patched-app history. + * Best-effort: a write failure must never disrupt the success UX. Quick mode + * uses the default patch set, so no per-bundle selection is captured. + */ + private suspend fun recordPatchedApp( + result: app.morphe.gui.util.PatchResult, + inputApkPath: String, + outputApkPath: String, + displayName: String, + ) { + try { + val pkg = result.packageName + if (pkg.isEmpty()) return + val (sha, size) = withContext(Dispatchers.IO) { + FileChecksum.fingerprintOrNull(outputApkPath) + } + val manifest = withContext(Dispatchers.IO) { + runCatching { ApkManifestReader.read(java.io.File(outputApkPath)) }.getOrNull() + } + val sources = currentResolvedPatchFiles().map { f -> + PatchedAppRecord.PatchedSourceSnapshot( + sourceId = f.nameWithoutExtension, + sourceName = f.nameWithoutExtension, + version = ApkOutputNaming.extractPatchesVersion(f.name) ?: "unknown", + ) + } + patchedAppStore.upsert( + PatchedAppRecord( + packageName = pkg, + currentPackageName = manifest?.packageName, + displayName = displayName.ifEmpty { pkg }, + // Prefer the manifest's versionName (e.g. "21.20.400") over the numeric + // versionCode so update-detection version comparisons work. + apkVersion = manifest?.versionName?.takeIf { it.isNotBlank() } ?: result.packageVersion, + inputApkPath = inputApkPath, + outputApkPath = outputApkPath, + outputApkSha256 = sha, + outputApkSize = size, + sourcesSnapshot = sources, + patchedAt = System.currentTimeMillis(), + patchedWithMorpheVersion = UpdateChecker.currentVersion() ?: "unknown", + ) + ) + } catch (e: Exception) { + Logger.error("Failed to record patched app (quick mode)", e) + } + } + private fun parseProgress(line: String) { // Pattern: "Executing patch X of Y" val executingPattern = Regex("""(?:Executing|Applying)\s+patch\s+(\d+)\s+of\s+(\d+)""", RegexOption.IGNORE_CASE) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index 64b0d6c8..e2f9cd40 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -55,6 +55,11 @@ import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.DeviceStatus import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger +import app.morphe.engine.PatchedAppStore +import app.morphe.engine.util.ApkManifestReader +import app.morphe.gui.data.model.SupportedApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.awt.Desktop import java.io.File @@ -93,6 +98,42 @@ fun ResultScreenContent(outputPath: String) { var installError by remember { mutableStateOf(null) } var installSuccess by remember { mutableStateOf(false) } + // Whether the patched package is already on the selected device → show "Update" + // instead of "Install" (the install itself already reinstalls with -r). + var outputPackage by remember { mutableStateOf(null) } + var alreadyInstalled by remember { mutableStateOf(false) } + LaunchedEffect(outputPath) { + outputPackage = withContext(Dispatchers.IO) { + runCatching { ApkManifestReader.read(outputFile)?.packageName }.getOrNull() + } + } + LaunchedEffect(monitorState.selectedDevice?.id, monitorState.selectedDevice?.isReady, outputPackage) { + val device = monitorState.selectedDevice + val pkg = outputPackage + alreadyInstalled = device != null && device.isReady && pkg != null && + adbManager.listInstalledPackages(device.id).getOrNull()?.contains(pkg) == true + } + + // Link-handling ("open with") state. The stock package — needed only for the + // optional "stop stock from opening links" half — comes from the recall + // record for this output (which stores original + renamed package names). + var stockPackage by remember { mutableStateOf(null) } + var disableStockLinks by remember { mutableStateOf(false) } + var isApplyingLinks by remember { mutableStateOf(false) } + var linkProgress by remember { mutableStateOf("") } + var linkError by remember { mutableStateOf(null) } + var linkSuccess by remember { mutableStateOf(false) } + var autoRouteLinks by remember { mutableStateOf(false) } + LaunchedEffect(outputPath, outputPackage) { + stockPackage = withContext(Dispatchers.IO) { + runCatching { + val records = PatchedAppStore.shared.getAll() + records.firstOrNull { it.outputApkPath == outputPath }?.packageName + ?: outputPackage?.let { pkg -> records.firstOrNull { it.installedPackageName == pkg }?.packageName } + }.getOrNull() + } + } + // Cleanup state var hasTempFiles by remember { mutableStateOf(false) } var tempFilesSize by remember { mutableStateOf(0L) } @@ -102,6 +143,8 @@ fun ResultScreenContent(outputPath: String) { LaunchedEffect(Unit) { val config = configRepository.loadConfig() autoCleanupEnabled = config.autoCleanupTempFiles + autoRouteLinks = config.autoRouteLinksAfterInstall + disableStockLinks = config.disableStockLinksAfterInstall hasTempFiles = FileUtils.hasTempFiles() tempFilesSize = FileUtils.getTempDirSize() @@ -118,18 +161,22 @@ fun ResultScreenContent(outputPath: String) { scope.launch { isInstalling = true installError = null - installProgress = "Installing on ${device.displayName}..." + installProgress = "${if (alreadyInstalled) "Updating" else "Installing"} on ${device.displayName}..." + // Always record a non-Play installer so the Play Store won't clobber + // the patched app with an official update. + val installer = adbManager.resolveSpoofInstaller(device.id) val result = adbManager.installApk( apkPath = outputPath, deviceId = device.id, + installerPackage = installer, onProgress = { installProgress = it } ) result.fold( onSuccess = { installSuccess = true - installProgress = "Installation successful!" + installProgress = if (alreadyInstalled) "Update successful!" else "Installation successful!" }, onFailure = { exception -> installError = (exception as? AdbException)?.message ?: exception.message ?: "Unknown error" @@ -140,6 +187,46 @@ fun ResultScreenContent(outputPath: String) { } } + fun applyLinkHandling(enable: Boolean) { + val device = monitorState.selectedDevice ?: return + val patched = outputPackage ?: return + scope.launch { + isApplyingLinks = true + linkError = null + val result = adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = patched, + stockPackage = if (disableStockLinks) stockPackage else null, + enable = enable, + onProgress = { linkProgress = it }, + ) + result.fold( + onSuccess = { outcome -> + linkSuccess = enable + linkProgress = when { + !enable -> "Default link handling restored" + outcome.stockChanged -> "Links routed to patched app, stock disabled" + else -> "Links routed to patched app" + } + }, + onFailure = { e -> + linkError = (e as? AdbException)?.message ?: e.message ?: "Unknown error" + } + ) + isApplyingLinks = false + } + } + + // Auto-route links once, right after a successful install, when the global + // setting is on. outputPackage is required (the apply no-ops without it). + LaunchedEffect(installSuccess, autoRouteLinks, outputPackage) { + if (installSuccess && autoRouteLinks && outputPackage != null && + !linkSuccess && !isApplyingLinks && linkError == null + ) { + applyLinkHandling(enable = true) + } + } + Column( modifier = Modifier .fillMaxSize() @@ -225,134 +312,7 @@ fun ResultScreenContent(outputPath: String) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically) ) { - // Output file info - Box( - modifier = Modifier - .widthIn(max = 520.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(corners.medium)) - .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) - .background(MaterialTheme.colorScheme.surface) - ) { - // Teal left stripe - Box( - modifier = Modifier - .width(3.dp) - .fillMaxHeight() - .background(accents.secondary) - .align(Alignment.CenterStart) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 3.dp) - ) { - // File name (first line) + size (second line) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp) - ) { - Text( - text = "OUTPUT FILE", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - letterSpacing = 1.5.sp - ) - Spacer(Modifier.height(4.dp)) - Text( - text = outputFile.name, - fontSize = 15.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (outputFile.exists()) { - Spacer(Modifier.height(4.dp)) - Text( - text = formatFileSize(outputFile.length()), - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accents.secondary - ) - } - Spacer(Modifier.height(2.dp)) - Text( - text = outputFile.parent ?: "", - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - // Open folder button row - Row( - modifier = Modifier - .fillMaxWidth() - .drawBehind { - drawLine( - color = borderColor, - start = Offset(20.dp.toPx(), 0f), - end = Offset(size.width - 20.dp.toPx(), 0f), - strokeWidth = 1f - ) - } - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val folderHover = remember { MutableInteractionSource() } - val isFolderHovered by folderHover.collectIsHoveredAsState() - val folderColor by animateColorAsState( - if (isFolderHovered) accents.primary else accents.primary.copy(alpha = 0.7f), - animationSpec = tween(150) - ) - val folderBg by animateColorAsState( - if (isFolderHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, - animationSpec = tween(150) - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .hoverable(folderHover) - .clip(RoundedCornerShape(corners.small)) - .background(folderBg, RoundedCornerShape(corners.small)) - .border( - 1.dp, - accents.primary.copy(alpha = if (isFolderHovered) 0.5f else 0.3f), - RoundedCornerShape(corners.small) - ) - .clickable { - try { - val folder = outputFile.parentFile - if (folder != null && Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(folder) - } - } catch (_: Exception) {} - } - .padding(horizontal = 14.dp, vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "OPEN FOLDER →", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = folderColor, - letterSpacing = 0.5.sp - ) - } - } - } - } + OutputFileCard(outputFile = outputFile, corners = corners, mono = mono, borderColor = borderColor) // ADB Install section if (isAdbDisabledByUser) { @@ -366,6 +326,7 @@ fun ResultScreenContent(outputPath: String) { AdbInstallSection( devices = monitorState.devices, selectedDevice = monitorState.selectedDevice, + alreadyInstalled = alreadyInstalled, isInstalling = isInstalling, installProgress = installProgress, installError = installError, @@ -382,6 +343,30 @@ fun ResultScreenContent(outputPath: String) { }, onDismissError = { installError = null } ) + + // Link handling ("open with"). Only meaningful once the patched + // app is on the device, so gate on a successful install (or the + // app already being present) + a ready, selected device. + val device = monitorState.selectedDevice + if (outputPackage != null && device?.isReady == true && (installSuccess || alreadyInstalled)) { + LinkHandlingSection( + patchedPackage = outputPackage!!, + stockPackage = stockPackage?.takeIf { it != outputPackage }, + disableStockLinks = disableStockLinks, + onToggleDisableStock = { disableStockLinks = it }, + isApplying = isApplyingLinks, + progress = linkProgress, + error = linkError, + success = linkSuccess, + selectedDeviceName = device.displayName, + corners = corners, + mono = mono, + borderColor = borderColor, + onApply = { applyLinkHandling(enable = true) }, + onRestore = { applyLinkHandling(enable = false) }, + onDismissError = { linkError = null }, + ) + } } // Cleanup section @@ -419,34 +404,7 @@ fun ResultScreenContent(outputPath: String) { // Patch Another button Spacer(Modifier.height(4.dp)) - - val patchAnotherHover = remember { MutableInteractionSource() } - val isPatchAnotherHovered by patchAnotherHover.collectIsHoveredAsState() - val patchAnotherBg by animateColorAsState( - if (isPatchAnotherHovered) accents.primary.copy(alpha = 0.9f) else accents.primary, - animationSpec = tween(150) - ) - - Box( - modifier = Modifier - .widthIn(max = 520.dp) - .fillMaxWidth() - .height(42.dp) - .hoverable(patchAnotherHover) - .clip(RoundedCornerShape(corners.small)) - .background(patchAnotherBg, RoundedCornerShape(corners.small)) - .clickable { navigator.popUntilRoot() }, - contentAlignment = Alignment.Center - ) { - Text( - text = "PATCH ANOTHER", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = Color.White, - letterSpacing = 1.sp - ) - } + PatchAnotherButton(corners = corners, mono = mono) Spacer(Modifier.height(8.dp)) } @@ -473,6 +431,7 @@ fun ResultScreenContent(outputPath: String) { private fun AdbInstallSection( devices: List, selectedDevice: AdbDevice?, + alreadyInstalled: Boolean = false, isInstalling: Boolean, installProgress: String, installError: String?, @@ -768,7 +727,7 @@ private fun AdbInstallSection( ) { Text( text = if (selectedDevice != null) - "INSTALL ON ${selectedDevice.displayName.uppercase()}" + "${if (alreadyInstalled) "UPDATE" else "INSTALL"} ON ${selectedDevice.displayName.uppercase()}" else "SELECT A DEVICE", fontSize = 11.sp, @@ -785,6 +744,222 @@ private fun AdbInstallSection( } } +// ═══════════════════════════════════════════════════════════════════ +// LINK HANDLING ("OPEN WITH") SECTION +// ═══════════════════════════════════════════════════════════════════ + +/** + * Route the patched app's web links to it (and optionally stop the stock app + * from grabbing them). Shown only once the patched app is installed on a ready + * device. The stock-disable checkbox appears only when a rename patch was used + * (a distinct [stockPackage]); on-device, [AdbManager.setLinkHandling] still + * verifies the stock app is actually installed before touching it. + */ +@Composable +private fun LinkHandlingSection( + patchedPackage: String, + stockPackage: String?, + disableStockLinks: Boolean, + onToggleDisableStock: (Boolean) -> Unit, + isApplying: Boolean, + progress: String, + error: String?, + success: Boolean, + selectedDeviceName: String?, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + onApply: () -> Unit, + onRestore: () -> Unit, + onDismissError: () -> Unit, +) { + val accents = LocalMorpheAccents.current + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = "LINK HANDLING", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Open supported web links in the patched app instead of the browser.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + ) + + // Optional OFF half — only when a rename was used so stock + patched coexist. + if (stockPackage != null) { + Spacer(Modifier.height(12.dp)) + val stockName = SupportedApp.getDisplayName(stockPackage) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .clickable(enabled = !isApplying) { onToggleDisableStock(!disableStockLinks) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Checkbox( + checked = disableStockLinks, + onCheckedChange = { onToggleDisableStock(it) }, + enabled = !isApplying, + colors = CheckboxDefaults.colors(checkedColor = accents.secondary), + modifier = Modifier.size(20.dp) + ) + Text( + text = "Also stop $stockName from opening these links", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + } + + Spacer(Modifier.height(14.dp)) + + when { + error != null -> { + Text( + text = error, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(10.dp)) + SecondaryActionChip(text = "DISMISS", corners = corners, mono = mono, onClick = onDismissError) + } + + isApplying -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = accents.primary + ) + Spacer(Modifier.width(10.dp)) + Text( + text = progress.ifEmpty { "Applying..." }.uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + } + + success -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = accents.secondary, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = progress.ifEmpty { "Links routed to patched app" }.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 0.5.sp, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + SecondaryActionChip(text = "RESTORE", corners = corners, mono = mono, onClick = onRestore) + } + } + + else -> { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val bg by animateColorAsState( + if (isHovered) accents.secondary.copy(alpha = 0.9f) else accents.secondary, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .background(bg, RoundedCornerShape(corners.small)) + .clickable(onClick = onApply), + contentAlignment = Alignment.Center + ) { + Text( + text = "OPEN LINKS WITH PATCHED APP", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 0.5.sp + ) + } + } + } + } + } +} + +/** Small bordered text button used for secondary actions (Dismiss/Restore). */ +@Composable +private fun SecondaryActionChip( + text: String, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Box( + modifier = Modifier + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.3f else 0.12f), + RoundedCornerShape(corners.small) + ) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp + ) + } +} + // ═══════════════════════════════════════════════════════════════════ // CLEANUP SECTION // ═══════════════════════════════════════════════════════════════════ @@ -969,3 +1144,176 @@ private fun formatFileSize(bytes: Long): String { else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) } } + +@Composable +private fun OutputFileCard( + outputFile: File, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, +) { + val accents = LocalMorpheAccents.current + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + // Teal left stripe + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(accents.secondary) + .align(Alignment.CenterStart) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 3.dp) + ) { + // File name (first line) + size (second line) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + Text( + text = "OUTPUT FILE", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(4.dp)) + Text( + text = outputFile.name, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (outputFile.exists()) { + Spacer(Modifier.height(4.dp)) + Text( + text = formatFileSize(outputFile.length()), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary + ) + } + Spacer(Modifier.height(2.dp)) + Text( + text = outputFile.parent ?: "", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Open folder button row + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val folderHover = remember { MutableInteractionSource() } + val isFolderHovered by folderHover.collectIsHoveredAsState() + val folderColor by animateColorAsState( + if (isFolderHovered) accents.primary else accents.primary.copy(alpha = 0.7f), + animationSpec = tween(150) + ) + val folderBg by animateColorAsState( + if (isFolderHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .hoverable(folderHover) + .clip(RoundedCornerShape(corners.small)) + .background(folderBg, RoundedCornerShape(corners.small)) + .border( + 1.dp, + accents.primary.copy(alpha = if (isFolderHovered) 0.5f else 0.3f), + RoundedCornerShape(corners.small) + ) + .clickable { + try { + val folder = outputFile.parentFile + if (folder != null && Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(folder) + } + } catch (_: Exception) {} + } + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "OPEN FOLDER →", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = folderColor, + letterSpacing = 0.5.sp + ) + } + } + } + } +} + +@Composable +private fun PatchAnotherButton( + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, +) { + val navigator = LocalNavigator.currentOrThrow + val accents = LocalMorpheAccents.current + val patchAnotherHover = remember { MutableInteractionSource() } + val isPatchAnotherHovered by patchAnotherHover.collectIsHoveredAsState() + val patchAnotherBg by animateColorAsState( + if (isPatchAnotherHovered) accents.primary.copy(alpha = 0.9f) else accents.primary, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .height(42.dp) + .hoverable(patchAnotherHover) + .clip(RoundedCornerShape(corners.small)) + .background(patchAnotherBg, RoundedCornerShape(corners.small)) + .clickable { navigator.popUntilRoot() }, + contentAlignment = Alignment.Center + ) { + Text( + text = "PATCH ANOTHER", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 1.sp + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt b/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt new file mode 100644 index 00000000..c6198c6e --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/theme/ChannelColors.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import app.morphe.gui.util.EnabledSourcesLoader.Channel + +/** + * Semantic color for a patch source's release channel. Single source of truth so + * the source-card badge and the home-screen pill LED always agree. + * + * Two axes: channel (stable/dev) and recency (latest/older). Map to four distinct, intuitive hues: + * - green (secondary) stable + latest → recommended, current + * - amber (warning) stable + older → safe channel, update available + * - blue (tertiary) dev + latest → experimental, on the newest build + * - red (error) dev + older → experimental AND behind, riskiest + * + * UNKNOWN / not-yet-loaded falls back to the "recommended" green. + */ +@Composable +@ReadOnlyComposable +fun channelColor(channel: Channel?): Color { + val accents = LocalMorpheAccents.current + return when (channel) { + Channel.STABLE_LATEST -> accents.secondary + Channel.STABLE_OLDER -> accents.warning + Channel.DEV_LATEST -> accents.tertiary + Channel.DEV_OLDER -> MaterialTheme.colorScheme.error + null, Channel.UNKNOWN -> accents.secondary + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt b/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt index f3c3a4e1..482c86c3 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.gui.ui.theme import androidx.compose.runtime.Composable diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt index b8fa6e1c..adf40fa6 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -5,6 +5,8 @@ package app.morphe.gui.util +import app.morphe.engine.util.AppLinkCommands +import app.morphe.engine.util.SignatureIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -12,13 +14,24 @@ import java.net.InetSocketAddress import java.net.Socket /** - * Manages ADB (Android Debug Bridge) operations for installing APKs. - * Works across macOS, Linux, and Windows. + * Manages ADB (Android Debug Bridge) operations for installing APKs. Works across macOS, Linux, and Windows. */ class AdbManager { private var adbPath: String? = null + private companion object { + /* Popular store packages. The spoof installer is the first one NOT on the device — see [resolveSpoofInstaller]. */ + val SPOOF_STORE_CANDIDATES = listOf( + "com.amazon.venezia", // Amazon Appstore + "com.sec.android.app.samsungapps", // Samsung Galaxy Store + "com.huawei.appmarket", // Huawei AppGallery + "com.apkpure.aegon", // APKPure + "com.aurora.store", // Aurora Store + "org.fdroid.fdroid", // F-Droid + ) + } + /** * Set to true once [startServer] confirms Morphe was the process that * spawned the ADB daemon (vs. attaching to one that was already running — @@ -283,10 +296,23 @@ class AdbManager { /** * Install an APK on the specified device (or default device if only one connected). */ + /** + * Pick an installer-source package to spoof for [deviceId]: the most-popular + * store NOT installed on the device, so nothing real tries to manage the app + * (and the Play Store won't auto-update it). Falls back to Amazon if all present. + */ + suspend fun resolveSpoofInstaller(deviceId: String): String { + val installed = listInstalledPackages(deviceId).getOrNull() ?: emptySet() + return SPOOF_STORE_CANDIDATES.firstOrNull { it !in installed } ?: SPOOF_STORE_CANDIDATES.first() + } + suspend fun installApk( apkPath: String, deviceId: String? = null, allowDowngrade: Boolean = true, + /** Set the recorded installer package (`pm install -i`). A non-Play value + * stops the Play Store from auto-updating (and clobbering) the patched app. */ + installerPackage: String? = null, onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { val adb = findAdb() ?: return@withContext Result.failure( @@ -333,48 +359,99 @@ class AdbManager { ) } - // Build install command - val command = mutableListOf(adb) - command.add("-s") - command.add(targetDevice.id) - command.add("install") - command.add("-r") // Replace existing - if (allowDowngrade) { - command.add("-d") // Allow downgrade + // Build + run the install, factored so we can transparently retry + // without installer attribution. Stricter Android builds could reject an + // `-i` pointing at a store the user doesn't have; if that's what failed, + // we'd rather install without Play-update blocking than not install at + // all. (Validated on Android 12 that an absent `-i` is accepted; this is + // a safety net for versions we haven't tested.) + fun attemptInstall(withInstaller: Boolean): Result { + val command = mutableListOf(adb, "-s", targetDevice.id, "install", "-r") + if (allowDowngrade) command.add("-d") // Allow downgrade + if (withInstaller && !installerPackage.isNullOrBlank()) { + command.add("-i") // Record installer source (blocks Play auto-update) + command.add(installerPackage) + } + command.add(apkPath) + + onProgress("Installing on ${targetDevice.displayName}...") + Logger.info("Running: ${command.joinToString(" ")}") + + return try { + val process = ProcessBuilder(command) + .redirectErrorStream(true) + .start() + + // Read output in real-time + val output = StringBuilder() + process.inputStream.bufferedReader().forEachLine { line -> + output.appendLine(line) + onProgress(line) + Logger.debug("ADB: $line") + } + + val exitCode = process.waitFor() + val outputStr = output.toString() + + if (exitCode == 0 && outputStr.contains("Success")) { + Logger.info("APK installed successfully") + Result.success(Unit) + } else { + val errorMessage = parseInstallError(outputStr) + Logger.error("Installation failed: $errorMessage") + Result.failure(AdbException(errorMessage)) + } + } catch (e: Exception) { + Logger.error("Error installing APK", e) + Result.failure(AdbException("Installation failed: ${e.message}")) + } } - command.add(apkPath) - onProgress("Installing on ${targetDevice.displayName}...") - Logger.info("Running: ${command.joinToString(" ")}") + val withInstaller = !installerPackage.isNullOrBlank() + val first = attemptInstall(withInstaller = withInstaller) + if (first.isFailure && withInstaller) { + Logger.info("Install with '-i $installerPackage' failed; retrying without installer attribution") + attemptInstall(withInstaller = false) + } else { + first + } + } + /** + * Uninstall [packageName] from [deviceId]. Used by the "Your apps" cards so a + * patched app can be removed through Morphe (keeping our recall state accurate) + * instead of from the launcher behind our back. + * + * Treats "package not installed" as success — the desired end state (app gone) + * already holds, so the caller can refresh and move on. + */ + suspend fun uninstallApk( + packageName: String, + deviceId: String, + ): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) try { - val process = ProcessBuilder(command) + val process = ProcessBuilder(adb, "-s", deviceId, "uninstall", packageName) .redirectErrorStream(true) .start() - - // Read output in real-time - val reader = process.inputStream.bufferedReader() - val output = StringBuilder() - reader.forEachLine { line -> - output.appendLine(line) - onProgress(line) - Logger.debug("ADB: $line") - } - + val output = process.inputStream.bufferedReader().readText().trim() val exitCode = process.waitFor() - val outputStr = output.toString() - - if (exitCode == 0 && outputStr.contains("Success")) { - Logger.info("APK installed successfully") - Result.success(Unit) - } else { - val errorMessage = parseInstallError(outputStr) - Logger.error("Installation failed: $errorMessage") - Result.failure(AdbException(errorMessage)) + Logger.info("uninstall $packageName on $deviceId -> exit $exitCode: $output") + + when { + exitCode == 0 && output.contains("Success") -> Result.success(Unit) + // Already gone is the end state we wanted. + output.contains("not installed", ignoreCase = true) || + output.contains("DELETE_FAILED_INTERNAL_ERROR", ignoreCase = true) && + listInstalledPackages(deviceId).getOrNull()?.contains(packageName) == false -> + Result.success(Unit) + else -> Result.failure(AdbException("Uninstall failed: ${output.ifBlank { "exit $exitCode" }}")) } } catch (e: Exception) { - Logger.error("Error installing APK", e) - Result.failure(AdbException("Installation failed: ${e.message}")) + Logger.error("Error uninstalling $packageName", e) + Result.failure(AdbException("Uninstall failed: ${e.message}")) } } @@ -455,6 +532,155 @@ class AdbManager { } } + // ── Link handling ("open with") ────────────────────────────────────────── + + /** + * Route the patched app's declared web links to it, and optionally stop the + * stock app from handling those same links. Reverse with [enable] = false. + * + * The OFF half (stock) only runs when [stockPackage] is a real, different, + * *installed* package — i.e. a rename patch was used and stock is present. + * Otherwise it's skipped (reported via [LinkHandlingResult.stockChanged]), + * never silently no-op'd. + * + * Commands come from [AppLinkCommands] so the CLI and GUI share one source + * of truth; here we just execute them through `adb -s shell`. + * + * Must be called AFTER the patched app is installed — the package has to + * exist on the device for `pm set-app-links-*` to take effect. + */ + suspend fun setLinkHandling( + deviceId: String, + patchedPackage: String, + stockPackage: String? = null, + enable: Boolean = true, + onProgress: (String) -> Unit = {}, + ): Result = withContext(Dispatchers.IO) { + findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + // Stock OFF only applies to a genuinely different, installed package. + val installed = listInstalledPackages(deviceId).getOrNull() ?: emptySet() + val stockEligible = !stockPackage.isNullOrBlank() && + stockPackage != patchedPackage && + stockPackage in installed + + onProgress( + if (enable) "Routing links to $patchedPackage..." + else "Restoring default link handling..." + ) + + val patchedCommands = if (enable) AppLinkCommands.enablePatched(patchedPackage) + else AppLinkCommands.restorePatched(patchedPackage) + runShellCommands(deviceId, patchedCommands).onFailure { + return@withContext Result.failure(it) + } + + var stockChanged = false + if (stockEligible) { + val stockCommands = if (enable) AppLinkCommands.disableStock(stockPackage!!) + else AppLinkCommands.restoreStock(stockPackage!!) + onProgress( + if (enable) "Disabling links in $stockPackage..." + else "Re-enabling links in $stockPackage..." + ) + runShellCommands(deviceId, stockCommands).onFailure { + return@withContext Result.failure(it) + } + stockChanged = true + } + + Logger.info( + "Link handling ${if (enable) "enabled" else "restored"} for $patchedPackage" + + (if (stockChanged) " (stock $stockPackage toggled)" else "") + ) + Result.success(LinkHandlingResult(patchedChanged = true, stockChanged = stockChanged)) + } + + /** + * Run a sequence of `adb -s shell ` commands, stopping at the + * first failure. `pm set-app-links-*` print nothing on success and exit 0; a + * non-zero exit (or "Error"/"Failure" in output) is treated as a failure. + */ + private suspend fun runShellCommands( + deviceId: String, + commands: List>, + ): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure(AdbException("ADB not found")) + for (argv in commands) { + try { + val process = ProcessBuilder(listOf(adb, "-s", deviceId, "shell") + argv) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + val exitCode = process.waitFor() + Logger.debug("ADB shell ${argv.joinToString(" ")} -> exit $exitCode${if (output.isNotBlank()) ": $output" else ""}") + if (exitCode != 0 || + output.contains("Error", ignoreCase = true) || + output.contains("Failure", ignoreCase = true) + ) { + return@withContext Result.failure( + AdbException("Command failed (pm ${argv.getOrNull(1) ?: ""}): ${output.ifBlank { "exit $exitCode" }}") + ) + } + } catch (e: Exception) { + Logger.error("Error running adb shell ${argv.joinToString(" ")}", e) + return@withContext Result.failure(AdbException("Failed to run command: ${e.message}")) + } + } + Result.success(Unit) + } + + // ── Patched-app recall: device-side queries ────────────────────────────── + + /** Package names installed on [deviceId] (`pm list packages`). */ + suspend fun listInstalledPackages(deviceId: String): Result> = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure(AdbException("ADB not found")) + try { + val process = ProcessBuilder(adb, "-s", deviceId, "shell", "pm", "list", "packages") + .redirectErrorStream(true).start() + val out = process.inputStream.bufferedReader().readText() + process.waitFor() + if (process.exitValue() != 0) return@withContext Result.failure(AdbException("pm list packages failed")) + val packages = out.lineSequence() + .map { it.trim() } + .filter { it.startsWith("package:") } + .map { it.removePrefix("package:").trim() } + .filter { it.isNotBlank() } + .toSet() + Result.success(packages) + } catch (e: Exception) { + Result.failure(AdbException("pm list packages failed: ${e.message}")) + } + } + + /** + * Installed `versionName` and signing-cert id of [pkg] on [deviceId] from a + * single `dumpsys package` call. Returns `(versionName, signatureId)` (either + * may be null if absent/unparseable), or null if the package isn't dumpable. + */ + suspend fun getInstalledPackageInfo(deviceId: String, pkg: String): Pair? = + withContext(Dispatchers.IO) { + val out = dumpsysPackage(deviceId, pkg) ?: return@withContext null + val version = Regex("""versionName=(\S+)""").find(out)?.groupValues?.get(1) + val signatureId = SignatureIdentity.parseDeviceSignatureId(out) + version to signatureId + } + + private suspend fun dumpsysPackage(deviceId: String, pkg: String): String? = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext null + try { + val process = ProcessBuilder(adb, "-s", deviceId, "shell", "dumpsys", "package", pkg) + .redirectErrorStream(true).start() + val out = process.inputStream.bufferedReader().readText() + process.waitFor() + out.ifBlank { null } + } catch (e: Exception) { + null + } + } + /** * Parse output from 'adb devices -l' command. * Example line: "XXXXXXXX device usb:1-1 product:flame model:Pixel_4 device:flame transport_id:1" @@ -596,6 +822,15 @@ enum class DeviceStatus { UNKNOWN // Unknown status } +/** + * Outcome of [AdbManager.setLinkHandling]. [stockChanged] is false when the + * stock-app OFF step was skipped (no rename, or stock not installed). + */ +data class LinkHandlingResult( + val patchedChanged: Boolean, + val stockChanged: Boolean, +) + open class AdbException(message: String) : Exception(message) class AdbMultipleDevicesException( diff --git a/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt b/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt index 66458426..b4129f89 100644 --- a/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/ChecksumUtils.kt @@ -5,9 +5,8 @@ package app.morphe.gui.util +import app.morphe.engine.util.FileChecksum import java.io.File -import java.io.FileInputStream -import java.security.MessageDigest /** * Utility for calculating and verifying file checksums. @@ -18,19 +17,7 @@ object ChecksumUtils { * Calculate SHA-256 checksum of a file. * @return Lowercase hex string of the checksum */ - fun calculateSha256(file: File): String { - val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(8192) - - FileInputStream(file).use { fis -> - var bytesRead: Int - while (fis.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - } - } - - return digest.digest().joinToString("") { "%02x".format(it) } - } + fun calculateSha256(file: File): String = FileChecksum.sha256(file) /** * Verify a file's checksum against expected value. diff --git a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt index 8364a712..653a2169 100644 --- a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt +++ b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt @@ -6,8 +6,10 @@ package app.morphe.gui.util import app.morphe.engine.MultiSourceLoader +import app.morphe.gui.data.model.FollowMode import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.data.model.SourceVersionPref import app.morphe.gui.data.repository.PatchRepository import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -43,6 +45,13 @@ object EnabledSourcesLoader { val source: PatchSource, val patchFile: File? = null, val resolvedVersion: String? = null, + /** + * Newest available release tag in the resolved channel (stable/dev), + * regardless of what's currently downloaded — lets the UI flag "a newer + * patch file is available" without the user having to select it first. + * Null when unknown (offline / cache fallback). + */ + val latestAvailableVersion: String? = null, val isOffline: Boolean = false, val error: String? = null, val channel: Channel = Channel.UNKNOWN, @@ -72,7 +81,7 @@ object EnabledSourcesLoader { suspend fun loadAll( enabled: List>, patchService: PatchService, - preferredVersionsBySource: Map = emptyMap(), + prefsBySource: Map = emptyMap(), ): Result = supervisorScope { // supervisorScope (not coroutineScope) so a single source's failure // doesn't cancel the other in-flight resolves. Each async catches its @@ -82,7 +91,7 @@ object EnabledSourcesLoader { val resolved = enabled.map { (source, repo) -> async(Dispatchers.IO) { try { - resolve(source, repo, preferredVersionsBySource[source.id]) + resolve(source, repo, prefsBySource[source.id]) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -129,7 +138,7 @@ object EnabledSourcesLoader { private suspend fun resolve( source: PatchSource, repo: PatchRepository?, - preferredVersion: String?, + pref: SourceVersionPref?, ): ResolvedSource = withContext(Dispatchers.IO) { when (source.type) { PatchSourceType.LOCAL -> resolveLocal(source) @@ -138,7 +147,7 @@ object EnabledSourcesLoader { // which API to talk to based on the source's provider type. PatchSourceType.DEFAULT, PatchSourceType.GITHUB, - PatchSourceType.GITLAB -> resolveRemote(source, repo, preferredVersion) + PatchSourceType.GITLAB -> resolveRemote(source, repo, pref) } } @@ -162,7 +171,7 @@ object EnabledSourcesLoader { private suspend fun resolveRemote( source: PatchSource, repo: PatchRepository?, - preferredVersion: String?, + pref: SourceVersionPref?, ): ResolvedSource { if (repo == null) { return ResolvedSource(source = source, error = "No repository configured for source") @@ -186,17 +195,23 @@ object EnabledSourcesLoader { return ResolvedSource(source = source, error = errMsg) } - // Honor a user-pinned version if it exists in this source's releases. - // Otherwise pick latest stable, falling back to latest dev. - val release = preferredVersion - ?.let { pinned -> releases.find { it.tagName == pinned } } - ?: releases.firstOrNull { !it.isDevRelease() } - ?: releases.firstOrNull() - ?: return ResolvedSource(source = source, error = "No releases found") - - // Classify against this source's release list so the LED + badge can - // distinguish "latest stable" from "older stable" from "dev". - val latestStableTag = releases.firstOrNull { !it.isDevRelease() }?.tagName + // Resolve which release to load from this source's version preference: + // - PINNED → the exact tag (fall back to latest stable if it's gone) + // - FOLLOW_DEV → newest release overall, pre-releases included + // - FOLLOW_STABLE → newest stable (also the default when there's no pref) + // The release list is newest-first, so firstOrNull() is the newest overall. + val latestStable = releases.firstOrNull { !it.isDevRelease() } + val latestOverall = releases.firstOrNull() + val release = when (pref?.mode) { + FollowMode.PINNED -> + releases.find { it.tagName == pref.pinnedTag } ?: latestStable ?: latestOverall + FollowMode.FOLLOW_DEV -> latestOverall ?: latestStable + FollowMode.FOLLOW_STABLE, null -> latestStable ?: latestOverall + } ?: return ResolvedSource(source = source, error = "No releases found") + + // Classify where the resolved release actually sits (for the LED + badge), + // independent of which track it's following. + val latestStableTag = latestStable?.tagName val latestDevTag = releases.firstOrNull { it.isDevRelease() }?.tagName val channel = when { release.isDevRelease() && release.tagName == latestDevTag -> Channel.DEV_LATEST @@ -216,6 +231,8 @@ object EnabledSourcesLoader { source = source, patchFile = patchFile, resolvedVersion = release.tagName, + // Latest in the resolved channel — what an "update" would move to. + latestAvailableVersion = if (release.isDevRelease()) latestDevTag else latestStableTag, isOffline = false, channel = channel, ) diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt index 45532cab..e78552f9 100644 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -26,7 +26,7 @@ object FileUtils { */ val ANDROID_ARCHITECTURES = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") - private val EXTENSION_APK_BUNDLES = setOf("apkm", "xapk", "apks") + private val EXTENSION_APK_BUNDLES = app.morphe.engine.util.BundleFormats.EXTENSIONS private val EXTENSION_APK_ANY = EXTENSION_APK_BUNDLES + "apk" /** Returns the unified Morphe data root. Was: per-OS app-data folder. */ diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 5f96e184..750417b0 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -15,6 +15,7 @@ import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import app.morphe.patcher.apk.ApkUtils +import app.morphe.engine.patches.PatchBundleLoader import java.io.File import kotlin.reflect.KType import app.morphe.patcher.patch.Patch as LibraryPatch @@ -115,7 +116,7 @@ class PatchService { tmp } try { - val loadedPatches = loadPatchesFromJar(tempCopies.toSet()) + val loadedPatches = PatchBundleLoader.loadFlat(tempCopies) // Convert GUI's flat "patchName.optionKey" -> value map // to engine's Map> format @@ -150,9 +151,28 @@ class PatchService { val engineResult = PatchEngine.patch(config, onProgress) + // Build the COMPLETE failure detail (full stack traces, incl. any + // nested "Caused by:" cause). Used for both the log file and the + // UI's expandable "Details" section. The short [failureReason] + // below is only the one-line banner summary. It must never be the + // single source, or the cause gets lost (e.g. a PatchException + // ending in "...which raised an exception:" with the cause dropped). + val failureDetail: String? = if (engineResult.success) null else buildString { + engineResult.failedPatches.forEach { fp -> + appendLine("Patch '${fp.name}' failed:") + appendLine(fp.error) + } + engineResult.stepResults + .filter { !it.success && it.error != null } + .forEach { appendLine("Step ${it.step.name} failed:"); appendLine(it.error) } + }.takeIf { it.isNotBlank() } + failureDetail?.let { Logger.error("Patching failed — full detail:\n$it") } + val failureReason = if (engineResult.success) null else { // Prefer a specific failed-patch error, else the last failed // step's error (rebuild/sign), else a generic fallback. + // First line only — this is the short UI-banner summary; the + // full traces are already logged above. engineResult.failedPatches.firstOrNull()?.let { fp -> "${fp.name}: ${fp.error.lineSequence().first()}" } @@ -166,11 +186,21 @@ class PatchService { appliedPatches = engineResult.appliedPatches, failedPatches = engineResult.failedPatches.map { it.name }, failureReason = failureReason, + failureDetail = failureDetail, + packageName = engineResult.packageName, + packageVersion = engineResult.packageVersion, )) } finally { tempCopies.forEach { runCatching { it.delete() } } } - } catch (e: Exception) { + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Throwable) { + // Catch Throwable, not just Exception: a mismatched patch bundle can + // throw java.lang.Error (e.g. NoSuchMethodError when two sources ship + // the same class compiled against different patcher versions). Those + // are Errors, not Exceptions, so catch(Exception) would let them escape and the UI + // would hang on "Loading patches" forever instead of surfacing a failure. Logger.error("Patching failed", e) Result.failure(e) } @@ -276,4 +306,12 @@ data class PatchResult( // failed patch's error or — when patching succeeded but a later step // (rebuild, sign) blew up — that step's error. Null on success. val failureReason: String? = null, + // Full failure detail: complete stack traces (incl. nested "Caused by:" + // causes) for every failed patch and step. The expandable "Details" + // counterpart to the one-line [failureReason]. Null on success. + val failureDetail: String? = null, + // Surfaced from the engine so callers (e.g. patched-app history) can record + // what was actually patched. Empty when the patcher didn't report them. + val packageName: String = "", + val packageVersion: String = "", ) diff --git a/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt b/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt index 14cfa5f3..044f0ee4 100644 --- a/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.gui.util import app.morphe.gui.data.model.SupportedApp diff --git a/src/test/kotlin/app/morphe/cli/command/OptionValueConverterTest.kt b/src/test/kotlin/app/morphe/cli/command/OptionValueConverterTest.kt index 89fe5b33..70d248c1 100644 --- a/src/test/kotlin/app/morphe/cli/command/OptionValueConverterTest.kt +++ b/src/test/kotlin/app/morphe/cli/command/OptionValueConverterTest.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import kotlin.test.Test diff --git a/src/test/kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt b/src/test/kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt index edb34da6..2a641926 100644 --- a/src/test/kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt +++ b/src/test/kotlin/app/morphe/cli/command/PatchOptionsFileTest.kt @@ -1,3 +1,8 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + package app.morphe.cli.command import app.morphe.cli.command.model.PatchBundle diff --git a/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt b/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt new file mode 100644 index 00000000..74c52558 --- /dev/null +++ b/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.engine.util.AppLinkCommands +import kotlin.test.Test +import kotlin.test.assertEquals + +class AppLinkCommandsTest { + + @Test + fun `enablePatched sets master switch then approves all hosts`() { + val cmds = AppLinkCommands.enablePatched("app.morphe.android.youtube") + assertEquals( + listOf( + listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "app.morphe.android.youtube", "true"), + listOf("pm", "set-app-links-user-selection", "--user", "0", "--package", "app.morphe.android.youtube", "true", "all"), + ), + cmds, + ) + } + + @Test + fun `disableStock turns off the master switch only`() { + val cmds = AppLinkCommands.disableStock("com.google.android.youtube") + assertEquals( + listOf( + listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "com.google.android.youtube", "false"), + ), + cmds, + ) + } + + @Test + fun `restore commands invert apply commands`() { + assertEquals( + listOf(listOf("pm", "set-app-links-user-selection", "--user", "0", "--package", "p", "false", "all")), + AppLinkCommands.restorePatched("p"), + ) + assertEquals( + listOf(listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "s", "true")), + AppLinkCommands.restoreStock("s"), + ) + } + + @Test + fun `custom user id is threaded through`() { + val cmds = AppLinkCommands.enablePatched("p", user = "10") + assertEquals("10", cmds.first()[3]) + } +} diff --git a/src/test/kotlin/app/morphe/engine/PatchedAppStoreTest.kt b/src/test/kotlin/app/morphe/engine/PatchedAppStoreTest.kt new file mode 100644 index 00000000..85794838 --- /dev/null +++ b/src/test/kotlin/app/morphe/engine/PatchedAppStoreTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.engine.model.PatchedAppRecord +import kotlinx.coroutines.runBlocking +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PatchedAppStoreTest { + + private val tmpDir: File = Files.createTempDirectory("morphe-store-test").toFile() + private val storeFile: File get() = File(tmpDir, PatchedAppStore.FILE_NAME) + private fun newStore() = PatchedAppStore(storeFile) + + @AfterTest + fun cleanup() { + tmpDir.deleteRecursively() + } + + private fun record(pkg: String, apkVersion: String = "1.0", patchedAt: Long = 1L) = + PatchedAppRecord( + packageName = pkg, + displayName = pkg.substringAfterLast('.'), + apkVersion = apkVersion, + inputApkPath = "/in/$pkg.apk", + outputApkPath = "/out/$pkg-patched.apk", + patchedAt = patchedAt, + patchedWithMorpheVersion = "test", + ) + + @Test + fun `empty when no file exists`() = runBlocking { + assertEquals(emptyList(), newStore().getAll()) + assertNull(newStore().get("com.whatever")) + } + + @Test + fun `upsert then read back`() = runBlocking { + val store = newStore() + store.upsert(record("com.a")) + assertEquals(1, store.getAll().size) + assertEquals("com.a", store.get("com.a")?.packageName) + assertNull(store.get("com.missing")) + } + + @Test + fun `upsert replaces the record for the same package`() = runBlocking { + val store = newStore() + store.upsert(record("com.a", apkVersion = "1.0")) + store.upsert(record("com.a", apkVersion = "2.0")) + val all = store.getAll() + assertEquals(1, all.size) + assertEquals("2.0", all.single().apkVersion) + } + + @Test + fun `most recently upserted comes first`() = runBlocking { + val store = newStore() + store.upsert(record("com.a")) + store.upsert(record("com.b")) + assertEquals("com.b", store.getAll().first().packageName) + } + + @Test + fun `delete removes the record`() = runBlocking { + val store = newStore() + store.upsert(record("com.a")) + store.delete("com.a") + assertTrue(store.getAll().isEmpty()) + } + + @Test + fun `records persist across store instances`() = runBlocking { + PatchedAppStore(storeFile).upsert(record("com.a")) + // Fresh instance (empty cache) must read the persisted file. + assertEquals("com.a", PatchedAppStore(storeFile).get("com.a")?.packageName) + } + + @Test + fun `tolerates a corrupt file and heals on next write`() = runBlocking { + storeFile.parentFile.mkdirs() + storeFile.writeText("{ not valid json ") + val store = PatchedAppStore(storeFile) + assertEquals(emptyList(), store.getAll()) // no throw + store.upsert(record("com.a")) + assertEquals(1, PatchedAppStore(storeFile).getAll().size) + } +}