diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d35a10b..7076d122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,59 @@ +## [1.9.1-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.9.1-dev.2...v1.9.1-dev.3) (2026-06-02) + + +### Bug Fixes + +* signing improvements ([#160](https://github.com/MorpheApp/morphe-cli/issues/160)) ([166f940](https://github.com/MorpheApp/morphe-cli/commit/166f9409b1cbe00af7663545c41548ead2c189c5)) + +## [1.9.1-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.9.1-dev.1...v1.9.1-dev.2) (2026-05-31) + + +### Bug Fixes + +* Update dependencies ([83d3969](https://github.com/MorpheApp/morphe-cli/commit/83d39692541ca81b7bb555dfd60a001fbb97b3f1)) + +## [1.9.1-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0...v1.9.1-dev.1) (2026-05-31) + + +### Bug Fixes + +* Update to latest ARSCLib ([f62a179](https://github.com/MorpheApp/morphe-cli/commit/f62a1793601fcfc489f54c558265115530ab6b8d)) + +# [1.9.0](https://github.com/MorpheApp/morphe-cli/compare/v1.8.1...v1.9.0) (2026-05-29) + + +### Bug Fixes + +* Close adb when app closes ([#153](https://github.com/MorpheApp/morphe-cli/issues/153)) ([a43de5a](https://github.com/MorpheApp/morphe-cli/commit/a43de5a61ee30b7484b534cb6cc74e03bb297fa1)) +* Multi patch source minor network times out ([#155](https://github.com/MorpheApp/morphe-cli/issues/155)) ([06e5788](https://github.com/MorpheApp/morphe-cli/commit/06e57889c460cfa334af67c2910dbcb8633191f8)) + + +### Features + +* Add setting menu to save patched app crash logs to file ([#143](https://github.com/MorpheApp/morphe-cli/issues/143)) ([90836b5](https://github.com/MorpheApp/morphe-cli/commit/90836b5cedbd6d0642a819abde7c33901a7e81a1)) +* Apply patches from multiple patch bundles, add GUI patch source selector ([#145](https://github.com/MorpheApp/morphe-cli/issues/145)) ([44ed6c6](https://github.com/MorpheApp/morphe-cli/commit/44ed6c6efe5d7f97624557056b2caca23278eebf)) + +# [1.9.0-dev.4](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0-dev.3...v1.9.0-dev.4) (2026-05-26) + + +### Bug Fixes + +* Multi patch source minor network times out ([#155](https://github.com/MorpheApp/morphe-cli/issues/155)) ([06e5788](https://github.com/MorpheApp/morphe-cli/commit/06e57889c460cfa334af67c2910dbcb8633191f8)) + +# [1.9.0-dev.3](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0-dev.2...v1.9.0-dev.3) (2026-05-25) + + +### Bug Fixes + +* Close adb when app closes ([#153](https://github.com/MorpheApp/morphe-cli/issues/153)) ([a43de5a](https://github.com/MorpheApp/morphe-cli/commit/a43de5a61ee30b7484b534cb6cc74e03bb297fa1)) + +# [1.9.0-dev.2](https://github.com/MorpheApp/morphe-cli/compare/v1.9.0-dev.1...v1.9.0-dev.2) (2026-05-20) + + +### Features + +* Apply patches from multiple patch bundles, add GUI patch source selector ([#145](https://github.com/MorpheApp/morphe-cli/issues/145)) ([44ed6c6](https://github.com/MorpheApp/morphe-cli/commit/44ed6c6efe5d7f97624557056b2caca23278eebf)) + # [1.9.0-dev.1](https://github.com/MorpheApp/morphe-cli/compare/v1.8.1...v1.9.0-dev.1) (2026-05-11) diff --git a/README.md b/README.md index f24a9380..b6d6a65f 100644 --- a/README.md +++ b/README.md @@ -11,55 +11,135 @@ /> -[![Website badge](https://img.shields.io/badge/Website-gray.svg?logo=data:image/svg%2bxml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ29weXJpZ2h0IDIwMjUgTW9ycGhlLiBUaGlzIGlzIGNvcHlyaWdodGVkIGNvbnRlbnQsIGFuZCBub3QgbGljZW5zZWQgdW5kZXIgb3BlbiBzb3VyY2UgdGVybXMuCiAgICAgU2VlIGh0dHBzOi8vZ2l0aHViLmNvbS9Nb3JwaGVBcHAvbW9ycGhlLWJyYW5kaW5nIC0tPgoKPHN2ZwogICB3aWR0aD0iNTEyIgogICBoZWlnaHQ9IjUxMiIKICAgdmlld0JveD0iMCAwIDUxMiA1MTIiCiAgIHZlcnNpb249IjEuMSIKICAgaWQ9InN2ZzIiCiAgIHNvZGlwb2RpOmRvY25hbWU9Im1vcnBoZV9sb2dvX2xpZ2h0LnN2ZyIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS40LjIgKGViZjBlOTQwZDAsIDIwMjUtMDUtMDgpIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBpZD0ibmFtZWR2aWV3MiIKICAgICBwYWdlY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyY29sb3I9IiMwMDAwMDAiCiAgICAgYm9yZGVyb3BhY2l0eT0iMC4yNSIKICAgICBpbmtzY2FwZTpzaG93cGFnZXNoYWRvdz0iMiIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMC4wIgogICAgIGlua3NjYXBlOnBhZ2VjaGVja2VyYm9hcmQ9IjAiCiAgICAgaW5rc2NhcGU6ZGVza2NvbG9yPSIjZDFkMWQxIgogICAgIGlua3NjYXBlOnpvb209IjEuMTU0Mjk2OSIKICAgICBpbmtzY2FwZTpjeD0iMjU2IgogICAgIGlua3NjYXBlOmN5PSIyNTYiCiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSIxNDQwIgogICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9IjgzNiIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iMCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzIiPgogICAgPGlua3NjYXBlOnBhZ2UKICAgICAgIHg9IjAiCiAgICAgICB5PSIwIgogICAgICAgd2lkdGg9IjUxMiIKICAgICAgIGhlaWdodD0iNTEyIgogICAgICAgaWQ9InBhZ2UyIgogICAgICAgbWFyZ2luPSIwIgogICAgICAgYmxlZWQ9IjAiIC8+CiAgPC9zb2RpcG9kaTpuYW1lZHZpZXc+CiAgPGRlZnMKICAgICBpZD0iZGVmczIiIC8+CiAgPCEtLSBMZXR0ZXIgLS0+CiAgPGcKICAgICBpZD0iTGV0dGVyIgogICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiPgogICAgPHBhdGgKICAgICAgIGlkPSJMZWZ0IgogICAgICAgZD0ibSAxMjMsMTQwIGMgLTIxLDAgLTM5LDE3IC00MCwzOCB2IDE5MiBjIDEsMjEgMTksMzggNDAsMzggMjEsMCAzOSwtMTcgNDAsLTM4IFYgMTc4IGMgLTEsLTIxIC0xOSwtMzggLTQwLC0zOCB6IgogICAgICAgZmlsbD0iIzFFNUFBOCIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgICA8cGF0aAogICAgICAgaWQ9IlJpZ2h0IgogICAgICAgZD0ibSAzNDksMjg1IHYgODUgYyAxLDIxIDE5LDM4IDQwLDM4IDIxLDAgMzksLTE3IDQwLC0zOCBWIDE4MiBjIC0xMSwtMTQgLTc0LDYzIC04MCwxMDMgeiIKICAgICAgIGZpbGw9IiMwMEFGQUUiCiAgICAgICBzdHlsZT0iZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxIiAvPgogICAgPHBhdGgKICAgICAgIGlkPSJNaWRkbGUiCiAgICAgICBkPSJtIDEyNywxMDggYyAtMzQsMCAtNDQsMjUgLTQ0LDQwIHYgNTQgYyAzMCwtMzMgNzUsMjcgODAsMzMgMjgsMzIgNDQsODcgOTMsODkgNDgsLTIgNjcsLTU2IDkzLC04OSAwLDAgNDUsLTc0IDgwLC04MCAwLC0yOCAtMTEsLTQ3IC00NCwtNDcgLTM0LDAgLTU4LDUwIC03NSw3MiAtMTcsMjIgLTI1LDQ2IC01NCw0NiAtMjksMCAtMzgsLTI1IC01NCwtNDYgLTE3LC0yMiAtNDEsLTcyIC03NSwtNzIgeiIKICAgICAgIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQyKSIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgPC9nPgo8L3N2Zz4K&style=for-the-badge)](https://morphe.software) [![Documentation badge](https://img.shields.io/badge/Documentation-gray?style=for-the-badge&logo=github)](https://github.com/MorpheApp/morphe-documentation#readme) [![Subreddit badge](https://img.shields.io/badge/Reddit-gray?style=for-the-badge&logo=reddit&logoColor=white)](https://www.reddit.com/r/MorpheApp) [![X badge](https://img.shields.io/badge/X_-gray?style=for-the-badge&logo=x)](https://x.com/MorpheApp) [![Crowdin badge](https://img.shields.io/badge/Translations-gray?style=for-the-badge&logo=crowdin)](https://morphe.software/translate) +[![Website badge](https://img.shields.io/badge/Website-gray.svg?logo=data:image/svg%2bxml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ29weXJpZ2h0IDIwMjUgTW9ycGhlLiBUaGlzIGlzIGNvcHlyaWdodGVkIGNvbnRlbnQsIGFuZCBub3QgbGljZW5zZWQgdW5kZXIgb3BlbiBzb3VyY2UgdGVybXMuCiAgICAgU2VlIGh0dHBzOi8vZ2l0aHViLmNvbS9Nb3JwaGVBcHAvbW9ycGhlLWJyYW5kaW5nIC0tPgoKPHN2ZwogICB3aWR0aD0iNTEyIgogICBoZWlnaHQ9IjUxMiIKICAgdmlld0JveD0iMCAwIDUxMiA1MTIiCiAgIHZlcnNpb249IjEuMSIKICAgaWQ9InN2ZzIiCiAgIHNvZGlwb2RpOmRvY25hbWU9Im1vcnBoZV9sb2dvX2xpZ2h0LnN2ZyIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS40LjIgKGViZjBlOTQwZDAsIDIwMjUtMDUtMDgpIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBpZD0ibmFtZWR2aWV3MiIKICAgICBwYWdlY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyY29sb3I9IiMwMDAwMDAiCiAgICAgYm9yZGVyb3BhY2l0eT0iMC4yNSIKICAgICBpbmtzY2FwZTpzaG93cGFnZXNoYWRvdz0iMiIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMC4wIgogICAgIGlua3NjYXBlOnBhZ2VjaGVja2VyYm9hcmQ9IjAiCiAgICAgaW5rc2NhcGU6ZGVza2NvbG9yPSIjZDFkMWQxIgogICAgIGlua3NjYXBlOnpvb209IjEuMTU0Mjk2OSIKICAgICBpbmtzY2FwZTpjeD0iMjU2IgogICAgIGlua3NjYXBlOmN5PSIyNTYiCiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSIxNDQwIgogICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9IjgzNiIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iMCIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iMCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzIiPgogICAgPGlua3NjYXBlOnBhZ2UKICAgICAgIHg9IjAiCiAgICAgICB5PSIwIgogICAgICAgd2lkdGg9IjUxMiIKICAgICAgIGhlaWdodD0iNTEyIgogICAgICAgaWQ9InBhZ2UyIgogICAgICAgbWFyZ2luPSIwIgogICAgICAgYmxlZWQ9IjAiIC8+CiAgPC9zb2RpcG9kaTpuYW1lZHZpZXc+CiAgPGRlZnMKICAgICBpZD0iZGVmczIiIC8+CiAgPCEtLSBMZXR0ZXIgLS0+CiAgPGcKICAgICBpZD0iTGV0dGVyIgogICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiPgogICAgPHBhdGgKICAgICAgIGlkPSJMZWZ0IgogICAgICAgZD0ibSAxMjMsMTQwIGMgLTIxLDAgLTM5LDE3IC00MCwzOCB2IDE5MiBjIDEsMjEgMTksMzggNDAsMzggMjEsMCAzOSwtMTcgNDAsLTM4IFYgMTc4IGMgLTEsLTIxIC0xOSwtMzggLTQwLC0zOCB6IgogICAgICAgZmlsbD0iIzFFNUFBOCIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgICA8cGF0aAogICAgICAgaWQ9IlJpZ2h0IgogICAgICAgZD0ibSAzNDksMjg1IHYgODUgYyAxLDIxIDE5LDM4IDQwLDM4IDIxLDAgMzksLTE3IDQwLC0zOCBWIDE4MiBjIC0xMSwtMTQgLTc0LDYzIC04MCwxMDMgeiIKICAgICAgIGZpbGw9IiMwMEFGQUUiCiAgICAgICBzdHlsZT0iZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxIiAvPgogICAgPHBhdGgKICAgICAgIGlkPSJNaWRkbGUiCiAgICAgICBkPSJtIDEyNywxMDggYyAtMzQsMCAtNDQsMjUgLTQ0LDQwIHYgNTQgYyAzMCwtMzMgNzUsMjcgODAsMzMgMjgsMzIgNDQsODcgOTMsODkgNDgsLTIgNjcsLTU2IDkzLC04OSAwLDAgNDUsLTc0IDgwLC04MCAwLC0yOCAtMTEsLTQ3IC00NCwtNDcgLTM0LDAgLTU4LDUwIC03NSw3MiAtMTcsMjIgLTI1LDQ2IC01NCw0NiAtMjksMCAtMzgsLTI1IC01NCwtNDYgLTE3LC0yMiAtNDEsLTcyIC03NSwtNzIgeiIKICAgICAgIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQyKSIKICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjEiIC8+CiAgPC9nPgo8L3N2Zz4K&style=for-the-badge)](https://morphe.software) [![Documentation badge](https://img.shields.io/badge/Documentation-gray?style=for-the-badge&logo=github)](https://github.com/MorpheApp/morphe-documentation#readme) [![Subreddit badge](https://img.shields.io/badge/Reddit-gray?style=for-the-badge&logo=reddit&logoColor=white)](https://www.reddit.com/r/MorpheApp) [![Crowdin badge](https://img.shields.io/badge/Translations-gray?style=for-the-badge&logo=crowdin)](https://morphe.software/translate)
-# 💻 Morphe CLI +

Morphe Desktop

-Command-line application to use Morphe. -  -## ❓ About +## About +Morphe Desktop is a command-line and a GUI application that uses [Morphe Patcher](https://github.com/MorpheApp/morphe-patcher) to patch Android apps. -Morphe CLI is a command-line application that uses [Morphe Patcher](https://github.com/MorpheApp/morphe-patcher) to patch Android apps. +Morphe Desktop's CLI is based on the prior work of [ReVanced](https://github.com/ReVanced/revanced-cli). +The GUI is developed by the Morphe team. +All modifications made by Morphe can be found in the Git history. -Morphe CLI is based off the prior work of [ReVanced](https://github.com/ReVanced/revanced-cli). -All modifications made by Morphe, along with their dates, can be found in the Git history. -## 💪 Features +## Prerequisites +1. [Required] Java Runtime Environment 11 or above ([Azul Zulu JRE](https://www.azul.com/downloads/?version=java-11-lts&package=jre#zulu) or [OpenJDK](https://jdk.java.net/archive/)). +2. [Required] Morphe Desktop jar file (morphe-desktop-*-all.jar). You can download the most recent stable version of Morphe Desktop from [here](https://github.com/MorpheApp/morphe-cli/releases/latest). +3. [Required] Patches mpp file (patches-*.mpp). You can download the latest stable patch file from [here](https://github.com/MorpheApp/morphe-patches/releases/latest). +4. [Required] Desired app file (app.apk). You can download your apk from [APK Mirror](https://www.apkmirror.com/). +5. [Optional] [Android Debug Bridge (ADB)](https://developer.android.com/studio/command-line/adb) Only if you want to install the patched APK file on your device -Some of the features Morphe CLI provides are: +## Documentation +Learn how to use Morphe Desktop by following the [documentation](/docs/documentation.md). -- 💉 **Patch apps**: Harness Morphe Patcher to patch Android apps. -- 💾 **Install and uninstall apps**: Install and uninstall Apps via ADB, - using the Android package manager or by mounting using root permissions. -- 📃 **List patches from patch bundles**: List available patches, compatible packages, and versions. -- 💪 **Flexibility and functionality**: Apply any combination of patches to any version of Android apps. +## Getting Started +Morphe Desktop is a powerful little application that allows you to patch and install(via ADB) android apps. Although sticking to the suggested apps is recommended, +you can try and experiment with other apps to your hearts content! -## 🔽 Download +Morphe Desktop runs in two modes: +- CLI: You can run the CLI mode by directly calling it in your preferred terminal. +- GUI: You can run the GUI mode by double-clicking the .jar file. This will open the Morphe Desktop window. -You can download the most recent version of Morphe CLI from -[here](https://github.com/MorpheApp/morphe-cli/releases/latest). -Learn how to use Morphe CLI by following the [documentation](/docs). +While there are a lot of things that you can do and explore, for the time being in this section, we'll focus on running our first patching and getting a patched apk with the CLI and GUI. -## 📚 Everything else +> [!TIP] +> If this your first time using Morphe Desktop, head over to the [GUI](#gui) section instead of [CLI](#cli). +> Once you get the hang of things, you can start tinkering with the CLI! -### 📙 Contributing +### First Run -Thank you for considering contributing to Morphe CLI. -You can find the contribution guidelines [here](CONTRIBUTING.md). +#### CLI +Following the [prerequisites](#prerequisites) section will get you the two basic but very required files for most patching: +- morphe-desktop-*-all.jar file +- patches-*.mpp + +Ideally place both of these files and your desired apk (preferably YouTube for your first run) file in the same folder for now to avoid path headaches. + +##### Steps: +1. Open the terminal in the folder you have placed your files. If you are not in that folder, go there by: + ``` + cd path/to/your/folder + ``` + +2. Run the `ls` command if required to check the contents of the folder and confirm that you have all the files over there. + +3. Now run the patch command to instruct the morphe-desktop.jar to run the patching process by using the patch command on your apk file like this: + ``` + java -jar morphe-desktop-*-all.jar patch -p patches-*.mpp your_app.apk + ``` +4. This should start the patching process. You should be able to see a bunch of patches being applied like such: + ``` + INFO: Loading patches + INFO: Decoding app manifest + INFO: Setting patch options + INFO: "Override certificate pinning" disabled + . + . + . + . + . + INFO: Aligning APK + INFO: Signing APK + INFO: Saved to /your/path/your_app-patched.apk + ``` + +> [!NOTE] +> If you run into any issues or errors, please head over to the [documentation](/docs/documentation.md). + + + +5. You should now have a patched apk with the name of: + ``` + your_app-patched.apk + ``` +Voilà! This is your final patched apk. Go ahead, install this apk on your device and try it out! + +Now head over to the [documentation](/docs/documentation.md) + +#### GUI +Unlike the CLI, the GUI is much user-friendly and straight forward to understand. If this is your first time, the GUI will open in simplified mode/ non-expert mode. + + +##### Steps: +1. Double-click on the downloaded morphe-desktop-*-all.jar. It should open like this: -### 🛠️ Building +![Morphe GUI Home Screen](docs/images/readme/home_screen.png) + +2. All you need to do here drag and drop your apk/apkm into the application. Once done, click on the 'Patch' button to begin patching: + +![Morphe GUI App Selected](docs/images/readme/app_selected.png) + +3. This should start the patching process and you should be able to see something like this: + +![Morphe GUI Patching](docs/images/readme/patching.png) + +> [!NOTE] +> If you run into any issues or errors, please head over to the [documentation](/docs/documentation.md). + + +4. Once the patching is done, you will see the completed screen. You can now copy the apk over to your device and install it. If you happen to have ADB enabled, you can also directly install it via the application. + +![Morphe GUI Success](docs/images/readme/success.png) + +Bravo! That should be your first successful patch. If you run into issues or want to tinker around more, please head to the [documentation](docs/documentation.md). + + +[//]: # (## Everything else) +## Contributing +Thank you for considering contributing to Morphe Desktop. +You can find the contribution guidelines [here](CONTRIBUTING.md). -To build a Morphe CLI, you can follow the [documentation](/docs). -### 📃 Documentation +[//]: # (### Building) -You can find the documentation of Morphe CLI [here](/docs). +[//]: # (To build Morphe Desktop, you can follow the [documentation](/docs).) -## 📜 License +## License 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. diff --git a/build.gradle.kts b/build.gradle.kts index c428d2d1..d9bc4ea6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,7 @@ repositories { mavenLocal() mavenCentral() google() + maven { url = uri("https://maven.pkg.jetbrains.space/public/p/compose/dev") } maven { // A repository must be specified for some reason. "registry" is a dummy. url = uri("https://maven.pkg.github.com/MorpheApp/registry") @@ -85,8 +86,6 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.serialization.json) -// testImplementation(libs.kotlin.test) -//} // -- Networking (GUI) -------------------------------------------------- implementation(libs.ktor.client.core) @@ -94,6 +93,7 @@ dependencies { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.logging) + implementation(libs.slf4j.nop) // -- DI / Navigation (GUI) --------------------------------------------- implementation(platform(libs.koin.bom)) @@ -109,9 +109,7 @@ dependencies { implementation(libs.jna) implementation(libs.jna.platform) - // -- APK Parsing (GUI) ------------------------------------------------- - implementation(libs.apk.parser) - + // -- License attribution UI (About / Licenses screen) ----------------- implementation(libs.about.libraries.core) implementation(libs.about.libraries.m3) @@ -209,12 +207,15 @@ tasks { exclude(dependency("app.morphe:morphe-patcher")) // Ktor uses ServiceLoader exclude(dependency("io.ktor:.*")) + exclude(dependency("org.slf4j:.*")) // Koin uses reflection exclude(dependency("io.insert-koin:.*")) // Coroutines Swing provides Dispatchers.Main via ServiceLoader exclude(dependency("org.jetbrains.kotlinx:kotlinx-coroutines-swing")) // JNA uses reflection + native loading for DWM title bar tinting exclude(dependency("net.java.dev.jna:.*")) + // Skiko uses ServiceLoader for native registration. Same class of problem as Ktor / Koin / JNA above. + exclude(dependency("org.jetbrains.skiko:.*")) } mergeServiceFiles() diff --git a/docs/0_prerequisites.md b/docs/0_prerequisites.md deleted file mode 100644 index db48b6c7..00000000 --- a/docs/0_prerequisites.md +++ /dev/null @@ -1,14 +0,0 @@ -# 💼 Prerequisites - -To use Morphe CLI, you will need to fulfill specific requirements. - -## 🤝 Requirements - -- Java Runtime Environment 21 - [Azul Zulu JRE](https://www.azul.com/downloads/?version=java-21-lts&package=jre#zulu) or [OpenJDK](https://jdk.java.net/archive/) -- [Android Debug Bridge (ADB)](https://developer.android.com/studio/command-line/adb) if you want to install the patched APK file on your device - -## ⏭️ Whats next - -The following section will show you how to use Morphe CLI. - -Continue: [🛠️ Using Morphe CLI](1_usage.md) diff --git a/docs/1_usage.md b/docs/1_usage.md index 930c3026..e69de29b 100644 --- a/docs/1_usage.md +++ b/docs/1_usage.md @@ -1,188 +0,0 @@ -# 🛠️ Using Morphe CLI - -Learn how to use Morphe CLI. -The following examples will show you how to perform basic operations. -You can list patches, patch an app, uninstall, and install an app. - -## 🚀 Show all commands - -```bash -java -jar morphe-cli.jar --help -``` - -## 📃 List patches - -```bash -java -jar morphe-cli.jar list-patches --with-packages --with-versions --with-options patches.mpp -``` - -## 💉 Patch an app - -To patch an app using the default list of patches, use the `patch` command: - -```bash -java -jar morphe-cli.jar patch --patches patches.mpp input.apk -``` - -To change the default set of enabled or disabled patches, use the option `-e` or `-d` to enable or disable specific patches. -You can use the `list-patches` command to see which patches are enabled by default. - -To only enable specific patches, you can use the option `--exclusive` combined with `-e`. -Remember that the options `-e` and `-d` match the patch's name exactly. Here is an example: - -```bash -java -jar morphe-cli.jar patch --patches patches.mpp --exclusive -e "Patch name" -e "Another patch name" input.apk -``` - -You can also use the options `--ei` or `--di` to enable or disable patches by their index. -This is useful, if two patches happen to have the same name, or if typing the names is too cumbersome. -To know the indices of patches, use the command `list-patches`: - -```bash -java -jar morphe-cli.jar list-patches patches.mpp -``` - -Then you can use the indices to enable or disable patches: - -```bash -java -jar morphe-cli.jar patch --patches patches.mpp --ei 123 --di 456 input.apk -``` - -You can combine the option `-e`, `-d`, `--ei`, `--di` and `--exclusive`. Here is an example: - -```bash -java -jar morphe-cli.jar patch --patches patches.mpp --exclusive -e "Patch name" --ei 123 input.apk -``` - - -> [!TIP] -> You can use the option `-i` to automatically install the patched app after patching. -> Make sure ADB is working: -> -> ```bash -> adb shell exit -> ``` - - -> [!TIP] -> You can use the option `--mount` to mount the patched app on top of the un-patched app. -> Make sure you have root permissions and the same app you are patching and mounting over is installed on your device: -> -> ```bash -> adb shell su -c exit -> adb install input.apk -> ``` - -## 📃 Patch options - -Patches can have options you can set using the option `-O` alongside the option to include the patch by name or index. -To know the options of a patch, use the option `--with-options` when listing patches: - -```bash -java -jar morphe-cli.jar list-patches --with-options patches.mpp -``` - -Each patch can have multiple options. You can set them using the option `-O`. -For example, to set the options for the patch with the name `Patch name` -with the key `key1` and `key2` to `value1` and `value2` respectively, use the following command: - -```bash -java -jar morphe-cli.jar patch --patches patches.mpp -e "Patch name" -Okey1=value1 -Okey2=value2 input.apk -``` - -If you want to set the option value to `null`, you can omit the value: - -```bash -java -jar morphe-cli.jar patch --patches patches.mpp -i "Patch name" -Okey1 input.apk -``` - -## 📃 Patch option json - -Generate a template patch options file, or update your existing file (remove invalid json, add missing json): -```bash -java -jar morphe-cli.jar options-create --patches patches.mpp --out options.json -``` - -After modifying the json file to include/exclude patches or set any patch options, use the file with `--options-file`: -```bash -java -jar morphe-cli.jar patch --patches patches.mpp --options-file options.json input.apk -``` - -To patch with an options.json and update the json (same functionality as `options-create` above), -then add parameter `--options-update`: -```bash -java -jar morphe-cli.jar patch --patches patches.mpp --options-file options.json --options-update input.apk -``` - - -## 📃 List patches - -> [!WARNING] -> Option values are usually typed. If you set a value with the wrong type, the patch can fail. -> The value types can be seen when listing patches with the option `--with-options`. -> -> Example option values: -> -> - String: `string` -> - Boolean: `true`, `false` -> - Integer: `123` -> - Double: `1.0` -> - Float: `1.0f` -> - Long: `1234567890`, `1L` -> - List: `[item1,item2,item3]` -> - List of type `Any`: `[item1,123,true,1.0]` -> - Empty list of type `Any`: `[]` -> - Typed empty list: `int[]` -> - Typed and nested empty list: `[int[]]` -> - List with null value and two empty strings: `[null,\'\',\"\"]` -> -> Quotes and commas escaped in strings (`\"`, `\'`, `\,`) are parsed as part of the string. -> List items are recursively parsed, so you can escape values in lists: -> -> - Escaped integer as a string: `[\'123\']` -> - Escaped boolean as a string: `[\'true\']` -> - Escaped list as a string: `[\'[item1,item2]\']` -> - Escaped null value as a string: `[\'null\']` -> - List with an integer, an integer as a string and a string with a comma, and an escaped list: [`123,\'123\',str\,ing`,`\'[]\'`] -> -> Example command with an escaped integer as a string: -> -> ```bash -> java -jar morphe-cli.jar --patches patches.mpp -e "Patch name" -OstringKey=\'1\' input.apk -> ``` -## 📦 Install an app manually - -```bash -java -jar morphe-cli.jar utility install -a input.apk -``` - -> [!TIP] -> You can use the option `--mount` to mount the patched app on top of the un-patched app. -> Make sure you have root permissions and the same app you are patching and mounting over is installed on your device: -> -> ```bash -> adb shell su -c exit -> adb install input.apk -> ``` - -## 🗑️ Uninstall an app manually - -Here `` is the package name of the app you want to uninstall: - -```bash -java -jar morphe-cli.jar utility uninstall --package-name -``` - -If the app is mounted, you need to unmount it by using the option `--unmount`: - -```bash -java -jar morphe-cli.jar utility uninstall --package-name --unmount -``` - -> [!TIP] -> By default, the app is installed or uninstalled to the first connected device. -> You can append one or more devices by their serial to install or uninstall an app on your selected choice of devices: -> -> ```bash -> java -jar morphe-cli.jar utility uninstall --package-name [ ...] -> ``` diff --git a/docs/README.md b/docs/README.md index fb4620f0..172a7b7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1 @@ -# 💻 Documentation and guides of Morphe CLI - -This documentation contains topics around [Morphe CLI](https://github.com/MorpheApp/morphes-cli). - -## 📖 Table of contents - -1. [💼 Prerequisites](0_prerequisites.md) -2. [🛠️ Using Morphe CLI](1_usage.md) -3. [🔨 Building Morphe CLI](2_building.md) +#### For full documentation, see [documentation.md](documentation.md). \ No newline at end of file diff --git a/docs/documentation.md b/docs/documentation.md new file mode 100644 index 00000000..1b0dbced --- /dev/null +++ b/docs/documentation.md @@ -0,0 +1,1047 @@ +

🛠️ Morphe Desktop Documentation

+ +This is the complete documentation for Morphe Desktop. It covers all the CLI sub-commands, flags, GUI usage +and common workflows. If you're brand new, it is recommended to start with the [first-run](../README.md#first-run) section +and then come back here. + +Now that you have gone through with your first run, lets dig deeper to understand how the magic happens and how you can make it even better! + +

Table of contents

+ +- [Prerequisites](#prerequisites) +- [CLI](#cli) + - [General flags](#general-flags) + - [patch](#subcommand-1-patch) + - [list-patches](#subcommand-2-list-patches) + - [list-versions](#subcommand-3-list-versions) + - [options-create](#subcommand-4-options-create) + - [utility](#subcommand-5-utility) + - [install](#utility-install) + - [uninstall](#utility-uninstall) + - [Value Types Reference](#value-types-reference) +- [GUI](#gui) +- [Building](#building) + + +

Prerequisites

+ +1. [Required] Java Runtime Environment 21 or above ([Azul Zulu JRE](https://www.azul.com/downloads/?version=java-21-lts&package=jre#zulu), [Temurin](https://adoptium.net/temurin/releases?version=21&os=any&arch=any) or [OpenJDK](https://jdk.java.net/archive/)). +2. [Required] Morphe Desktop jar file (morphe-desktop-*-all.jar). You can download the most recent stable version of Morphe Desktop from [here](https://github.com/MorpheApp/morphe-cli/releases/latest). +3. [Required] Patches mpp file (patches-*.mpp). You can download the latest stable patch file from [here](https://github.com/MorpheApp/morphe-patches/releases/latest). +4. [Required] Desired app file (app.apk). You can download your apk from [APK Mirror](https://www.apkmirror.com/). +5. [Optional] [Android Debug Bridge (ADB)](https://developer.android.com/studio/command-line/adb) Only if you want to install the patched APK file on your device directly from your computer. + + +

CLI

+ +The CLI suite is an extremely powerful tool. Often, new features will first appear in the CLI and then will be slowly implemented onto the GUI. Hence, getting a hang of the CLI is very advantageous. + +The CLI has some general flags but is mainly divided into 5 main sub-commands (and they all lived in harmony, until the fire nation attacked. Caught that reference?): + +![img.png](images/readme/cli_overview.png) + +### General flags: + +#### 1. `-h`, `--help`: + +Shows all the general flags and sub commands available. +``` +java -jar morphe-desktop-*-all.jar --help +``` + +#### 2. `-V`, `--version`: + +Shows the current version of the morphe-desktop.jar +``` +java -jar morphe-desktop-*-all.jar --version +``` + +

Subcommand 1: patch

+ +This is the most fundamental sub-command. Add the `patch` keyword to run this sub-command. +``` +java -jar morphe-desktop-*-all.jar patch [flag/s] +``` + +Here is a quick lookup for all the flags under this subcommand: + +| Flag | Description | +|--------------------------------|---------------------------------------------------------------| +| `-p`, `--patches` | Paths to .mpp files or GitHub repo URLs | +| `--prerelease` | Fetch latest dev pre-release instead of stable release | +| *(positional arg)* | APK file to patch | +| `-o`, `--out` | Path to save the patched APK to | +| `-e`, `--enable` | Enable a patch by name | +| `--ei` | Enable a patch by index | +| `-d`, `--disable` | Disable a patch by name | +| `--di` | Disable a patch by index | +| `-O`, `--options` | Set patch option values (e.g. `-Okey=value`) | +| `--exclusive` | Disable all patches except explicitly enabled ones | +| `-f`, `--force` | Skip APK version compatibility check | +| `-i`, `--install` | Install to ADB device (optional serial) | +| `--mount` | Install by mounting over existing app (requires root) | +| `--keystore` | Path to keystore file for signing | +| `--keystore-password` | Keystore password | +| `--keystore-entry-alias` | Alias of the key pair in the keystore | +| `--keystore-entry-password` | Password for the keystore entry | +| `--signer` | Signer name in the APK signature | +| `--unsigned` | Skip signing the final APK | +| `-t`, `--temporary-files-path` | Path to store temp files | +| `--purge` | Delete temp files after patching | +| `--custom-aapt2-binary` | Deprecated. No effect, will be removed in a future release | +| `--force-apktool` | Deprecated. No effect, will be removed in a future release | +| `--striplibs` | Architectures to keep, comma-separated (e.g. `arm64-v8a,x86`) | +| `--bytecode-mode` | Bytecode mode: `FULL`, `STRIP_SAFE`, or `STRIP_FAST` | +| `--verify-with-sdk` | Verify the patched DEX/APK using an Android SDK | +| `--continue-on-error` | Continue patching if a patch fails | +| `--options-file` | Path to options JSON file | +| `--options-update` | Auto-update options JSON file after patching | +| `-r`, `--result-file` | Path to save patching result JSON | + +> [!NOTE] +> The examples used for each flag below only show the usage of that specific flag, but in practice, you'll almost always combine multiple flags together to customize your patching. Here's an example of a more complete command: +> ``` +> java -jar morphe-desktop-*-all.jar patch -p patches.mpp -o your_app_patched.apk --striplibs arm64-v8a --force --continue-on-error -d "change package name" -d "spoof signature" "your_app.apk" +> ``` + + +#### 1. `-p`, `--patches`: +Required: Yes + +Default: - + +This flag is used to specify the patch file to patch your apk. You can pass local .mpp file paths or a GitHub repository URL. When a URL is provided, Morphe will automatically download the .mpp file from the latest release and cache it for future runs. +``` +java -jar morphe-desktop-*-all.jar patch --patches patches-*.mpp your_app.apk +``` + +You can also pass a GitHub repo URL: +``` +java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches your_app.apk +``` + +Or a specific release URL: +``` +java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches/releases/tag/v1.0.0 your_app.apk +``` + + +#### 2. `--prerelease`: +Required: No + +Default: `false` + +When using a GitHub repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. +``` +java -jar morphe-desktop-*-all.jar patch --patches https://github.com/MorpheApp/morphe-patches --prerelease your_app.apk +``` + + +#### 3. Positional argument (APK file): +Required: Yes + +Default: - + +The APK file you want to patch. This is passed directly without a flag name, at the end of the command. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp your_app.apk +``` + +> [!NOTE] +> Morphe also supports `.apkm`, `.xapk`, and `.apks` files (split APK bundles). If you pass one of these, Morphe will automatically merge the splits into a single APK before patching. + + +#### 4. `-o`, `--out`: +Required: No + +Default: `-patched.apk` in the current working directory + +Specify a custom output path for the patched APK. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -o /path/to/output.apk your_app.apk +``` + + +#### 5. `-e`, `--enable`: +Required: No + +Default: - + +Enable a specific patch by its exact name. Can be used multiple times to enable several patches. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -e "Patch name" -e "Another patch" your_app.apk +``` + + +#### 6. `--ei`: +Required: No + +Default: - + +Enable a specific patch by its index number. Can be used multiple times. Use `list-patches` to find the index of each patch. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --ei 1 --ei 5 your_app.apk +``` + + +#### 7. `-d`, `--disable`: +Required: No + +Default: - + +Disable a specific patch by its exact name. Can be used multiple times. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -d "Patch name" your_app.apk +``` + + +#### 8. `--di`: +Required: No + +Default: - + +Disable a specific patch by its index number. Can be used multiple times. Use `list-patches` to find the index of each patch. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --di 456 your_app.apk +``` + +> [!TIP] +> You can combine `-e`, `-d`, `--ei`, `--di` and `--exclusive` in the same command. +> ``` +> java -jar morphe-desktop-*-all.jar patch -p patches.mpp --exclusive -e "Patch name" --ei 123 your_app.apk +> ``` + + +#### 9. `-O`, `--options`: +Required: No + +Default: - + +Set option values for patches. Options are key-value pairs passed alongside patch enable flags. To set a value to null, omit the value. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -e "Patch name" -Okey1=value1 -Okey2=value2 your_app.apk +``` + +To set an option to null: +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -e "Patch name" -Okey1 your_app.apk +``` + +> [!WARNING] +> Option values are typed. Setting a value with the wrong type can cause the patch to fail. Use `list-patches --with-options` to see the expected types. +> +> Common types: `string`, `true`/`false`, `123` (integer), `1.0` (double), `[item1,item2]` (list) + + +#### 10. `--exclusive`: +Required: No + +Default: `false` + +Disable all patches except the ones you explicitly enable with `-e` or `--ei`. Useful when you only want a specific set of patches applied. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --exclusive -e "Patch name" your_app.apk +``` + + +#### 11. `-f`, `--force`: +Required: No + +Default: `false` + +Skip the APK version compatibility check. By default, Morphe will warn you and skip patches that aren't compatible with your APK's version. This flag forces all compatible patches to run regardless. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --force your_app.apk +``` + +> [!TIP] +> Patches are built for specific app versions. +> If you're using versions that are newer or older than the recommended ones, Morphe will skip all the incompatible patches (which is almost all of them since the version don't match) by default. **Use `--force` to apply them anyway** - they may still work fine, especially on recent versions. + + +#### 12. `-i`, `--install`: +Required: No + +Default: - + +Automatically install the patched APK to a connected ADB device after patching. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i your_app.apk +``` +If no serial is provided, it installs to the first connected device. You can optionally specify a device serial. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i SERIAL123 your_app.apk +``` + +> [!TIP] +> Make sure ADB is working before using this flag: +> ``` +> adb shell exit +> ``` + + +#### 13. `--mount`: +Required: No + +Default: `false` + +Install the patched APK by mounting it on top of the original app. Requires root access and the original app to be installed on the device. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -i --mount your_app.apk +``` + +> [!NOTE] +> Make sure you have root permissions: +> ``` +> adb shell su -c exit +> ``` + + +#### 14. `--keystore`: +Required: No + +Default: Auto-generated keystore next to the output APK + +Path to a keystore file containing a private key and certificate pair to sign the patched APK with. If not specified, Morphe generates a new keystore automatically. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --keystore /path/to/keystore.jks your_app.apk +``` + + +#### 15. `--keystore-password`: +Required: No + +Default: `"Morphe"` + +Password to open the keystore file. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --keystore keystore.jks --keystore-password "mypassword" your_app.apk +``` + +> [!NOTE] +> The CLI and the Morphe Manager Android app share the same keystore defaults (alias `"Morphe"`, password `"Morphe"`), so a keystore generated by either tool works in the other without extra flags. + + +#### 16. `--keystore-entry-alias`: +Required: No + +Default: `"Morphe"` + +The alias of the private key entry inside the keystore. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --keystore keystore.jks --keystore-entry-alias "my-alias" your_app.apk +``` + + +#### 17. `--keystore-entry-password`: +Required: No + +Default: `"Morphe"` + +Password for the specific keystore entry. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --keystore keystore.jks --keystore-entry-password "mypassword" your_app.apk +``` + + +#### 18. `--signer`: +Required: No + +Default: `"Morphe"` + +The name of the signer embedded in the APK signature. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --signer "My Signer" your_app.apk +``` + + +#### 19. `--unsigned`: +Required: No + +Default: `false` + +Skip signing the patched APK entirely. The output APK will not be signed and cannot be installed directly on a device without signing it separately. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --unsigned your_app.apk +``` + + +#### 20. `-t`, `--temporary-files-path`: +Required: No + +Default: `morphe-temporary-files/` in the current working directory + +Path to a directory where Morphe stores temporary files during patching. This is also where downloaded .mpp files are cached when using URLs with `--patches`. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -t /tmp/morphe-temp your_app.apk +``` + + +#### 21. `--purge`: +Required: No + +Default: `false` + +Delete the temporary files directory after patching is complete. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --purge your_app.apk +``` + + +#### 22. `--custom-aapt2-binary`: +Required: No + +Default: - + +> [!WARNING] +> **Deprecated.** AAPT2 was only used through apktool, which has been replaced by ARSCLib — this flag now has no effect and will be removed in a future release. It's kept for backward compatibility with older scripts. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --custom-aapt2-binary /path/to/aapt2 your_app.apk +``` + + +#### 23. `--force-apktool`: +Required: No + +Default: `false` + +> [!WARNING] +> **Deprecated.** apktool has been replaced by ARSCLib — this flag now has no effect and will be removed in a future release. It's kept for backward compatibility with older scripts. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --force-apktool your_app.apk +``` + + +#### 24. `--striplibs`: +Required: No + +Default: - + +Comma-separated list of native library architectures to **keep**. All other architectures will be stripped from the APK, reducing file size. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --striplibs arm64-v8a,armeabi-v7a your_app.apk +``` + + +#### 25. `--bytecode-mode`: +Required: No + +Default: `STRIP_FAST` + +Controls how Morphe processes the APK's bytecode (DEX) during patching. Trade-off between speed, output size, and safety. + +| Value | Description | +|----------------|------------------------------------------------------------------------------------------------------------------------------| +| `FULL` | Rebuilds and includes all bytecode. Largest output, slowest, safest - use if you hit runtime issues with the stripped modes. | +| `STRIP_SAFE` | Strips bytecode that is provably unused, while keeping anything reachable via reflection or dynamic loading. | +| `STRIP_FAST` | Default. Aggressively strips unused bytecode for the smallest, fastest build. May break apps that rely on reflection. | + +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --bytecode-mode FULL your_app.apk +``` + +> [!TIP] +> If a patched app crashes at startup or has odd missing-class errors, switch to `STRIP_SAFE` or `FULL` and see if it fixes things. + +> [!NOTE] +> On Windows, Morphe currently forces bytecode mode to `FULL` regardless of what you pass to this flag (workaround for a platform-specific issue). The flag still works as expected on macOS and Linux. + + +#### 26. `--verify-with-sdk`: +Required: No + +Default: - (verification is skipped) + +Verify the patched DEX and APK files using a local Android SDK after patching. Helpful for catching corrupt output before installing on a device. Pass a path to your SDK install directory, or pass the flag with no value to let Morphe auto-discover one. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --verify-with-sdk /path/to/android-sdk your_app.apk +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --verify-with-sdk your_app.apk +``` + +> [!NOTE] +> When you pass `--verify-with-sdk` without a path, Morphe resolves the SDK in this order: +> 1. `$ANDROID_HOME` +> 2. `$ANDROID_SDK_ROOT` +> 3. OS-default install location: +> - macOS: `~/Library/Android/sdk` +> - Windows: `~/AppData/Local/Android/Sdk` +> - Linux: `~/Android/Sdk` +> +> If none of those resolve to a real SDK, Morphe errors out with a hint to either set one of the env vars or supply a path explicitly. + + +#### 27. `--continue-on-error`: +Required: No + +Default: `false` + +By default, patching stops on the first patch failure. This flag lets Morphe continue applying the remaining patches even if one fails. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --continue-on-error your_app.apk +``` + + +#### 28. `--options-file`: +Required: No + +Default: - + +Path to a JSON file that controls which patches are enabled/disabled and their option values. Generate one using the `options-create` subcommand. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --options-file options.json your_app.apk +``` + +> [!NOTE] +> If the file you specify doesn't exist yet, Morphe will automatically generate one with default values at that path and use it for the current patch. This means you can skip `options-create` entirely - just pass a path to a non-existent file and Morphe will create it for you. + +> [!TIP] +> The options file is great for repeatable patching. Generate it once (either with `options-create` or by letting `--options-file` auto-generate it), tweak it, and reuse it every time you patch. + + +#### 29. `--options-update`: +Required: No + +Default: `false` + +Automatically update the options JSON file after patching to reflect the current patches. Without this flag, the file is left unchanged. New patches get added, removed patches get cleaned up. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --options-file options.json --options-update your_app.apk +``` + + +#### 30. `-r`, `--result-file`: +Required: No + +Default: - + +Path to save a JSON file containing the patching result, including which patches succeeded, which failed, and any error details. +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -r result.json your_app.apk +``` + +--- + + +

Subcommand 2: list-patches

+ +Lists all available patches from the supplied MPP files. Useful for finding patch names, indices, compatible packages, and options before patching. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp [flag/s] +``` + +Here is a quick lookup for all the flags under this subcommand: + +| Flag | Description | +|----------------------------------|--------------------------------------------------------| +| `--patches` | Paths to .mpp files or GitHub repo URLs | +| `--prerelease` | Fetch latest dev pre-release instead of stable release | +| `-t`, `--temporary-files-path` | Path to store temporary files | +| `--out` | Write patch list to a file instead of stdout | +| `-d`, `--with-descriptions` | Show patch descriptions | +| `-p`, `--with-packages` | Show compatible packages | +| `-v`, `--with-versions` | Show compatible versions | +| `-o`, `--with-options` | Show patch options | +| `-u`, `--with-universal-patches` | Include patches compatible with any app | +| `-i`, `--index` | Show patch index | +| `-f`, `--filter-package-name` | Filter patches by package name | + + +#### 1. `--patches`: +Required: Yes + +Default: - + +One or more paths to .mpp patch files or GitHub repository URLs to list patches from. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp +java -jar morphe-desktop-*-all.jar list-patches --patches https://github.com/MorpheApp/morphe-patches +``` + + +#### 2. `--prerelease`: +Required: No + +Default: `false` + +When using a GitHub repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches https://github.com/MorpheApp/morphe-patches --prerelease +``` + + +#### 3. `-t`, `--temporary-files-path`: +Required: No + +Default: `morphe-temporary-files/` in the current working directory + +Path to a directory where Morphe stores temporary files, including cached .mpp downloads when using URLs with `--patches`. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches https://github.com/MorpheApp/morphe-patches -t /tmp/morphe-temp +``` + + +#### 4. `--out`: +Required: No + +Default: - + +Write the patch list to a file instead of printing to stdout. Useful in environments where `>` redirection is not available. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --out patches-list.txt +``` + + +#### 5. `-d`, `--with-descriptions`: +Required: No + +Default: `true` + +Show the description of each patch. Enabled by default - use `--with-descriptions=false` to hide them. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --with-descriptions=false +``` + + +#### 6. `-p`, `--with-packages`: +Required: No + +Default: `false` + +Show the packages each patch is compatible with. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --with-packages +``` + + +#### 7. `-v`, `--with-versions`: +Required: No + +Default: `false` + +Show the compatible app versions for each patch. Requires `--with-packages` to be useful. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --with-packages --with-versions +``` + + +#### 8. `-o`, `--with-options`: +Required: No + +Default: `false` + +Show the configurable options for each patch, including their keys, types, default values, and possible values. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --with-options +``` + + +#### 9. `-u`, `--with-universal-patches`: +Required: No + +Default: `true` + +Include patches that are compatible with any app (universal patches). Use `--with-universal-patches=false` to only show patches targeting specific packages. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --with-universal-patches=false +``` + + +#### 10. `-i`, `--index`: +Required: No + +Default: `true` + +Show the index of each patch. The index can be used with `--ei` and `--di` in the `patch` subcommand. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp +``` + + +#### 11. `-f`, `--filter-package-name`: +Required: No + +Default: - + +Only show patches that are compatible with the specified package name. +``` +java -jar morphe-desktop-*-all.jar list-patches --patches patches.mpp --filter-package-name com.google.android.youtube +``` + + +--- + +

Subcommand 3: list-versions

+ + +Lists the most common compatible app versions for the patches in the supplied MPP files. Useful for knowing which APK version to download before patching. +``` +java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp [flag/s] +``` + +Here is a quick lookup for all the flags under this subcommand: + +| Flag | Description | +|--------------------------------|--------------------------------------------------------| +| `--patches` | Paths to .mpp files or GitHub repo URLs | +| `--prerelease` | Fetch latest dev pre-release instead of stable release | +| `-t`, `--temporary-files-path` | Path to store temporary files | +| `-f`, `--filter-package-names` | Filter by package names | +| `-u`, `--count-unused-patches` | Include unused patches in the version count | + + +#### 1. `--patches`: +Required: Yes + +Default: - + +One or more paths to .mpp patch files or GitHub repository URLs. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs. +``` +java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp +java -jar morphe-desktop-*-all.jar list-versions --patches https://github.com/MorpheApp/morphe-patches +``` + + +#### 2. `--prerelease`: +Required: No + +Default: `false` + +When using a GitHub repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. +``` +java -jar morphe-desktop-*-all.jar list-versions --patches https://github.com/MorpheApp/morphe-patches --prerelease +``` + + +#### 3. `-t`, `--temporary-files-path`: +Required: No + +Default: `morphe-temporary-files/` in the current working directory + +Path to a directory where Morphe stores temporary files, including cached .mpp downloads when using URLs with `--patches`. +``` +java -jar morphe-desktop-*-all.jar list-versions --patches https://github.com/MorpheApp/morphe-patches -t /tmp/morphe-temp +``` + + +#### 4. `-f`, `--filter-package-names`: +Required: No + +Default: - + +Only show versions for the specified package names. Can be used to check versions for a specific app. +``` +java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp -f com.google.android.youtube +``` + + +#### 5. `-u`, `--count-unused-patches`: +Required: No + +Default: `false` + +Include patches that are not enabled by default when calculating the most common compatible versions. By default, only patches that are enabled are counted. +``` +java -jar morphe-desktop-*-all.jar list-versions --patches patches.mpp --count-unused-patches +``` + +--- + + +

Subcommand 4: options-create

+ +Creates or updates an options JSON file for controlling which patches are enabled/disabled and their option values. The generated file can be passed to the `patch` subcommand with `--options-file`. +``` +java -jar morphe-desktop-*-all.jar options-create [flag/s] +``` + +Here is a quick lookup for all the flags under this subcommand: + +| Flag | Description | +|--------------------------------|--------------------------------------------------------| +| `-p`, `--patches` | Paths to .mpp files or GitHub repo URLs | +| `--prerelease` | Fetch latest dev pre-release instead of stable release | +| `-t`, `--temporary-files-path` | Path to store temporary files | +| `-o`, `--out` | Path to the output JSON file | +| `-f`, `--filter-package-name` | Filter patches by package name | + + +#### 1. `-p`, `--patches`: +Required: Yes + +Default: - + +One or more paths to .mpp patch files or GitHub repository URLs to generate options from. When a URL is provided, Morphe downloads the .mpp file and caches it for future runs. +``` +java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json +java -jar morphe-desktop-*-all.jar options-create -p https://github.com/MorpheApp/morphe-patches -o options.json +``` + + +#### 2. `--prerelease`: +Required: No + +Default: `false` + +When using a GitHub repo URL with `--patches`, fetch the latest dev pre-release instead of the latest stable release. +``` +java -jar morphe-desktop-*-all.jar options-create -p https://github.com/MorpheApp/morphe-patches --prerelease -o options.json +``` + + +#### 3. `-t`, `--temporary-files-path`: +Required: No + +Default: `morphe-temporary-files/` in the current working directory + +Path to a directory where Morphe stores temporary files, including cached .mpp downloads when using URLs with `--patches`. +``` +java -jar morphe-desktop-*-all.jar options-create -p https://github.com/MorpheApp/morphe-patches -t /tmp/morphe-temp -o options.json +``` + + +#### 4. `-o`, `--out`: +Required: Yes + +Default: - + +Path to the output JSON file. If the file already exists, Morphe will merge the current patches into it - preserving your existing settings, adding new patches, and removing patches that no longer exist. +``` +java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json +``` + +> [!TIP] +> Run this command again after updating your .mpp file to keep your options file in sync. Existing settings are preserved. + + +#### 5. `-f`, `--filter-package-name`: +Required: No + +Default: - + +Only include patches compatible with the specified package name in the generated options file. +``` +java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json -f com.google.android.youtube +``` + + +#### Options JSON Workflow + +The options JSON file lets you save your patch preferences and reuse them across multiple patching sessions. Here's the typical workflow: + +**Step 1: Generate the options file** + +Use `options-create` to generate a JSON file with all available patches and their default settings: +``` +java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json +``` + +**Step 2: Edit the file** + +Open `options.json` in any text editor. You can enable/disable patches and set option values. The file contains a list of patch bundles, each with patch entries that look like: +```json +{ + "patchName": { + "enabled": true, + "options": { + "optionKey": "optionValue" + } + } +} +``` + +Set `"enabled": false` to disable a patch, or change option values as needed. + +**Step 3: Patch using the options file** + +Pass your customized options file to the `patch` command: +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp --options-file options.json your_app.apk +``` + +**Step 4: Keep the file in sync (optional)** + +When you update your .mpp file to a newer version, patches may be added or removed. You have two ways to sync: + +- Re-run `options-create` - this merges new patches in while preserving your existing settings: + ``` + java -jar morphe-desktop-*-all.jar options-create -p patches.mpp -o options.json + ``` + +- Use `--options-update` during patching - this auto-updates the file after patching: + ``` + java -jar morphe-desktop-*-all.jar patch -p patches.mpp --options-file options.json --options-update your_app.apk + ``` + +> [!NOTE] +> CLI flags (`-e`, `-d`, `--ei`, `--di`, `-O`) always take precedence over the options file. If you enable a patch via CLI that the options file disables, the CLI wins. Morphe will log when this happens. + +> [!TIP] +> You can also skip `options-create` entirely. If you pass `--options-file` with a path that doesn't exist yet, Morphe will auto-generate the file with defaults for you. + +--- + + +

Subcommand 5: utility

+ +Parent command for utility operations like manually installing or uninstalling apps via ADB. Has two sub-subcommands: `install` and `uninstall`. + + +#### `utility install` + +Manually install an APK file to one or more ADB-connected devices. +``` +java -jar morphe-desktop-*-all.jar utility install [flag/s] [device-serial...] +``` + +| Flag | Description | +|--------------------|-------------------------------------------------------------| +| `-a`, `--apk` | APK file to install | +| `-m`, `--mount` | Mount over an existing app (requires package name and root) | +| *(positional arg)* | ADB device serial(s) | + + +##### 1. `-a`, `--apk`: +Required: Yes + +Default: - + +Path to the APK file you want to install. +``` +java -jar morphe-desktop-*-all.jar utility install -a patched_app.apk +``` + + +##### 2. `-m`, `--mount`: +Required: No + +Default: - + +Mount the APK on top of an existing app instead of a regular install. Pass the package name of the app to mount over. Requires root access. +``` +java -jar morphe-desktop-*-all.jar utility install -a patched_app.apk -m com.google.android.youtube +``` + + +##### 3. Positional argument (device serials): +Required: No + +Default: First connected device + +One or more ADB device serials to install to. If not provided, installs to the first connected device. +``` +java -jar morphe-desktop-*-all.jar utility install -a patched_app.apk SERIAL1 SERIAL2 +``` + +--- + + +#### `utility uninstall` + +Manually uninstall a patched app from one or more ADB-connected devices. +``` +java -jar morphe-desktop-*-all.jar utility uninstall [flag/s] [device-serial...] +``` + +| Flag | Description | +|------------------------|--------------------------------------| +| `-p`, `--package-name` | Package name of the app to uninstall | +| `-u`, `--unmount` | Unmount instead of uninstall | +| *(positional arg)* | ADB device serial(s) | + + +##### 1. `-p`, `--package-name`: +Required: Yes + +Default: - + +The package name of the app to uninstall. +``` +java -jar morphe-desktop-*-all.jar utility uninstall -p com.google.android.youtube +``` + + +##### 2. `-u`, `--unmount`: +Required: No + +Default: `false` + +If the app was installed by mounting (using `--mount`), use this flag to unmount it instead of a regular uninstall. +``` +java -jar morphe-desktop-*-all.jar utility uninstall -p com.google.android.youtube --unmount +``` + + +##### 3. Positional argument (device serials): +Required: No + +Default: First connected device + +One or more ADB device serials to uninstall from. If not provided, uninstalls from the first connected device. +``` +java -jar morphe-desktop-*-all.jar utility uninstall -p com.google.android.youtube SERIAL1 SERIAL2 +``` + +--- + + +### Value Types Reference + +When setting patch options with `-O` or in an options JSON file, values are typed. Using the wrong type can cause a patch to fail. Here are the supported types and how to format them: + +| Type | Example | Notes | +|-----------------------|------------------------|------------------------------------| +| String | `string` | Plain text | +| Boolean | `true`, `false` | | +| Integer | `123` | Whole numbers | +| Double | `1.0` | Decimal numbers | +| Float | `1.0f` | Decimal with `f` suffix | +| Long | `1234567890`, `1L` | Large numbers, optional `L` suffix | +| List | `[item1,item2,item3]` | Comma-separated, no spaces | +| List (mixed types) | `[item1,123,true,1.0]` | Items are parsed by their type | +| Empty list (any type) | `[]` | | +| Typed empty list | `int[]` | Empty list of a specific type | +| Nested empty list | `[int[]]` | | +| List with null/empty | `[null,'','"]` | | + +**Escaping:** + +Quotes and commas inside strings need to be escaped with `\`: +- `\"` - escaped double quote +- `\'` - escaped single quote +- `\,` - escaped comma (treated as part of the string, not a list separator) + +List items are parsed recursively, so escaping works inside lists too: + +| What you want | How to write it | +|---------------------|-----------------------| +| Integer as a string | `[\'123\']` | +| Boolean as a string | `[\'true\']` | +| List as a string | `[\'[item1,item2]\']` | +| Null as a string | `[\'null\']` | + +**Example command:** +``` +java -jar morphe-desktop-*-all.jar patch -p patches.mpp -e "Patch name" -OstringKey=\'1\' your_app.apk +``` + +This sets `stringKey` to the string `"1"` instead of the integer `1`. + + +

GUI

+ +Are you tired of memorizing flags? Is your terminal history just 47 variations of the same command, +each one slightly more wrong than the last? Do you find yourself copy-pasting from the documentation above and STILL somehow misspelling +basic version name? Then fear not, the GUI is for you! + +The GUI, also known as "" is the window you get when you double-click the jar file. +"But hey... I don't need a GUI, I'm a power user." No you're not Kyle, you've been staring at the same +error message for 20 minutes because you forgot a quote somewhere, and you have no idea where. So let's +learn how to practically use it. + +First off, that entire CLI section you just scrolled through? All those flags, the keystore nonsense, +the options JSON workflow? Forget it. All of it. + +In your [first run](../README.md#first-run), you would've encountered the non-expert mode of the GUI. +This mode is for beginners and quick patches. + +Coming soon. diff --git a/docs/images/readme/app_selected.png b/docs/images/readme/app_selected.png new file mode 100644 index 00000000..c880ad94 Binary files /dev/null and b/docs/images/readme/app_selected.png differ diff --git a/docs/images/readme/cli_overview.png b/docs/images/readme/cli_overview.png new file mode 100644 index 00000000..defb2f67 Binary files /dev/null and b/docs/images/readme/cli_overview.png differ diff --git a/docs/images/readme/home_screen.png b/docs/images/readme/home_screen.png new file mode 100644 index 00000000..145e59f5 Binary files /dev/null and b/docs/images/readme/home_screen.png differ diff --git a/docs/images/readme/patching.png b/docs/images/readme/patching.png new file mode 100644 index 00000000..889be72f Binary files /dev/null and b/docs/images/readme/patching.png differ diff --git a/docs/images/readme/success.png b/docs/images/readme/success.png new file mode 100644 index 00000000..8639a86b Binary files /dev/null and b/docs/images/readme/success.png differ diff --git a/gradle.properties b/gradle.properties index 84ba7dd2..f363fc2a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.parallel = true org.gradle.caching = true kotlin.code.style = official -version = 1.9.0-dev.1 +version = 1.9.1-dev.3 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index daa6efad..7b8bfdba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,15 +5,15 @@ kotlin = "2.3.21" # CLI picocli = "4.7.7" -arsclib = "d78a66bcee" -morphe-patcher = "1.5.1" +arsclib = "a28c6fb2a7" +morphe-patcher = "1.5.2-dev.1" morphe-library = "1.3.0" # Compose Desktop compose = "1.10.3" # Networking -ktor = "3.4.3" +ktor = "3.5.0" # DI koin-bom = "4.2.1" @@ -28,11 +28,11 @@ kotlinx-serialization = "1.11.0" # JNA (Windows DWM title bar tinting) jna = "5.18.1" -# APK -apk-parser = "2.6.10" - # Testing -mockk = "1.14.9" +mockk = "1.14.11" + +# Logging +slf4j = "2.0.18" # Libraries about-libraries = "14.1.0" @@ -74,13 +74,13 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } -# APK -apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" } - # Testing kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +# Logging +slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } + # About Libraries about-libraries-core = { group = "com.mikepenz", name = "aboutlibraries-compose-core", version.ref = "about-libraries" } about-libraries-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "about-libraries" } diff --git a/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt b/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt new file mode 100644 index 00000000..80cf7814 --- /dev/null +++ b/src/main/kotlin/app/morphe/cli/command/CliHttpClient.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.cli.command + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json + +/** + * Lazy initialized HttpClient for CLI commands. One client per process is fine for short-lived + * `morhpe-cli ....` invocations. Engine remote sources (like GitHub and GitLab) require this to be passed in. + * + * We could later swap `by lazy` for `fun create()` if we ever want the CLI to share lifecycle with anything else. + */ +object CliHttpClient { + val instance: HttpClient by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { json() } + } + } +} diff --git a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt index 78fcf0f2..523389c4 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListCompatibleVersions.kt @@ -1,5 +1,6 @@ package app.morphe.cli.command +import app.morphe.engine.MorpheData import app.morphe.patcher.patch.PackageName import app.morphe.patcher.patch.VersionMap import app.morphe.patcher.patch.loadPatchesFromJar @@ -83,13 +84,14 @@ internal class ListCompatibleVersions : Runnable { appendLine(versions.buildVersionsString().prependIndent("\t")) } - val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir try { patchesFiles = PatchFileResolver.resolve( patchesFiles, prerelease, - temporaryFilesPath + temporaryFilesPath, + CliHttpClient.instance ) } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( diff --git a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt index 94384eaf..bf2a3156 100644 --- a/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/ListPatchesCommand.kt @@ -8,6 +8,7 @@ package app.morphe.cli.command +import app.morphe.engine.MorpheData import app.morphe.patcher.patch.Package import app.morphe.patcher.patch.Patch import app.morphe.patcher.patch.loadPatchesFromJar @@ -181,13 +182,14 @@ internal object ListPatchesCommand : Runnable { } ?: withUniversalPatches - val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir try { patchesFiles = PatchFileResolver.resolve( patchesFiles, prerelease, - temporaryFilesPath + temporaryFilesPath, + CliHttpClient.instance ) } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index 00ac126c..3229d55c 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -1,10 +1,12 @@ package app.morphe.cli.command import app.morphe.cli.command.model.PatchBundle +import app.morphe.engine.MorpheData +import app.morphe.engine.patches.LoadedBundle +import app.morphe.engine.patches.PatchBundleLoader import app.morphe.cli.command.model.findMatchingBundle import app.morphe.cli.command.model.mergeWithBundle import app.morphe.cli.command.model.withUpdatedBundle -import app.morphe.patcher.patch.loadPatchesFromJar import kotlinx.serialization.json.Json import picocli.CommandLine import picocli.CommandLine.Command @@ -71,14 +73,19 @@ internal object OptionsCommand : Callable { private val json = Json { prettyPrint = true } override fun call(): Int { - val temporaryFilesPath = temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir try { - patchesFiles = PatchFileResolver.resolve( - patchesFiles, - prerelease, - temporaryFilesPath - ) + // Since we could have many URLs, we resolve each of them separately + patchesFiles = patchesFiles.map { file -> + val resolved = PatchFileResolver.resolve( + setOf(file), + prerelease, + temporaryFilesPath, + CliHttpClient.instance + ) + resolved.single() + }.toSet() } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( spec.commandLine(), @@ -87,51 +94,64 @@ internal object OptionsCommand : Callable { } return try { - logger.info("Loading patches") - - val patches = loadPatchesFromJar(patchesFiles) + logger.info("Loading patches...") - val filtered = packageName?.let { pkg -> - patches.filter { patch -> - patch.compatiblePackages?.any { (name, _) -> name == pkg } ?: true - }.toSet() - } ?: patches + // Load each bundle separately so we produce one JSON entry per .mpp + // matches the shape PatchCommand expects when reading --options-file. + val loadedBundles: List = PatchBundleLoader.loadEach(patchesFiles) - // Read existing bundles list if the file already exists - val existingBundles: List? = if (outputFile.exists()) { + // Read existing bundles list if the file already exists. + val existingBundles: List = if (outputFile.exists()) + { try { Json.decodeFromString>(outputFile.readText()) } catch (e: Exception) { - logger.warning("Could not parse existing file, creating fresh: ${e.message}") - null + logger.warning( + "Could not parse existing file, creating fresh: ${e.message}" + ) + emptyList() } - } else null - - // Find the bundle matching the current .mpp file(s), merge with it (or create fresh) - val existingBundle = existingBundles?.findMatchingBundle(patchesFiles) - val updatedBundle = filtered.mergeWithBundle( - existing = existingBundle, - sourceFiles = patchesFiles, - ) - - // Replace the matching entry in the list (or start a new list) - val updatedBundles = existingBundles?.withUpdatedBundle(updatedBundle) - ?: listOf(updatedBundle) + } else emptyList() + + // For each bundle: apply optional package filter, find its matching JSON + // entry (by source filename), merge, splice updated entry back into the running list. + var updatedBundles = existingBundles + loadedBundles.forEach { lb -> + val filtered = packageName?.let { pkg -> + lb.patches.filter { patch -> + patch.compatiblePackages?.any { (name, _) -> name == pkg } ?: true + }.toSet() + } ?: lb.patches + + val existingBundle = updatedBundles.findMatchingBundle(setOf(lb.sourceFile)) + val updatedBundle = filtered.mergeWithBundle( + existing = existingBundle, + sourceFiles = setOf(lb.sourceFile), + ) + updatedBundles = updatedBundles.withUpdatedBundle(updatedBundle) + + // Per-bundle log line so users can see what changed for each .mpp + if (existingBundle != null) { + val existingNames = existingBundle.patches.keys.map { it.lowercase() }.toSet() + val newNames = updatedBundle.patches.keys.map { it.lowercase() }.toSet() + val added = newNames - existingNames + val removed = existingNames - newNames + val kept = newNames.intersect(existingNames) + + logger.info( + "Updated bundle for ${lb.sourceFile.name}: ${kept.size} preserved, ${added.size} added, ${removed.size} removed" + ) + } else { + logger.info( + "Created new bundle for ${lb.sourceFile.name} with ${updatedBundle.patches.size} patches" + ) + } + } outputFile.absoluteFile.parentFile?.mkdirs() outputFile.writeText(json.encodeToString(updatedBundles)) - if (existingBundle != null) { - val existingNames = existingBundle.patches.keys.map { it.lowercase() }.toSet() - val newNames = updatedBundle.patches.keys.map { it.lowercase() }.toSet() - val added = newNames - existingNames - val removed = existingNames - newNames - val kept = newNames.intersect(existingNames) - logger.info("Updated bundle in options file at ${outputFile.path}") - logger.info(" ${kept.size} patches preserved, ${added.size} added, ${removed.size} removed") - } else { - logger.info("Created new bundle in options file at ${outputFile.path} with ${updatedBundle.patches.size} patches") - } + logger.info("Options file saved to ${outputFile.path}") EXIT_CODE_SUCCESS } catch (e: Exception) { diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 5624f7bf..4134eb3a 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -9,14 +9,16 @@ package app.morphe.cli.command import app.morphe.cli.command.model.* +import app.morphe.engine.MorpheData import app.morphe.engine.PatchEngine import app.morphe.engine.isWindows import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME -import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_ALIAS -import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_PASSWORD import app.morphe.engine.UpdateChecker +import app.morphe.engine.util.signWithLegacyFallback +import app.morphe.engine.patches.LoadedBundle +import app.morphe.engine.patches.PatchBundleLoader import app.morphe.library.installation.installer.* import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig @@ -33,6 +35,7 @@ import app.morphe.patcher.patch.setOptions import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToStream import org.jetbrains.annotations.VisibleForTesting @@ -64,10 +67,22 @@ internal object PatchCommand : Callable { @Spec private lateinit var spec: CommandSpec - @ArgGroup(exclusive = false, multiplicity = "0..*") - private var selection = mutableSetOf() + @ArgGroup(exclusive = false, multiplicity = "1..*") + private var bundles = mutableListOf() - internal class Selection { + internal class BundleArgs { + @CommandLine.Option( + names = ["-p", "--patches"], + description = ["Path to a MPP file or a GitHub/Gitlab repo url such as https://github.com/MorpheApp/morphe-patches (Supports multiple patch files)"], + required = true, + ) + lateinit var patchesFile: File + + @ArgGroup(exclusive = false, multiplicity = "0..*") + var selections = mutableListOf() + } + + internal class Selection{ @ArgGroup(exclusive = false) internal var enabled: EnableSelection? = null @@ -222,7 +237,8 @@ internal object PatchCommand : Callable { @CommandLine.Option( names = ["--purge"], - description = ["Purge temporary files directory after patching."], + description = ["Delete THIS run's scratch files after patching. " + + "Does not affect cached patches, other sessions, or config."], showDefaultValue = ALWAYS, ) private var purge: Boolean = false @@ -244,18 +260,6 @@ internal object PatchCommand : Callable { private lateinit var apk: File - @CommandLine.Option( - names = ["-p", "--patches"], - description = ["Path to a MPP file or a GitHub repo url such as https://github.com/MorpheApp/morphe-patches"], - required = true, - ) - @Suppress("unused") - private fun setPatchesFile(patchesFiles: Set) { - this.patchesFiles = checkFileExistsOrIsUrl(patchesFiles, spec) - } - - private var patchesFiles = emptySet() - @CommandLine.Option( names = ["--prerelease"], description = ["Fetch the latest dev pre-release instead of the stable main release from the repo provided in --patches."], @@ -410,17 +414,25 @@ internal object PatchCommand : Callable { // region Setup - val outputFilePath = - outputFilePath ?: File("").absoluteFile.resolve( - "${apk.nameWithoutExtension}-patched.apk", + // Default output uses the unified scheme shared with the GUI: + // //-Morphe-{apkVer}-patches-{patchesVer}.apk + // The folder name uses the APK's human-friendly label (e.g. "Youtube") + // when readable from the manifest, falling back to the filename for + // corrupt or unparseable APKs. GUI populates this from apkInfo; + // CLI parses the APK here so both surfaces produce identical paths. + // Users who want the legacy `./-patched.apk` layout pass --out. + val outputFilePath = outputFilePath ?: run { + val displayName = app.morphe.engine.util.ApkOutputNaming.resolveAppDisplayName(apk) + app.morphe.engine.util.ApkOutputNaming.outputApkPath( + inputApk = apk, + patchesFile = bundles.firstOrNull()?.patchesFile, + appDisplayName = displayName, ) + } - val temporaryFilesPath = - temporaryFilesPath ?: File("").absoluteFile.resolve("morphe-temporary-files") + val temporaryFilesPath = temporaryFilesPath ?: MorpheData.tmpDir - val keystoreFilePath = - keyStoreFilePath ?: outputFilePath.parentFile - .resolve("${outputFilePath.nameWithoutExtension}.keystore") + val keystoreFilePath = keyStoreFilePath ?: MorpheData.defaultKeystoreFile val installer = if (deviceSerial != null) { val deviceSerial = deviceSerial!!.ifEmpty { null } @@ -459,10 +471,19 @@ internal object PatchCommand : Callable { // Lightweight snapshot of patch metadata for use in finally block (auto-update). // Lightweight snapshot of current bundle metadata for use in finally block (auto-update). // The heavy Patch objects hold DEX classloaders and must not leak into finally. - var patchesSnapshot: PatchBundle? = null + var patchesSnapshotForFinally: List = emptyList() try { - patchesFiles = PatchFileResolver.resolve(patchesFiles, prerelease, temporaryFilesPath) + // We resolve each bundle's URL separately. + bundles.forEach { bundle -> + val resolved = PatchFileResolver.resolve( + setOf(bundle.patchesFile), + prerelease, + temporaryFilesPath, + CliHttpClient.instance + ) + bundle.patchesFile = resolved.single() + } } catch (e: IllegalArgumentException) { throw CommandLine.ParameterException( spec.commandLine(), @@ -470,60 +491,101 @@ internal object PatchCommand : Callable { ) } + // Per-session scratch dir. Hoisted out of the patching `try` block so + // the `finally` block can reference it for --purge scope (Phase 6). + // Naming matches the GUI's FileUtils.createPatchingTempDir() so the + // tmp/ folder shows consistent siblings across CLI + GUI sessions. + val patcherTemporaryFilesPath = + temporaryFilesPath.resolve("patching-${System.currentTimeMillis()}").also { it.mkdirs() } + try { - logger.info("Loading patches") - val patches: MutableSet> = loadPatchesFromJar(patchesFiles).toMutableSet() - patchesSnapshot = patches.toPatchBundle(sourceFiles = patchesFiles) + logger.info("Loading patches...") - // region Parse options JSON + // We load each bundle separately so each bundle's options can be scoped correctly. + val loadedBundles: List = PatchBundleLoader.loadEach( + bundles.map { it.patchesFile } + ) - val patchOptionsBundle: PatchBundle? = optionsFilePath?.let { file -> - if (file.exists()) { + + val patches: MutableSet> = loadedBundles.flatMap { it.patches }.toMutableSet() + val patchSnapshots: List = loadedBundles.map { lb -> + lb.patches.toPatchBundle(sourceFiles = setOf(lb.sourceFile)) + } + + // region Parse options JSON + val patchOptionsByFile: Map = optionsFilePath?.let { file -> + if (file.exists()){ logger.info("Reading options from ${file.path}") - val bundles = Json.decodeFromString>(file.readText()) - bundles.findMatchingBundle(patchesFiles) + val jsonBundles = Json.decodeFromString>(file.readText()) + loadedBundles.associate { lb -> + lb.sourceFile to jsonBundles.findMatchingBundle(setOf(lb.sourceFile)) + } } else { logger.info("Options file ${file.path} does not exist, generating with defaults") - val bundle = patches.toPatchBundle(sourceFiles = patchesFiles) + val freshBundles = patchSnapshots val json = Json { prettyPrint = true } file.absoluteFile.parentFile?.mkdirs() - file.writeText(json.encodeToString(listOf(bundle))) + file.writeText(json.encodeToString(freshBundles)) logger.info("Generated options file at ${file.path}") - bundle + loadedBundles.zip(freshBundles).associate { (lb, b) -> + lb.sourceFile to b + } } + } ?: emptyMap() + + // Per-bundle JSON-sourced enable/disable. Same patch name in two bundles can + // have different enabled states across mpps. + val jsonEnabledByFile: Map> = patchOptionsByFile.mapValues { (_, bundle) -> + bundle?.patches?.filter { it.value.enabled }?.keys?.map { + it.lowercase() }?.toSet() + ?: emptySet() } - // Build enable/disable sets from JSON (lowercase for case-insensitive matching) - val jsonEnabledPatches = patchOptionsBundle?.patches - ?.filter { (_, entry) -> entry.enabled } - ?.keys?.map { it.lowercase() }?.toSet() ?: emptySet() - val jsonDisabledPatches = patchOptionsBundle?.patches - ?.filter { (_, entry) -> !entry.enabled } - ?.keys?.map { it.lowercase() }?.toSet() ?: emptySet() - - // Build options map from JSON, deserializing values using each patch's option types - val jsonOptionsMap: Map> = patchOptionsBundle?.patches - ?.mapNotNull { (patchName, entry) -> - if (entry.options.isEmpty()) return@mapNotNull null - val patch = patches.firstOrNull { it.name.equals(patchName, ignoreCase = true) } - ?: return@mapNotNull null - val resolvedName = patch.name ?: return@mapNotNull null - val deserializedOptions = entry.options.mapNotNull { (key, element) -> - if (!patch.options.containsKey(key)) return@mapNotNull null - val option = patch.options[key] - try { - key to deserializeOptionValue(element, option.type) - } catch (e: Exception) { - logger.warning("Failed to deserialize option \"$key\" for \"$patchName\": ${e.message}") - null - } - }.toMap() - if (deserializedOptions.isEmpty()) null else resolvedName to deserializedOptions - }?.toMap() ?: emptyMap() + val jsonDisabledByFile: Map> = patchOptionsByFile.mapValues { (_, bundle) -> + bundle?.patches?.filter { !it.value.enabled }?.keys?.map { + it.lowercase() }?.toSet() + ?: emptySet() + } + + // Per-bundle options map. Same as before but indexed by source file. + // Option values are deserialized using the patch types from that file's bundle, not the global pool. + val jsonOptionsByFile: Map>> = + loadedBundles.associate { lb -> + val bundle = patchOptionsByFile[lb.sourceFile] + val opts = bundle?.patches?.mapNotNull { (patchName, entry) -> + if (entry.options.isEmpty()) return@mapNotNull null + val patch = lb.patches.firstOrNull { + it.name.equals(patchName, ignoreCase = true) + } ?: return@mapNotNull null + val resolvedName = patch.name ?: return@mapNotNull null + val deserializedOptions = entry.options.mapNotNull { (key, element) -> + if (!patch.options.containsKey(key)) return@mapNotNull null + val option = patch.options[key] + try { + key to deserializeOptionValue(element, option.type) + } catch (e: Exception) { + logger.warning( + "Failed to deserialize option $key for $patchName in ${lb.sourceFile.name}: ${e.message}" + ) + null + } + }.toMap() + + if (deserializedOptions.isEmpty()) null + else resolvedName to deserializedOptions + }?.toMap() ?: emptyMap() + lb.sourceFile to opts + } + + // Hand the per-bundle snapshots off to the finally block before we + // enter the Patcher use{} (which holds DEX classloaders we don't + // want leaking into finally). + patchesSnapshotForFinally = patchSnapshots // endregion - val patcherTemporaryFilesPath = temporaryFilesPath.resolve("patcher") + // (patcherTemporaryFilesPath is declared above the outer try + // block so it's visible to --purge in the finally clause.) // We need to check for apkm (like reddit), xapk and apks formats here @@ -547,6 +609,7 @@ internal object PatchCommand : Callable { apk } + logger.info("Initializing patcher...") val (packageName, patcherResult) = Patcher( PatcherConfig( inputApk, @@ -569,141 +632,161 @@ internal object PatchCommand : Callable { patchingResult.packageName = packageName patchingResult.packageVersion = packageVersion - // Warn if options file is out of date (only for patches compatible with this app) - if (patchOptionsBundle != null && optionsFilePath?.exists() == true && !updateOptions) { - val compatiblePatchNames = patches - .filter { patch -> - patch.compatiblePackages == null || - patch.compatiblePackages!!.any { (name, _) -> name == packageName } - } - .mapNotNull { it.name?.lowercase() } - .toSet() - // All patch names in the .mpp regardless of app compatibility. - // Used for "removed" detection: a patch is only truly removed if it's - // gone from the .mpp entirely, not just incompatible with this app. - val allMppPatchNames = patches.mapNotNull { it.name?.lowercase() }.toSet() - val jsonPatchNames = patchOptionsBundle.patches.keys.map { it.lowercase() }.toSet() - - val newPatches = compatiblePatchNames - jsonPatchNames - val oldPatches = jsonPatchNames - compatiblePatchNames - val removedPatches = jsonPatchNames - allMppPatchNames - - // Check for new option keys within existing patches. For better messaging, store it as a map and show users which patch is outdated instead of just a number. - val patchesWithNewOptions = mutableMapOf>() - val patchesWithOldOptions = mutableMapOf>() - - for ((patchName, _) in patchesSnapshot.patches) { - if (patchName.lowercase() !in compatiblePatchNames) continue - val jsonEntry = patchOptionsBundle.patches.entries - .firstOrNull { it.key.equals(patchName, ignoreCase = true) }?.value - ?: continue - - // We compare from the patches list instead of the snapshot making it much better and accurate. - // Snapshot keeps merging all patches with same names but different options making it a problem. - val actualPatch = patches.find { - it.name.equals(patchName, ignoreCase = true) && - (it.compatiblePackages == null || it.compatiblePackages!!.any { - (name, _) -> name == packageName - }) - } - val actualOptionKeys = actualPatch?.options?.keys ?: emptySet() - - // This is for new keys that are introduced in the new patch - val newOptionKeys = actualOptionKeys - jsonEntry.options.keys - if (newOptionKeys.isNotEmpty()) patchesWithNewOptions[patchName] = newOptionKeys - - // This is for the old option keys that are not present in the new file - val oldOptionKeys = jsonEntry.options.keys - actualOptionKeys - if (oldOptionKeys.isNotEmpty()) patchesWithOldOptions[patchName] = oldOptionKeys - } - - if (newPatches.isNotEmpty() || oldPatches.isNotEmpty() || removedPatches.isNotEmpty() || patchesWithNewOptions.isNotEmpty() || patchesWithOldOptions.isNotEmpty()) { - logger.warning("Your options file is out of date with the current patches:") - if (newPatches.isNotEmpty()) { - logger.warning(" ${newPatches.size} new patches not in your options file, default patch values will be applied. New patches are:") - newPatches.forEach { - logger.warning(" - $it") + // Warn if options file is out of date — checked PER BUNDLE so + // each .mpp's drift is reported against its own JSON entry. + if (optionsFilePath?.exists() == true && !updateOptions) { + loadedBundles.forEachIndexed { i, lb -> + val bundleOpts = patchOptionsByFile[lb.sourceFile] ?: return@forEachIndexed + val bundleSnapshot = patchSnapshots[i] + val bundlePatches = lb.patches + + val compatiblePatchNames = bundlePatches + .filter { patch -> + patch.compatiblePackages == null || + patch.compatiblePackages!!.any { (name, _) -> name == packageName } } - } + .mapNotNull { it.name?.lowercase() } + .toSet() + // All patch names in this bundle regardless of app compatibility. + // Used for "removed" detection: a patch is only truly removed if + // it's gone from the .mpp entirely, not just incompatible with + // this app. + val allMppPatchNames = bundlePatches.mapNotNull { it.name?.lowercase() }.toSet() + val jsonPatchNames = bundleOpts.patches.keys.map { it.lowercase() }.toSet() + + val newPatches = compatiblePatchNames - jsonPatchNames + val oldPatches = jsonPatchNames - compatiblePatchNames + val removedPatches = jsonPatchNames - allMppPatchNames + + // Per-patch option-key drift. + val patchesWithNewOptions = mutableMapOf>() + val patchesWithOldOptions = mutableMapOf>() + + for ((patchName, _) in bundleSnapshot.patches) { + if (patchName.lowercase() !in compatiblePatchNames) continue + val jsonEntry = bundleOpts.patches.entries + .firstOrNull { it.key.equals(patchName, ignoreCase = true) }?.value + ?: continue + + // Compare against the live patch in this bundle (not the snapshot) + // so multi-app patches with the same name aren't merged together. + val actualPatch = bundlePatches.find { + it.name.equals(patchName, ignoreCase = true) && + (it.compatiblePackages == null || it.compatiblePackages!!.any { + (name, _) -> name == packageName + }) + } + val actualOptionKeys = actualPatch?.options?.keys ?: emptySet() - if (removedPatches.isNotEmpty()) { - logger.warning(" ${removedPatches.size} patches in your options file no longer exist and will be ignored") - } + val newOptionKeys = actualOptionKeys - jsonEntry.options.keys + if (newOptionKeys.isNotEmpty()) patchesWithNewOptions[patchName] = newOptionKeys - if (oldPatches.isNotEmpty()) { - logger.warning(" ${oldPatches.size} patches in your options file are not compatible with the app:") - oldPatches.forEach { - logger.warning(" - $it") - } + val oldOptionKeys = jsonEntry.options.keys - actualOptionKeys + if (oldOptionKeys.isNotEmpty()) patchesWithOldOptions[patchName] = oldOptionKeys } - if (patchesWithNewOptions.isNotEmpty()) { - patchesWithNewOptions.forEach { - (patch, key) -> - logger.warning(" \"$patch\" has new options: ${key.joinToString(", ")}") + if (newPatches.isNotEmpty() || oldPatches.isNotEmpty() || removedPatches.isNotEmpty() || + patchesWithNewOptions.isNotEmpty() || patchesWithOldOptions.isNotEmpty() + ) { + logger.warning("Options file is out of date for ${lb.sourceFile.name}:") + if (newPatches.isNotEmpty()) { + logger.warning(" ${newPatches.size} new patches not in your options file, default patch values will be applied. New patches are:") + newPatches.forEach { logger.warning(" - $it") } } - } - - if (patchesWithOldOptions.isNotEmpty()) { - patchesWithOldOptions.forEach { - (patch, key) -> - logger.warning(" \"$patch\" has old options: ${key.joinToString(", ")} that were removed.") + if (removedPatches.isNotEmpty()) { + logger.warning(" ${removedPatches.size} patches in your options file no longer exist and will be ignored") } + if (oldPatches.isNotEmpty()) { + logger.warning(" ${oldPatches.size} patches in your options file are not compatible with the app:") + oldPatches.forEach { logger.warning(" - $it") } + } + if (patchesWithNewOptions.isNotEmpty()) { + patchesWithNewOptions.forEach { (patch, key) -> + logger.warning(" \"$patch\" has new options: ${key.joinToString(", ")}") + } + } + if (patchesWithOldOptions.isNotEmpty()) { + patchesWithOldOptions.forEach { (patch, key) -> + logger.warning(" \"$patch\" has old options: ${key.joinToString(", ")} that were removed.") + } + } + logger.warning(" Use --options-update parameter to sync, or use 'options-create' command to regenerate.") } - logger.warning(" Use --options-update parameter to sync, or use 'options-create' command to regenerate.") } } - logger.info("Setting patch options") - - // Scope filteredPatches inside let so it goes out of scope immediately after - // patcher += filteredPatches, matching the pattern from PR #54. - patches.filterPatchSelection( - packageName, - packageVersion, - jsonEnabledPatches, - jsonDisabledPatches, - ).let { filteredPatches -> - val patchesList = patches.toList() - val cliOptionsMap = selection.filter { it.enabled != null }.associate { - val enabledSelection = it.enabled!! - - val resolvedName = enabledSelection.selector.name?.let { userInput -> - patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true) }?.name ?: userInput - } ?: patchesList[enabledSelection.selector.index!!].name!! - - resolvedName to enabledSelection.options + logger.info("Filtering patches for $packageName v$packageVersion...") + + // Filter + apply options PER BUNDLE so each bundle's selectors + // and options only touch its own patches. Final patcher input + // is the union across all bundles. + val finalPatches = mutableSetOf>() + loadedBundles.forEachIndexed { i, lb -> + val bundleArg = bundles[i] + val jsonEnabled = jsonEnabledByFile[lb.sourceFile] ?: emptySet() + val jsonDisabled = jsonDisabledByFile[lb.sourceFile] ?: emptySet() + val jsonOpts = jsonOptionsByFile[lb.sourceFile] ?: emptyMap() + + val patchesList = lb.patches.toList() + + // CLI options map scoped to this bundle. Name resolution looks + // up only this bundle's patches; --ei index is interpreted as + // an index INTO THIS BUNDLE'S patch list. + val cliOptionsMap = bundleArg.selections.filter { it.enabled != null }.associate { sel -> + val enabledSel = sel.enabled!! + val resolvedName = enabledSel.selector.name?.let { userInput -> + patchesList.firstOrNull { it.name.equals(userInput, ignoreCase = true) }?.name + ?: userInput + } ?: patchesList[enabledSel.selector.index!!].name!! + resolvedName to enabledSel.options } - (jsonOptionsMap.keys + cliOptionsMap.keys).associateWith { patchName -> - val jsonOpts = jsonOptionsMap[patchName] ?: emptyMap() - val cliOpts = cliOptionsMap[patchName] ?: emptyMap() + val filtered = lb.patches.filterPatchSelection( + packageName, + packageVersion, + bundleArg.selections, + jsonEnabled, + jsonDisabled, + ) - // Log when CLI options override JSON values - for ((key, cliValue) in cliOpts) { - val jsonValue = jsonOpts[key] + // Merge JSON + CLI options (CLI overrides JSON for same key) + // and apply to this bundle's filtered patches. + (jsonOpts.keys + cliOptionsMap.keys).associateWith { patchName -> + val js = jsonOpts[patchName] ?: emptyMap() + val cl = cliOptionsMap[patchName] ?: emptyMap() + for ((key, cliValue) in cl) { + val jsonValue = js[key] if (jsonValue != null && jsonValue != cliValue) { - logger.info("CLI option overrides JSON for \"$patchName\" -> \"$key\": $jsonValue -> $cliValue") + logger.info( + "CLI option overrides JSON for \"$patchName\" " + + "(${lb.sourceFile.name}) -> \"$key\": $jsonValue -> $cliValue" + ) } } + js + cl + }.let(filtered::setOptions) - jsonOpts + cliOpts // CLI entries override JSON entries for same key - }.let(filteredPatches::setOptions) - - patcher += filteredPatches - } // filteredPatches and patchesList go out of scope here - - // Execute patches. + finalPatches += filtered + } + patcher += finalPatches + + // Execute patches. Log lines match the engine's "Applying N + // patches…" → "Applied: " / "FAILED: " format so + // CLI and GUI output is consistent. CLI still appends the + // stacktrace on failure since there's no "View details" UI + // in a terminal. + logger.info("Applying ${finalPatches.size} patches...") patchingResult.addStepResult( PatchingStep.PATCHING, { runBlocking { patcher().collect { patchResult -> + val patchName = patchResult.patch.name ?: "Unknown" patchResult.exception?.let { exception -> StringWriter().use { writer -> exception.printStackTrace(PrintWriter(writer)) - logger.severe("\"${patchResult.patch}\" failed:\n$writer") + logger.severe("FAILED: $patchName\n$writer") patchingResult.failedPatches.add( FailedPatch( @@ -715,14 +798,14 @@ internal object PatchCommand : Callable { if (!continueOnError) { patchingResult.success = false throw PatchFailedException( - "\"${patchResult.patch}\" failed", + "FAILED: $patchName", exception ) } } - } ?: patchResult.patch.let { + } ?: run { patchingResult.appliedPatches.add(patchResult.patch.toSerializablePatch()) - logger.info("\"${patchResult.patch}\" succeeded") + logger.info("Applied: $patchName") } } } @@ -752,33 +835,18 @@ internal object PatchCommand : Callable { patchingResult.addStepResult( PatchingStep.SIGNING, { - fun signApk(alias: String, password: String) { - ApkUtils.signApk( - patchedApkFile, - outputFilePath, - signer, - ApkUtils.KeyStoreDetails( - keystoreFilePath, - keyStorePassword, - alias, - password, - ) - ) - } - try { - signApk(keyStoreEntryAlias, keyStoreEntryPassword) - } catch (e: Exception){ - // Retry with legacy keystore defaults. - if (keyStoreEntryAlias == DEFAULT_KEYSTORE_ALIAS && - keyStoreEntryPassword == DEFAULT_KEYSTORE_PASSWORD && - keystoreFilePath.exists() - ) { - logger.info("Using legacy keystore credentials") - - signApk(LEGACY_KEYSTORE_ALIAS, LEGACY_KEYSTORE_PASSWORD) - } else { - throw e - } + signWithLegacyFallback( + primary = ApkUtils.KeyStoreDetails( + keystoreFilePath, + keyStorePassword, + keyStoreEntryAlias, + keyStoreEntryPassword, + ), + allowLegacyFallback = keyStoreEntryAlias == DEFAULT_KEYSTORE_ALIAS && + keyStoreEntryPassword == DEFAULT_KEYSTORE_PASSWORD, + logger = logger, + ) { details -> + ApkUtils.signApk(patchedApkFile, outputFilePath, signer, details) } } ) @@ -836,9 +904,9 @@ internal object PatchCommand : Callable { logger.info("Patching result saved to $outputFile") } - // Auto-update options JSON file using lightweight snapshot (no DEX references) - val snapshot = patchesSnapshot - if (optionsFilePath != null && updateOptions && snapshot != null) { + // Auto-update options JSON file using the per-bundle snapshots + // (no DEX references). One JSON entry per .mpp, matched by source. + if (optionsFilePath != null && updateOptions && patchesSnapshotForFinally.isNotEmpty()) { try { val existingBundles = optionsFilePath!!.let { file -> if (file.exists()) { @@ -846,9 +914,19 @@ internal object PatchCommand : Callable { catch (e: Exception) { emptyList() } } else emptyList() } - val existingBundle = existingBundles.findMatchingBundle(patchesFiles) - val updatedBundle = snapshot.mergeWith(existingBundle) - val updatedBundles = existingBundles.withUpdatedBundle(updatedBundle) + // Walk each bundle's snapshot, merge against its matching + // existing entry (by sha256 / source name), and splice the + // updated entry back into the list. Bundles without a prior + // entry get appended. + var updatedBundles = existingBundles + patchesSnapshotForFinally.forEach { snapshot -> + val sourceFile = snapshot.meta.source?.let { File(it) } + val existing = if (sourceFile != null) { + updatedBundles.findMatchingBundle(setOf(sourceFile)) + } else null + val updated = snapshot.mergeWith(existing) + updatedBundles = updatedBundles.withUpdatedBundle(updated) + } val json = Json { prettyPrint = true } optionsFilePath!!.writeText(json.encodeToString(updatedBundles)) logger.info("Updated options file ${optionsFilePath!!.path}") @@ -858,8 +936,14 @@ internal object PatchCommand : Callable { } if (purge) { - logger.info("Purging temporary files") - purge(temporaryFilesPath) + // Scope: only THIS session's tmp subfolder. Cached patches, + // logs, config, and other in-flight sessions (CLI or GUI) are + // never touched. + if (patcherTemporaryFilesPath.deleteRecursively()) { + logger.info("Purged this session's temp files: ${patcherTemporaryFilesPath.name}") + } else { + logger.warning("Failed to purge ${patcherTemporaryFilesPath.path}") + } } // Clean up merged apk if we created one from apkm, xapk or apks @@ -885,18 +969,19 @@ internal object PatchCommand : Callable { private fun Set>.filterPatchSelection( packageName: String, packageVersion: String, + bundleSelections: List, jsonEnabledPatches: Set = emptySet(), jsonDisabledPatches: Set = emptySet(), ): Set> = buildSet { // CLI flags (take precedence over JSON) val cliEnabledByName = - selection.mapNotNull { it.enabled?.selector?.name?.lowercase() }.toSet() + bundleSelections.mapNotNull { it.enabled?.selector?.name?.lowercase() }.toSet() val cliEnabledByIndex = - selection.mapNotNull { it.enabled?.selector?.index }.toSet() + bundleSelections.mapNotNull { it.enabled?.selector?.index }.toSet() val cliDisabledByName = - selection.mapNotNull { it.disable?.selector?.name?.lowercase() }.toSet() + bundleSelections.mapNotNull { it.disable?.selector?.name?.lowercase() }.toSet() val cliDisabledByIndex = - selection.mapNotNull { it.disable?.selector?.index }.toSet() + bundleSelections.mapNotNull { it.disable?.selector?.index }.toSet() this@filterPatchSelection.withIndex().forEach patchLoop@{ (i, patch) -> val patchName = patch.name!! @@ -906,56 +991,67 @@ internal object PatchCommand : Callable { patch.compatiblePackages?.let { packages -> packages.singleOrNull { (name, _) -> name == packageName }?.let { (_, versions) -> if (versions?.isEmpty() == true) { - return@patchLoop logger.warning("\"$patchName\" incompatible with \"$packageName\"") + return@patchLoop logger.warning( + "Skipping \"$patchName\": incompatible with $packageName" + ) } val matchesVersion = force || versions?.let { it.any { version -> version == packageVersion } } ?: true if (!matchesVersion) { + val compatibilityHint = packages.joinToString("; ") { (pkg, vers) -> + pkg + " " + (vers ?: emptySet()).joinToString(", ") + } return@patchLoop logger.warning( - "\"$patchName\" incompatible with $packageName $packageVersion " + - "but compatible with " + - packages.joinToString("; ") { (packageName, versions) -> - packageName + " " + versions!!.joinToString(", ") - }, + "Skipping \"$patchName\": incompatible with $packageName $packageVersion " + + "(supported: $compatibilityHint)" ) } } ?: return@patchLoop logger.fine( - "\"$patchName\" incompatible with $packageName. " + - "It is only compatible with " + - packages.joinToString(", ") { (name, _) -> name }, + "Skipping \"$patchName\": incompatible with $packageName " + + "(only compatible with " + + packages.joinToString(", ") { (name, _) -> name } + ")" ) return@let } ?: logger.fine("\"$patchName\" has no package constraints") - // CLI flags take precedence over JSON, JSON takes precedence over defaults + // CLI flags take precedence over JSON, JSON takes precedence over defaults. + // Log strings match the GUI engine's "Skipping disabled: …" format so + // surfaces stay consistent. CLI-specific override hints are preserved + // as parentheticals. val isCliDisabled = patchNameLower in cliDisabledByName || i in cliDisabledByIndex if (isCliDisabled) { if (patchNameLower in jsonEnabledPatches) { - logger.info("\"$patchName\" disabled manually (overrides options file: enabled)") + logger.info("Skipping disabled: $patchName (overrides options file: enabled)") } else { - logger.info("\"$patchName\" disabled manually") + logger.info("Skipping disabled: $patchName") } return@patchLoop } val isCliEnabled = patchNameLower in cliEnabledByName || i in cliEnabledByIndex if (isCliEnabled && patchNameLower in jsonDisabledPatches) { - logger.info("\"$patchName\" enabled manually (overrides options file: disabled)") + logger.info("Enabling: $patchName (overrides options file: disabled)") } // JSON-sourced enable/disable (only applies if no CLI flag for this patch) val isJsonDisabled = !isCliEnabled && patchNameLower in jsonDisabledPatches - if (isJsonDisabled) return@patchLoop logger.info("\"$patchName\" disabled via options file") + if (isJsonDisabled) return@patchLoop logger.info( + "Skipping disabled: $patchName (from options file)" + ) val isJsonEnabled = patchNameLower in jsonEnabledPatches val isEnabled = !exclusive && patch.use if (!(isEnabled || isCliEnabled || isJsonEnabled)) { - return@patchLoop logger.info("\"$patchName\" disabled") + // Default-disabled patches (the patch ships with use=false and + // wasn't explicitly enabled). Log at info level — most CLI + // users want to see WHY each patch was skipped, even the + // ones that opted-out by default. + return@patchLoop logger.info("Skipping disabled: $patchName (default)") } add(patch) @@ -964,15 +1060,6 @@ internal object PatchCommand : Callable { } } - private fun purge(resourceCachePath: File) { - val result = - if (resourceCachePath.deleteRecursively()) { - "Purged resource cache directory" - } else { - "Failed to purge resource cache directory" - } - logger.info(result) - } } private class PatchFailedException(message: String, cause: Throwable) : Exception(message, cause) diff --git a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt index f7d48dc3..ece55d2e 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchFileResolver.kt @@ -1,10 +1,9 @@ package app.morphe.cli.command -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import app.morphe.engine.patches.RemotePatchSourceFactory +import app.morphe.engine.patches.findPatchAsset +import io.ktor.client.HttpClient +import kotlinx.coroutines.runBlocking import java.io.File import java.util.logging.Logger @@ -15,167 +14,94 @@ object PatchFileResolver { /** * Takes the user's provided Patch Files and resolves any URLs that might be present. * Returns a new Set with URLs replaced by downloaded/cached .mpp files. + * + * Provider detection (GitHub vs GitLab) + URL parsing + API talk lives in the engine. + * This function only owns the CLI's disk cache layout and the "which release do we pick" decision. */ - fun resolve( patchFiles: Set, prerelease: Boolean, - cacheDir: File + cacheDir: File, + httpClient: HttpClient ): Set { - // We try to download our patch file here if the user passed a link - if (patchFiles.any { - it.path.startsWith("http:/") || - it.path.startsWith("https:/") - }) { - try { - val urlEntry = patchFiles.first{ - it.path.startsWith("http:/") || it.path.startsWith("https:/") - } + val urlEntry = patchFiles.firstOrNull { + it.path.startsWith("http:/") || it.path.startsWith("https:/") + } ?: return patchFiles - val url = urlEntry.path + val url = urlEntry.path - val urlParts = url.split("/") - val owner = urlParts[2] - val repo = urlParts[3] + return runBlocking { + try { + // Parse the URL here, the engine handles github.com, gitlab.com, + // morphe.software/add-source links, bare owner/repo. - // Resolve the version and asset from the GitHub API, then use the helper to cache/download. - val version: String - val asset: JsonElement? + val parsed = RemotePatchSourceFactory.parse(url) + ?: throw IllegalArgumentException("Unrecognized patch URL: \$url") - if (url.contains("releases/tag/")){ - // We have the release version in this branch. - version = urlParts[6] // version part of the url + val source = parsed.instantiate(httpClient) - // First we hit the GitHub api for this specific release - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}" - ).toURL().openStream().bufferedReader().readText() + // List releases and decide which one to use. `releases/tag/` in the URL pins to + // that exact tag. - // Then we find where the .mpp file is from the stream above - val json = Json.parseToJsonElement(response).jsonObject - val assetArray = json["assets"]?.jsonArray + val pinnedTag = Regex("release/tag/([^/]+)").find(url)?.groupValues?.get(1) - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + val release = source.listReleases().getOrThrow() + val targetRelease = when { + pinnedTag != null -> release.firstOrNull { + it.tagName == pinnedTag } + ?: throw IllegalArgumentException("Version $pinnedTag not found in ${parsed.repoPath}") - } else if (!prerelease) { - // Here in this "only repo mentioned" branch, get the latest stable version. - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases/latest" - ).toURL().openStream().bufferedReader().readText() - - // Then we find where the .mpp file is from the stream above - val json = Json.parseToJsonElement(response).jsonObject - val assetArray = json["assets"]?.jsonArray - - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true + prerelease -> release.firstOrNull { + it.isDevRelease() } + ?: throw IllegalArgumentException("Could not get dev release from ${parsed.repoPath}") - version = json["tag_name"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException( - "Could not determine version from ${owner}/${repo}" - ) - - } else { - // Get latest dev version here. - // Get latest dev version from GitHub immediately to check our local file. - val response = java.net.URI( - "https://api.github.com/repos/${owner}/${repo}/releases" - ).toURL().openStream().bufferedReader().readText() - - val releases = Json.parseToJsonElement(response).jsonArray - val release = releases.firstOrNull { - it.jsonObject["prerelease"]?.jsonPrimitive?.content == "true" + else -> release.firstOrNull { + !it.isDevRelease() } - ?: throw IllegalArgumentException( - "Could not get dev release from ${owner}/${repo}" - ) + ?: throw IllegalArgumentException("Could not get stable release from ${parsed.repoPath}") + } - val assetArray = release.jsonObject["assets"]?.jsonArray + // Find the .mpp asset in that release. + val asset = targetRelease.findPatchAsset() + ?: throw IllegalArgumentException("No .mpp file found in release ${targetRelease.tagName}") - asset = assetArray?.find { - it.jsonObject["name"]?.jsonPrimitive?.content?.endsWith(".mpp") == true - } + // Disk-cache check (same layout as before: {cacheDir}/download/{owner}-{repo}/). + val versionNumber = targetRelease.tagName.removePrefix("v") + val repoCacheDir = + cacheDir.resolve("download").resolve(parsed.repoPath.replace("/", "-")) - version = release.jsonObject["tag_name"]?.jsonPrimitive?.content - ?: throw IllegalArgumentException( - "Could not determine version from ${owner}/${repo}" - ) + val cachedFile = repoCacheDir.listFiles()?.find { + it.name.endsWith(".mpp") && it.name.contains(versionNumber) } - // Use the helper to check cache or download the .mpp file - val resolvedFile = fetchRemotePatchFile( - owner, - repo, - version, - asset, - cacheDir - ) - return patchFiles - urlEntry + resolvedFile - - } catch (e: Exception) { - throw IllegalArgumentException("Failed to download patches from URL: ${e.message}") - } - } - return patchFiles - } + val resolvedFile = if (cachedFile != null) { + val rel = cachedFile.relativeTo(cacheDir.parentFile).path + logger.info("Using cached patch file at $rel") - // This is the helper function that can be called to do the patch files downloading. - // The caller resolves the version and asset from the GitHub API before calling this. - private fun fetchRemotePatchFile( - owner: String, - repo: String, - version: String, - asset: JsonElement?, - cacheDir: File - ): File { - val versionNumber = version.removePrefix("v") - - val repoCacheDir = cacheDir.resolve("download").resolve("${owner}-${repo}") - - val cachedFile = repoCacheDir.listFiles()?.find { - it.name.endsWith(".mpp") && it.name.contains(versionNumber) - } + cachedFile + } else { + // Different version cached -> wipe it before downloading (matches the existing behavior) + repoCacheDir.listFiles() + ?.filter { it.name.endsWith(".mpp") } + ?.forEach{ it.delete() } + repoCacheDir.mkdirs() - if (cachedFile != null){ - val relativePath = cachedFile.relativeTo(cacheDir.parentFile).path - // If the user mentioned file with that version already exists, return that file location. - logger.info("Using cached patch file at $relativePath") - return cachedFile - } - else{ - // If it doesn't exist or some other version is present, then we come here. - // Either way we download our version and replace whatever else is present. - repoCacheDir.listFiles()?.filter { - it.name.endsWith(".mpp") - }?.forEach { it.delete() } - repoCacheDir.mkdirs() - - // Get the .mpp file ready here - val downloadUrl = asset?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.content - - // Also get the file name ready here - val assetName = asset?.jsonObject?.get("name")?.jsonPrimitive?.content - - if (downloadUrl == null || assetName == null){ - throw IllegalArgumentException("No .mpp file found in release $version") - } + val targetFile = File(repoCacheDir, asset.name) + logger.info("Downloading patches from ${parsed.repoPath} $versionNumber...") - // We finally download and set everything here. - logger.info("Downloading patches from ${owner}/${repo} ${versionNumber}...") - val targetFile = File(repoCacheDir, assetName) - java.net.URI(downloadUrl).toURL().openStream().use { input -> - targetFile.outputStream().use { output -> - input.copyTo(output) - } - } + source.downloadAsset(asset, targetFile).getOrThrow() - val relativePath = targetFile.relativeTo(cacheDir.parentFile).path - logger.info("Patches mpp saved to $relativePath. This file will be used on your next run as long as it is not deleted!") + val rel = targetFile.relativeTo(cacheDir.parentFile).path + logger.info("Patches mpp saved to $rel. This file will be used on your next run as long as it is not deleted!") - return targetFile + targetFile + } + patchFiles - urlEntry + resolvedFile + } catch (e: Exception) { + throw IllegalArgumentException("Failed to download patches from URL: ${e.message}") + } } } } \ No newline at end of file diff --git a/src/main/kotlin/app/morphe/engine/MorpheData.kt b/src/main/kotlin/app/morphe/engine/MorpheData.kt new file mode 100644 index 00000000..4e6ea42c --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/MorpheData.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import java.io.File +import java.util.logging.Logger + +/** + * Single source of truth for where Morphe stores its runtime data on disk. + * + * **Primary location**: a `morphe-data/` folder created **next to the running + * JAR**. Survives upgrades (multiple JAR versions side-by-side share one + * folder), trivially findable for users (just `cd` to where the JAR lives), + * portable across drives/USB sticks. + * + * **Fallback location**: `~/morphe/`. Used when the primary path is + * unreachable — most commonly when running from an IDE (`./gradlew run`) + * where the "JAR location" resolves to a `build/classes/` directory that + * would get wiped by `./gradlew clean`. Also covers the (rare) case of a + * read-only JAR install location. + * + * Layout once populated: + * ``` + * morphe-data/ + * patches/{owner}-{repo}/v1.5.0__patches.mpp # downloaded .mpp files + * logs/ # app logs + * config.json # GUI preferences + sources + * tmp/patching-{timestamp}/ # per-session patcher scratch + * morphe.keystore # shared default signing key + * ``` + * + * A single shared keystore is intentional: Android refuses updates whose + * signatures don't match the installed app, so per-app or per-output-APK + * keystores would break "re-patch and reinstall over the old version." A + * user who wants their own signing identity can point at a custom keystore + * in Settings, which overrides this default. + * + * All paths are computed lazily so the JVM is fully bootstrapped (classloader, + * security manager, etc.) before we probe for the JAR location. The + * resolution runs **at most once per JVM** — the lazy property caches. + */ +object MorpheData { + private val logger = Logger.getLogger(MorpheData::class.java.name) + + private val resolution: Resolution by lazy { resolveRoot() } + + /** Root: JAR-adjacent `morphe-data/`, with fallback to `~/morphe/`. */ + val root: File get() = resolution.root + + /** + * Anchor for portable relative paths in `config.json`. This is the + * **JAR's containing directory** — i.e. `root.parentFile` in the happy + * (JAR-adjacent) case. Null in fallback / IDE mode because there's no + * portable bundle then. + * + * Paths the user picks (output directory, keystore) that live under this + * anchor are stored in config as anchor-relative, so the whole bundle + * (JAR + `morphe-data/` + sibling folders) survives being moved. + */ + val bundleRoot: File? get() = resolution.bundleRoot + + private data class Resolution(val root: File, val bundleRoot: File?) + + /** Downloaded `.mpp` patch files, organized by source. */ + val patchesDir: File by lazy { File(root, "patches").also { it.mkdirs() } } + + /** App logs. */ + val logsDir: File by lazy { File(root, "logs").also { it.mkdirs() } } + + /** Patcher scratch space. Each patching session gets its own subfolder + * here (see Phase 6 of the unified-data-location plan). */ + val tmpDir: File by lazy { File(root, "tmp").also { it.mkdirs() } } + + /** GUI's persisted preferences (theme, enabled sources, etc.). */ + val configFile: File get() = File(root, "config.json") + + /** + * Default shared keystore. The patcher library creates it on first sign + * if missing; subsequent signs reuse the same identity so patched apps + * can be updated on-device without reinstalling. + */ + val defaultKeystoreFile: File get() = File(root, "morphe.keystore") + + /** + * Reason the primary (JAR-adjacent) location was rejected. Drives the + * fallback log message so a user reporting "where's my cache?" can + * tell from logs alone which branch ran. + */ + private enum class FallbackReason(val message: String) { + NO_JAR_LOCATION("Could not determine JAR location (running from IDE / classpath?)"), + NOT_A_JAR("Running source is not a JAR (likely IDE / `./gradlew run`)"), + NOT_WRITABLE("JAR directory is not writable"), + EXCEPTION("Exception while resolving JAR location"), + } + + private fun resolveRoot(): Resolution { + val (jarAdjacent, fallbackReason) = tryJarAdjacent() + if (jarAdjacent != null) { + logger.info("Morphe data root: ${jarAdjacent.absolutePath} (JAR-adjacent)") + jarAdjacent.mkdirs() + return Resolution(root = jarAdjacent, bundleRoot = jarAdjacent.parentFile) + } + val fallback = userHomeFallback() + // WARNING level — users debugging "I can't find my patches" or "config + // didn't persist" need to see this to know we fell back and why. + logger.warning( + "Morphe data root falling back to ${fallback.absolutePath} — " + + "primary (JAR-adjacent) unavailable: ${fallbackReason?.message ?: "unknown"}" + ) + fallback.mkdirs() + // No portable bundle concept in fallback mode — paths stay absolute. + return Resolution(root = fallback, bundleRoot = null) + } + + /** + * Returns (path, null) on success, (null, reason) on fallback. + * The reason gets surfaced in logs so users can tell WHY we fell back. + */ + private fun tryJarAdjacent(): Pair { + val location = try { + MorpheData::class.java.protectionDomain.codeSource?.location + ?: return null to FallbackReason.NO_JAR_LOCATION + } catch (e: Exception) { + return null to FallbackReason.EXCEPTION + } + + val jarFile = try { + // canonicalFile resolves symlinks — Homebrew/asdf-style installs + // often symlink the JAR; we want the cache next to the real file, + // not next to the symlink. + File(location.toURI()).canonicalFile + } catch (e: Exception) { + return null to FallbackReason.EXCEPTION + } + + // When running from IDE (`./gradlew run`), location is a classes + // directory, not a JAR. Detect and fall back so we don't pollute + // build outputs that `./gradlew clean` wipes. + if (jarFile.isDirectory || !jarFile.name.endsWith(".jar")) { + return null to FallbackReason.NOT_A_JAR + } + + val candidate = File(jarFile.parentFile, "morphe-data") + if (!isWritable(candidate)) { + return null to FallbackReason.NOT_WRITABLE + } + return candidate to null + } + + private fun userHomeFallback(): File { + val userHome = System.getProperty("user.home") + return File(userHome, "morphe") + } + + private fun isWritable(dir: File): Boolean { + if (dir.exists()) return dir.canWrite() + // Probe parent — if we can create the dir, we can write to it. + val parent = dir.parentFile ?: return false + return parent.canWrite() + } +} diff --git a/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt new file mode 100644 index 00000000..0058782f --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.patcher.patch.Patch +import app.morphe.patcher.patch.loadPatchesFromJar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.io.File +import java.util.logging.Logger + +/** + * Loads patches from one or more `.mpp` files in parallel and tags each loaded + * Patch instance with the source it came from. The union [Result.allPatches] + * is what [PatchEngine.patch] expects; the per-source breakdown is for + * GUI/CLI consumers that want to badge or filter by origin. + * + * Lives in the engine package so the CLI can consume the same multi-source + * loading path when it adopts multi-source. GUI-specific aggregation (display + * name resolution, icon color picking) lives in the GUI layer. + */ +object MultiSourceLoader { + + private val logger = Logger.getLogger(this::class.java.name) + + data class SourceInput( + val sourceId: String, + val sourceName: String, + val patchFile: File, + ) + + data class LoadedSource( + val sourceId: String, + val sourceName: String, + val patches: Set>, + val error: Throwable? = null, + ) { + val isSuccess: Boolean get() = error == null + } + + data class Result( + val perSource: List, + val allPatches: Set>, + // Map a deduped patch back to ALL source IDs that contain it. When the + // same patch (by Patch.equals, i.e. matching name + body + options) + // appears in multiple bundles, this set has every contributing source + // so the UI can render multi-source attribution. + val patchToSourceIds: Map, Set>, + ) { + val hasErrors: Boolean get() = perSource.any { !it.isSuccess } + } + + /** + * Load patches from each input in parallel. Each .mpp is copied to a temp file + * before loading to work around Windows URLClassLoader file-locking (mirrors + * the same workaround in PatchService.kt). + */ + suspend fun load(inputs: List): Result = coroutineScope { + val loaded = inputs.map { input -> + async(Dispatchers.IO) { loadOne(input) } + }.awaitAll() + + // Dedup intentional: identical patches across sources collapse into + // ONE entry — the UI then shows them as a single card with a multi- + // source attribution badge. Previously this used `.toMap()` for the + // source mapping which silently dropped all but the last source. + val allPatches = loaded.flatMap { it.patches }.toSet() + val patchToSourceIds: Map, Set> = loaded + .flatMap { src -> src.patches.map { it to src.sourceId } } + .groupBy({ it.first }, { it.second }) + .mapValues { it.value.toSet() } + + Result( + perSource = loaded, + allPatches = allPatches, + patchToSourceIds = patchToSourceIds, + ) + } + + private suspend fun loadOne(input: SourceInput): LoadedSource = withContext(Dispatchers.IO) { + val tempCopy = File.createTempFile("morphe-mp-${input.sourceId}-", ".mpp") + try { + input.patchFile.copyTo(tempCopy, overwrite = true) + val patches = loadPatchesFromJar(setOf(tempCopy)) + logger.info("MultiSourceLoader: loaded ${patches.size} patches from '${input.sourceName}'") + LoadedSource( + sourceId = input.sourceId, + sourceName = input.sourceName, + patches = patches, + ) + } catch (e: Exception) { + logger.warning("MultiSourceLoader: failed to load '${input.sourceName}': ${e.message}") + LoadedSource( + sourceId = input.sourceId, + sourceName = input.sourceName, + patches = emptySet(), + error = e, + ) + } finally { + tempCopy.deleteOnExit() + } + } +} diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index cddc9cbe..26d64a2f 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.signWithLegacyFallback import app.morphe.patcher.Patcher import app.morphe.patcher.PatcherConfig import app.morphe.patcher.apk.ApkMerger @@ -244,7 +245,11 @@ object PatchEngine { } try { - fun signApk(details: ApkUtils.KeyStoreDetails) { + signWithLegacyFallback( + primary = keystoreDetails, + allowLegacyFallback = config.keystoreDetails == null, + logger = logger, + ) { details -> ApkUtils.signApk( rebuiltApk, tempOutput, @@ -252,25 +257,6 @@ object PatchEngine { details, ) } - - try { - signApk(keystoreDetails) - } catch (e: Exception) { - // Retry with legacy keystore defaults. - if (config.keystoreDetails == null && keystoreDetails.keyStore.exists()) { - logger.info("Using legacy keystore credentials") - - val legacyKeystoreDetails = ApkUtils.KeyStoreDetails( - keystoreDetails.keyStore, - null, - Config.LEGACY_KEYSTORE_ALIAS, - Config.LEGACY_KEYSTORE_PASSWORD, - ) - signApk(legacyKeystoreDetails) - } else { - throw e - } - } stepResults.add(StepResult(PatchStep.SIGNING, true)) } catch (e: Exception) { stepResults.add(StepResult(PatchStep.SIGNING, false, e.toString())) diff --git a/src/main/kotlin/app/morphe/gui/data/model/Release.kt b/src/main/kotlin/app/morphe/engine/model/Release.kt similarity index 56% rename from src/main/kotlin/app/morphe/gui/data/model/Release.kt rename to src/main/kotlin/app/morphe/engine/model/Release.kt index 50d54635..6b25a764 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Release.kt +++ b/src/main/kotlin/app/morphe/engine/model/Release.kt @@ -3,17 +3,25 @@ * https://github.com/MorpheApp/morphe-cli */ -package app.morphe.gui.data.model +package app.morphe.engine.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Represents a GitHub release (for CLI or Patches) + * Represents a release from a remote patch source (GitHub, GitLab, …). + * + * The on-the-wire JSON shape varies by provider. The engine's provider + * implementations are responsible for normalizing their response into this + * model so the rest of the codebase (GUI, CLI) doesn't need to know which + * provider produced a given release. */ @Serializable data class Release( - val id: Long, + // Default 0L: GitHub always returns a numeric release id, but GitLab's + // release list keys releases by tag_name instead — and we never read + // `id` anywhere, so a fallback keeps the model provider-agnostic. + val id: Long = 0L, @SerialName("tag_name") val tagName: String, val name: String? = null, @@ -35,7 +43,9 @@ data class Release( } /** - * Check if this is a dev/pre-release version + * Check if this is a dev/pre-release version. Providers that expose a + * `prerelease` flag (GitHub) set [isPrerelease] directly; providers that + * don't (GitLab) lean on the tag-name heuristic below. */ fun isDevRelease(): Boolean { return isPrerelease || tagName.contains("dev", ignoreCase = true) || @@ -46,13 +56,17 @@ data class Release( @Serializable data class ReleaseAsset( - val id: Long, + // Defaults: GitLab release links don't expose all of these consistently. + // None of these fields are required for selection / download — they're + // surfaced in UI ("12 MB · application/zip") at best, so a missing + // value just renders as "0 B" / "application/octet-stream". + val id: Long = 0L, val name: String, @SerialName("browser_download_url") val downloadUrl: String, - val size: Long, + val size: Long = 0L, @SerialName("content_type") - val contentType: String + val contentType: String = "application/octet-stream", ) { /** * Check if this is a patch file (.mpp) diff --git a/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt new file mode 100644 index 00000000..a4df1fbf --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/GitHubPatchSource.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.readRawBytes +import io.ktor.http.HttpHeaders +import io.ktor.http.isSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.logging.Logger + +/** + * GitHub provider. Hits api.github.com/repos/{owner}/{repo}/releases. + * + * GitHub's release JSON matches our [Release] model directly (the SerialName + * annotations align with GitHub's field names), so deserialization is a + * straight `response.body()` via Ktor content negotiation. + */ +class GitHubPatchSource( + private val httpClient: HttpClient, + override val repoPath: String, +) : RemotePatchSource { + + override val provider = PatchProvider.GITHUB + + private val logger = Logger.getLogger(GitHubPatchSource::class.java.name) + private val releasesEndpoint = "$API_BASE/repos/$repoPath/releases" + + override suspend fun listReleases(): Result> = withContext(Dispatchers.IO) { + try { + logger.info("GitHub: fetching releases from $releasesEndpoint") + val response: HttpResponse = httpClient.get(releasesEndpoint) { + headers { + append(HttpHeaders.Accept, "application/vnd.github+json") + append("X-GitHub-Api-Version", "2022-11-28") + } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("GitHub releases fetch failed: HTTP ${response.status}") + ) + } + val releases: List = response.body() + logger.info("GitHub: fetched ${releases.size} releases from $repoPath") + Result.success(releases) + } catch (e: Exception) { + logger.warning("GitHub releases fetch error for $repoPath: ${e.message}") + Result.failure(e) + } + } + + override suspend fun downloadAsset( + asset: ReleaseAsset, + targetFile: File, + ): Result = withContext(Dispatchers.IO) { + try { + logger.info("GitHub: downloading ${asset.name} from ${asset.downloadUrl}") + val response: HttpResponse = httpClient.get(asset.downloadUrl) { + headers { + append(HttpHeaders.Accept, "application/octet-stream") + } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("Download failed: HTTP ${response.status} from ${asset.downloadUrl}") + ) + } + val bytes = response.readRawBytes() + if (bytes.isEmpty()) { + return@withContext Result.failure( + Exception("Download returned 0 bytes from ${asset.downloadUrl}") + ) + } + targetFile.parentFile?.mkdirs() + targetFile.writeBytes(bytes) + logger.info("GitHub: wrote ${bytes.size} bytes to ${targetFile.absolutePath}") + Result.success(targetFile) + } catch (e: Exception) { + // Don't leave a partial file behind + if (targetFile.exists() && targetFile.length() == 0L) targetFile.delete() + Result.failure(e) + } + } + + companion object { + private const val API_BASE = "https://api.github.com" + } +} diff --git a/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt new file mode 100644 index 00000000..d1dfeff2 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/GitLabPatchSource.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.head +import io.ktor.client.request.headers +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.client.statement.readRawBytes +import io.ktor.http.HttpHeaders +import io.ktor.http.isSuccess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.net.URLEncoder +import java.util.logging.Logger + +/** + * GitLab provider. Hits gitlab.com/api/v4/projects/{owner%2Frepo}/releases. + * + * GitLab's release JSON shape differs from GitHub's in several ways that + * require normalization rather than direct deserialization: + * - Assets live under `assets.links[]`, not `assets[]` + * - Each asset link uses `direct_asset_url` (or fallback `url`), not + * `browser_download_url` + * - No `prerelease` flag — dev detection falls back to the tag-name + * heuristic in [Release.isDevRelease] + * - No size or content_type in the release payload — we resolve sizes + * via parallel HEAD requests against the .mpp assets we care about, + * so the UI can show real megabytes + */ +class GitLabPatchSource( + private val httpClient: HttpClient, + override val repoPath: String, +) : RemotePatchSource { + + override val provider = PatchProvider.GITLAB + + private val logger = Logger.getLogger(GitLabPatchSource::class.java.name) + + // GitLab's projects API expects the path URL-encoded as `owner%2Frepo`. + private val releasesEndpoint: String = run { + val encoded = URLEncoder.encode(repoPath, "UTF-8") + "$API_BASE/projects/$encoded/releases" + } + + override suspend fun listReleases(): Result> = withContext(Dispatchers.IO) { + try { + logger.info("GitLab: fetching releases from $releasesEndpoint") + val response: HttpResponse = httpClient.get(releasesEndpoint) { + headers { + append(HttpHeaders.Accept, "application/json") + } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("GitLab releases fetch failed: HTTP ${response.status}") + ) + } + val raw = response.bodyAsText() + val releases = parseReleases(raw) + logger.info("GitLab: fetched ${releases.size} releases from $repoPath") + Result.success(releases) + } catch (e: Exception) { + logger.warning("GitLab releases fetch error for $repoPath: ${e.message}") + Result.failure(e) + } + } + + override suspend fun downloadAsset( + asset: ReleaseAsset, + targetFile: File, + ): Result = withContext(Dispatchers.IO) { + try { + logger.info("GitLab: downloading ${asset.name} from ${asset.downloadUrl}") + val response: HttpResponse = httpClient.get(asset.downloadUrl) { + headers { + append(HttpHeaders.Accept, "application/octet-stream") + } + } + if (!response.status.isSuccess()) { + return@withContext Result.failure( + Exception("Download failed: HTTP ${response.status} from ${asset.downloadUrl}") + ) + } + val bytes = response.readRawBytes() + if (bytes.isEmpty()) { + return@withContext Result.failure( + Exception("Download returned 0 bytes from ${asset.downloadUrl}") + ) + } + targetFile.parentFile?.mkdirs() + targetFile.writeBytes(bytes) + logger.info("GitLab: wrote ${bytes.size} bytes to ${targetFile.absolutePath}") + Result.success(targetFile) + } catch (e: Exception) { + if (targetFile.exists() && targetFile.length() == 0L) targetFile.delete() + Result.failure(e) + } + } + + // ── Normalization ────────────────────────────────────────────────────── + + private suspend fun parseReleases(rawJson: String): List { + val root = Json.parseToJsonElement(rawJson) + val array = (root as? JsonArray) ?: return emptyList() + + // Pass 1: collect tag/name/etc + (assetName, downloadUrl) pairs, sizes + // still unknown. + data class RawRelease( + val tagName: String, + val name: String?, + val publishedAt: String?, + val description: String?, + val assets: List>, + ) + + val rawReleases: List = array.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + val tagName = obj["tag_name"]?.jsonPrimitive?.content ?: return@mapNotNull null + val name = obj["name"]?.jsonPrimitive?.content + val publishedAt = obj["released_at"]?.jsonPrimitive?.content + val description = obj["description"]?.jsonPrimitive?.content + val links = obj["assets"]?.jsonObject?.get("links")?.jsonArray ?: JsonArray(emptyList()) + val assetPairs = links.mapNotNull { linkEl -> + val link = linkEl as? JsonObject ?: return@mapNotNull null + val assetName = link["name"]?.jsonPrimitive?.content ?: return@mapNotNull null + val downloadUrl = link["direct_asset_url"]?.jsonPrimitive?.content + ?: link["url"]?.jsonPrimitive?.content + ?: return@mapNotNull null + assetName to downloadUrl + } + RawRelease(tagName, name, publishedAt, description, assetPairs) + } + + // Pass 1.5: resolve sizes for .mpp assets via parallel HEAD requests. + // GitLab's 2000 req/hr unauth limit means even ~50 HEADs per fetch + // is comfortably within budget; running them in parallel keeps total + // latency at one round-trip. + val mppUrls: Set = rawReleases + .flatMap { it.assets } + .filter { it.first.endsWith(".mpp", ignoreCase = true) } + .map { it.second } + .toSet() + + val sizesByUrl: Map = if (mppUrls.isEmpty()) { + emptyMap() + } else { + coroutineScope { + mppUrls.map { url -> + async { url to resolveContentLength(url) } + }.awaitAll().toMap() + } + } + + // Pass 2: build the model with resolved sizes spliced in. + return rawReleases.map { raw -> + val releaseAssets = raw.assets.map { (assetName, downloadUrl) -> + ReleaseAsset( + name = assetName, + downloadUrl = downloadUrl, + size = sizesByUrl[downloadUrl] ?: 0L, + ) + } + Release( + tagName = raw.tagName, + name = raw.name, + // GitLab has no prerelease flag — dev detection falls back to + // tag-name patterns inside Release.isDevRelease(). + isPrerelease = false, + publishedAt = raw.publishedAt, + assets = releaseAssets, + body = raw.description, + ) + } + } + + /** + * HEAD a URL and read Content-Length. Returns 0 on any failure — size is + * cosmetic, never blocks the release listing. + */ + private suspend fun resolveContentLength(url: String): Long { + return try { + val response: HttpResponse = httpClient.head(url) + if (!response.status.isSuccess()) { + logger.fine("HEAD $url returned ${response.status}") + return 0L + } + response.headers[HttpHeaders.ContentLength]?.toLongOrNull() ?: 0L + } catch (e: Exception) { + logger.fine("HEAD failed for $url: ${e.message}") + 0L + } + } + + companion object { + private const val API_BASE = "https://gitlab.com/api/v4" + } +} diff --git a/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt b/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt new file mode 100644 index 00000000..2009e845 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/PatchBundleLoader.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import app.morphe.patcher.patch.Patch +import app.morphe.patcher.patch.loadPatchesFromJar +import java.io.File + +/** + * One .mpp file's worth of patches, paired with the file they came from. + * Used by callers (CLI selector scoping, GUI multi-source provenance) that + * need to tell which bundle a patch originated in. + */ +data class LoadedBundle( + val sourceFile: File, + val patches: Set>, +) + +/** + * Bundle-aware patch loader. Wraps morphe-patcher's flat + * [loadPatchesFromJar] so callers can keep per-source separation — + * critical for the CLI when the same patch name appears in multiple + * bundles and selectors (`-e`, `-d`, `-O`) need to be scoped per file. + * + * morphe-patcher itself is unchanged: we just call it once per file + * instead of once with the union. + */ +object PatchBundleLoader { + + /** + * Load each [.mpp] file separately. Returns a list in the same order + * as [files] so callers can pair up CLI argument order with results + * (positional scoping relies on this). + * + * If any file fails to load, the exception propagates — same behavior + * as a flat [loadPatchesFromJar] call. + */ + fun loadEach(files: Iterable): List = + files.map { file -> + LoadedBundle( + sourceFile = file, + patches = loadPatchesFromJar(setOf(file)).toSet(), + ) + } + + /** + * Convenience: load and flatten into a single set, matching the old + * [loadPatchesFromJar] shape. Use only when bundle provenance is + * genuinely irrelevant — e.g. when you've already done per-bundle + * selection and just need the final union to hand to the patcher. + */ + fun loadFlat(files: Iterable): Set> = + loadEach(files).flatMap { it.patches }.toSet() +} diff --git a/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt new file mode 100644 index 00000000..ef03d743 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSource.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import java.io.File + +/** + * Provider-agnostic interface for fetching patch releases from a remote + * source (GitHub, GitLab, …). Implementations own: + * - API endpoint construction + * - HTTP headers and response shape normalization + * - Asset download mechanics (redirects, byte streaming) + * + * Implementations do NOT own: + * - In-memory or disk caching policy — that's a caller concern + * - Multi-source orchestration + * - UI / progress reporting style + * + * This is the engine layer's heart for remote patches. Both the GUI's + * PatchRepository and the CLI's PatchFileResolver call into a + * RemotePatchSource to do the real work, while owning their own caching + * and surface-specific concerns on top. + */ +interface RemotePatchSource { + /** Which remote provider this source talks to. */ + val provider: PatchProvider + + /** The "owner/repo" path on the remote. */ + val repoPath: String + + /** + * Fetch all releases for [repoPath] from the remote API. Implementations + * normalize the provider's JSON shape into the shared [Release] model. + * + * Failures (network, HTTP non-2xx, parse errors) surface as a failed + * [Result] — never throw. Callers decide whether to retry, return stale + * data, or bubble the error to the user. + */ + suspend fun listReleases(): Result> + + /** + * Download [asset] to [targetFile]. Replaces any existing file at the + * target path. Returns the file on success. + * + * Implementations are responsible for: + * - Following any provider-specific redirects (GitLab's `direct_asset_url`) + * - Sending appropriate Accept / auth headers + * - Failing if the response body is empty (zero-byte downloads are + * never valid patch files) + * + * Implementations are NOT responsible for cache-hit checks — the caller + * should look at [targetFile] before calling this if it wants caching. + */ + suspend fun downloadAsset(asset: ReleaseAsset, targetFile: File): Result +} + +/** + * Remote providers the engine knows how to talk to. Add new entries here + * (Gitea, self-hosted GitLab, etc.) and they propagate to every caller. + */ +enum class PatchProvider { + GITHUB, + GITLAB, +} + +/** Convenience: find the .mpp asset in a release. */ +fun Release.findPatchAsset(): ReleaseAsset? = assets.firstOrNull { it.isPatchFile() } diff --git a/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt new file mode 100644 index 00000000..b82010dd --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/patches/RemotePatchSourceFactory.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.patches + +import io.ktor.client.HttpClient + +/** + * Centralized URL parsing + provider detection for remote patch sources. + * + * Single source of truth for "given some user input, figure out the + * provider, owner, and repo." Both GUI (PatchSourceManager, + * PatchSourceDialogs) and CLI (PatchFileResolver) call into this — never + * roll their own URL parsing. + * + * Accepted inputs: + * - Full URL: `https://github.com/owner/repo[/…]`, `https://gitlab.com/owner/repo[/…]` + * - Bare host path: `github.com/owner/repo`, `gitlab.com/owner/repo` + * - Deep-link: `morphe.software/add-source?github=owner/repo` (or `?gitlab=owner/repo`) + * - Bare `owner/repo` — defaults to GitHub for backwards compatibility + * + * Anything that doesn't match → null. + */ +object RemotePatchSourceFactory { + + /** + * Parse a user-entered URL and return a [Parsed] descriptor on success, + * null when the input can't be classified. + * + * Use [instantiate] to turn the descriptor into a working [RemotePatchSource]. + * Splitting parse from instantiation lets callers validate URLs in + * dialogs without needing an HttpClient handy. + */ + fun parse(input: String): Parsed? { + val trimmed = input.trim() + if (trimmed.isBlank()) return null + + // Deep-link form + if (trimmed.contains("morphe.software/add-source")) { + Regex("[?&]github=([^&]+)").find(trimmed)?.let { match -> + return buildParsed(match.groupValues[1], PatchProvider.GITHUB) + } + Regex("[?&]gitlab=([^&]+)").find(trimmed)?.let { match -> + return buildParsed(match.groupValues[1], PatchProvider.GITLAB) + } + return null + } + + if (trimmed.contains("github.com/")) { + val match = Regex("github\\.com/([^/]+/[^/?#]+)").find(trimmed) ?: return null + return buildParsed(match.groupValues[1], PatchProvider.GITHUB) + } + + if (trimmed.contains("gitlab.com/")) { + val match = Regex("gitlab\\.com/([^/]+/[^/?#]+)").find(trimmed) ?: return null + return buildParsed(match.groupValues[1], PatchProvider.GITLAB) + } + + // Bare "owner/repo" — assume GitHub for backwards compatibility with + // the historical default behavior. + if (trimmed.matches(Regex("[\\w.-]+/[\\w.-]+"))) { + return buildParsed(trimmed, PatchProvider.GITHUB) + } + + return null + } + + /** + * Convenience: parse and instantiate in one shot. + */ + fun from(input: String, httpClient: HttpClient): RemotePatchSource? = + parse(input)?.instantiate(httpClient) + + /** + * Build a source for a known provider + repoPath, skipping URL parsing. + * Used by callers that already have the canonical pieces in hand (e.g. + * the GUI's PatchSourceManager loading a previously-saved source). + */ + fun build(provider: PatchProvider, repoPath: String, httpClient: HttpClient): RemotePatchSource = + Parsed(provider, repoPath).instantiate(httpClient) + + private fun buildParsed(rawPath: String, provider: PatchProvider): Parsed? { + val clean = rawPath.trimEnd('/').removeSuffix(".git") + if (!clean.contains('/') || clean.split('/').size != 2) return null + return Parsed(provider, clean) + } + + /** + * Result of parsing — provider + repoPath are all the engine needs to + * spin up a working source. The canonical URL is reconstructed from + * provider + repoPath via [canonicalUrl] for surface code that needs + * to persist or display it. + */ + data class Parsed( + val provider: PatchProvider, + val repoPath: String, + ) { + val canonicalUrl: String + get() = when (provider) { + PatchProvider.GITHUB -> "https://github.com/$repoPath" + PatchProvider.GITLAB -> "https://gitlab.com/$repoPath" + } + + fun instantiate(httpClient: HttpClient): RemotePatchSource = when (provider) { + PatchProvider.GITHUB -> GitHubPatchSource(httpClient, repoPath) + PatchProvider.GITLAB -> GitLabPatchSource(httpClient, repoPath) + } + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt b/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt new file mode 100644 index 00000000..a3e6a752 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/ApkManifestReader.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import com.reandroid.arsc.chunk.xml.AndroidManifestBlock +import java.io.File +import java.util.logging.Logger +import java.util.zip.ZipFile + +/** + * Read structural metadata from an APK's `AndroidManifest.xml` using ARSCLib. + * + * This is the **only** manifest reader we use across the project. It replaces + * `net.dongliu:apk-parser`, which is unmaintained and crashes on split APKs + * whose base.apk references resources living in other splits (SoundCloud, + * Spotify, every large modular Play Store app). ARSCLib is the same library + * morphe-patcher uses internally — so anything the patcher can read, we can + * read. + * + * Only **direct string attributes** are exposed (packageName, versionName, + * minSdkVersion). The application label is included only when it's a literal + * string in the manifest — when it's a `@string/app_name` resource reference, + * resolving it would require the full resource table (and the split tables + * for split APKs), which we deliberately don't do. Callers that need a + * friendly display name should look it up against their supported-apps list + * by packageName. + * + * Caller is responsible for extracting `base.apk` from bundle formats + * (.apkm/.xapk/.apks) before passing to [read]. + */ +object ApkManifestReader { + private val logger = Logger.getLogger(ApkManifestReader::class.java.name) + + /** + * Read the manifest of an APK file. Returns null on failure (corrupt, + * not an APK, missing AndroidManifest.xml). + */ + fun read(apkFile: File): ApkManifest? { + return try { + ZipFile(apkFile).use { zip -> + val entry = zip.getEntry("AndroidManifest.xml") ?: run { + logger.warning("No AndroidManifest.xml in ${apkFile.name}") + return null + } + val block = zip.getInputStream(entry).use { input -> + AndroidManifestBlock.load(input) + } + val packageName = block.packageName ?: run { + logger.warning("Manifest has no package name in ${apkFile.name}") + return null + } + ApkManifest( + packageName = packageName, + versionName = block.versionName, + versionCode = block.versionCode, + minSdkVersion = block.minSdkVersion, + applicationLabel = block.applicationLabelString, + ) + } + } catch (e: Exception) { + logger.warning("Failed to read manifest from ${apkFile.name}: ${e.message}") + null + } + } +} + +/** + * Direct attributes from `AndroidManifest.xml`. None of these require resource + * resolution — they're plain strings/integers stored inline in the manifest. + * + * @property packageName always present (manifest is rejected without it) + * @property versionName may be null for APKs that omit it (rare) + * @property versionCode may be null for APKs that omit it (rare) + * @property minSdkVersion from `` + * @property applicationLabel the app's display name, only when stored as a + * literal string. Null when stored as a resource + * reference (`@string/app_name`) — callers should + * fall back to a supported-apps lookup by package. + */ +data class ApkManifest( + val packageName: String, + val versionName: String?, + val versionCode: Int?, + val minSdkVersion: Int?, + val applicationLabel: String?, +) diff --git a/src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt b/src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt new file mode 100644 index 00000000..bac028b4 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/ApkOutputNaming.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import java.io.File + +/** + * Shared filename helpers + output-path computation for patched APKs. Used by + * both the GUI ([app.morphe.gui.ui.screens.patches.PatchSelectionViewModel]) + * and the CLI ([app.morphe.cli.command.PatchCommand]) so identical inputs + * produce identical output paths — no surprises when users switch between + * surfaces. + * + * Lives in `engine.util` because output naming is a pure data transformation + * with no UI or CLI dependencies, and consolidating it in the engine moves + * one more thing toward the long-term "engine is the heart" architecture. + */ +object ApkOutputNaming { + + private val patchesVersionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") + + /** + * Extract APK version from an APKMirror-style filename: + * `_-.apk` → returns ``. + * Also handles the same convention with .apkm/.xapk/.apks extensions — + * `_.apkm` → returns ``. Returns null for + * filenames that don't follow this convention. + */ + fun extractApkVersionFromFilename(fileName: String): String? = try { + // Strip the bundle extension first so it doesn't leak into the version. + // File.nameWithoutExtension handles single extensions cleanly; we list + // the .apk-family ones explicitly because filenames like + // `soundcloud_2026.04.27.apkm` have multiple "extensions" in a row + // (the version dots look like extensions to nameWithoutExtension). + val withoutExt = fileName + .removeSuffix(".apk") + .removeSuffix(".apkm") + .removeSuffix(".xapk") + .removeSuffix(".apks") + val afterPackage = withoutExt.substringAfter("_") + afterPackage.substringBefore("-").takeIf { it.isNotEmpty() } + } catch (e: Exception) { + null + } + + /** + * Extract patches version from a .mpp filename like + * `morphe-patches-1.13.0.mpp` or `morphe-patches-1.13.0-dev.5.mpp`. + * Returns the bare version string (`1.13.0` / `1.13.0-dev.5`) or null + * when no version-shaped token is present. + */ + fun extractPatchesVersion(patchesFileName: String): String? = + patchesVersionRegex.find(patchesFileName)?.groupValues?.get(1) + + /** + * Resolve the human-friendly app label from an APK file via ARSCLib + * (engine [ApkManifestReader]). Returns null when: + * - the manifest can't be read at all (corrupt APK) + * - the manifest has no label + * - the label is stored as a resource reference (`@string/app_name`) + * instead of a literal string — common for big apps. Callers should + * fall back to a supported-apps lookup or filename in that case. + */ + fun resolveAppDisplayName(apkFile: File): String? = + ApkManifestReader.read(apkFile)?.applicationLabel?.takeIf { it.isNotBlank() } + + /** + * Compute the unified output APK path. Layout: + * `//-Morphe-{apkVer}-patches-{patchesVer}.apk` + * + * - Per-app subfolder prevents collisions when patching different APK + * versions of the same package + * - Both versions encoded in the filename so the output is self-describing + * - `patchesFile` is optional; if null, no `-patches-{ver}` suffix is added + * + * @param inputApk the APK being patched. Its parent directory is the + * default base unless [baseOutputDir] is provided. + * @param patchesFile primary `.mpp` file. Used only for the suffix — + * in multi-source mode pass any one of the bundles. + * @param baseOutputDir override for the base directory (e.g. the GUI's + * configured default output directory). Defaults to + * `inputApk.parentFile`. + * @param appDisplayName Pre-resolved app label (e.g. "Youtube"). If null, + * falls back to the input APK's filename without + * extension. GUI callers pass the value from their + * apkInfo; the CLI can call [resolveAppDisplayName] + * to populate this. + */ + fun outputApkPath( + inputApk: File, + patchesFile: File? = null, + baseOutputDir: File? = null, + appDisplayName: String? = null, + ): File { + val appFolderName = (appDisplayName ?: inputApk.nameWithoutExtension) + .replace(" ", "-") + val base = baseOutputDir + ?: inputApk.absoluteFile.parentFile + ?: File("").absoluteFile + val outputDir = File(base, appFolderName).also { it.mkdirs() } + val version = extractApkVersionFromFilename(inputApk.name) ?: "patched" + val patchesVersion = patchesFile?.name?.let { extractPatchesVersion(it) } + val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" + return File(outputDir, "${appFolderName}-Morphe-${version}${patchesSuffix}.apk") + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt b/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt new file mode 100644 index 00000000..c859af7e --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/KeystoreSigner.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import app.morphe.engine.PatchEngine +import app.morphe.patcher.apk.ApkUtils +import java.util.logging.Logger + +/** + * Signs an APK with [primary] credentials, falling back to the legacy ("Morphe Key" / empty password) entry. + * The legacy retry only fires when [allowLegacyFallback] is true AND the keystore file already exists, + * i.e. the user is on default credentials and we're reading a pre-existing keystore that might predate the current alias. + * This preserves the exact condition both call sites (CLI + engine) used before. + * + * On double failure the PRIMARY exception is thrown (legacy attached as suppressed). + * The primary error is the meaningful one: the user expects the current Morphe key, + * so "no 'Morphe' entry" is more actionable than whatever the legacy retry hit. + * The old behavior threw the *legacy* failure, which surfaced confusing errors. + * + * [sign] performs the actual signing; callers wrap this call with their own progress / step-result reporting. + */ +fun signWithLegacyFallback( + primary: ApkUtils.KeyStoreDetails, + allowLegacyFallback: Boolean, + logger: Logger, + sign: (ApkUtils.KeyStoreDetails) -> Unit, +) { + try { + sign(primary) + } catch (primaryError: Exception) { + if (!allowLegacyFallback || !primary.keyStore.exists()) throw primaryError + + // Never silently swallow the real cause. Always log it before the back-compat path. + logger.info( + "Default keystore credentials failed (${primaryError.message}). Retrying with legacy credentials" + ) + + val legacy = ApkUtils.KeyStoreDetails( + primary.keyStore, + primary.keyStorePassword, + PatchEngine.Config.LEGACY_KEYSTORE_ALIAS, + PatchEngine.Config.LEGACY_KEYSTORE_PASSWORD, + ) + try { + sign(legacy) + } catch (legacyError: Exception) { + primaryError.addSuppressed(legacyError) + throw primaryError + } + } +} diff --git a/src/main/kotlin/app/morphe/engine/util/PortablePaths.kt b/src/main/kotlin/app/morphe/engine/util/PortablePaths.kt new file mode 100644 index 00000000..e3311f03 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/PortablePaths.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +import app.morphe.engine.MorpheData +import java.io.File + +/** + * Two-way conversion between **stored** path strings (what lives in + * `config.json`) and **live** [File] instances (what the rest of the app + * works with). + * + * The goal: paths picked by the user that live **inside the bundle** (the + * JAR's containing directory and anything below it) are stored relative to + * [MorpheData.bundleRoot], so the entire bundle — JAR + `morphe-data/` + + * any sibling folders the user chose for output/keystore — can be moved to + * a different location (USB stick, Desktop → Documents, another machine) + * without breaking the config. Paths picked outside the bundle are stored + * absolute as before because there's no useful anchor for them. + * + * Read sites must go through [resolve]; write sites must go through + * [storableForm]. Bypassing either side breaks portability silently. + */ +object PortablePaths { + + /** + * Convert a user-picked absolute path to the form we want to persist in + * config. Returns the **anchor-relative** path string when [absolutePath] + * lives at or below [MorpheData.bundleRoot]; otherwise returns + * [absolutePath] unchanged. + * + * Falls through to the absolute form in fallback mode + * ([MorpheData.bundleRoot] == null) since there's no portable bundle to + * anchor against. + */ + fun storableForm(absolutePath: String): String { + val anchor = MorpheData.bundleRoot?.canonicalFile ?: return absolutePath + val target = try { + File(absolutePath).canonicalFile + } catch (e: Exception) { + return absolutePath + } + return if (target.startsWith(anchor)) { + // `relativeTo` is the inverse of File(anchor, x). Returns "" when + // target == anchor — fall back to absolute in that edge case to + // avoid storing an empty string that would round-trip to anchor. + val rel = target.relativeTo(anchor).path + if (rel.isEmpty()) absolutePath else rel + } else { + absolutePath + } + } + + /** + * Convert a stored path string back to a live [File]. Absolute paths + * pass through unchanged; relative paths are resolved against + * [MorpheData.bundleRoot] (NOT against the JVM's working directory, + * which would be unstable — the user can launch the JAR from anywhere). + */ + fun resolve(stored: String): File { + val f = File(stored) + if (f.isAbsolute) return f + val anchor = MorpheData.bundleRoot ?: return f.absoluteFile + return File(anchor, stored) + } +} diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index 4ccd9d25..067a9b94 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -22,6 +22,7 @@ import app.morphe.gui.ui.components.SakuraPetals import app.morphe.gui.util.applyTitleBarTint import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.SlideTransition +import app.morphe.gui.data.repository.ActiveMode import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.di.appModule @@ -51,6 +52,21 @@ val LocalModeState = staticCompositionLocalOf { error("No ModeState provided") } +/** + * Auto-start ADB preference. Exposed as a composition local so the + * SettingsDialog (writer) and DeviceIndicator + install buttons (readers) + * can react without prop-drilling through Voyager screens. App-level + * lifecycle (start/stop the daemon when this flips) is handled in [App.kt]. + */ +data class AdbPreferenceState( + val enabled: Boolean, + val onChange: (Boolean) -> Unit, +) + +val LocalAdbPreference = staticCompositionLocalOf { + error("No AdbPreferenceState provided") +} + @Composable fun App( initialSimplifiedMode: Boolean = true @@ -76,6 +92,7 @@ private fun AppContent( var themePreference by remember { mutableStateOf(ThemePreference.SYSTEM) } var isSimplifiedMode by remember { mutableStateOf(initialSimplifiedMode) } + var autoStartAdb by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(true) } // Initialize PatchSourceManager and load config on startup @@ -84,6 +101,13 @@ private fun AppContent( val config = configRepository.loadConfig() themePreference = config.getThemePreference() isSimplifiedMode = config.useSimplifiedMode + + autoStartAdb = config.autoStartAdb + // Publish the initial active mode BEFORE the VMs subscribe so their + // activeMode listener fires with the correct value on first emit. + patchSourceManager.setActiveMode( + if (isSimplifiedMode) ActiveMode.QUICK else ActiveMode.EXPERT + ) isLoading = false } @@ -99,12 +123,36 @@ private fun AppContent( // Callback for changing mode val onModeChange: (Boolean) -> Unit = { simplified -> isSimplifiedMode = simplified + // Update the manager immediately so the now-visible mode's VM + // starts reacting to source changes and the now-hidden one stops — + // prevents duplicate parallel loads and the cancellation cascade + // that comes with them. + patchSourceManager.setActiveMode( + if (simplified) ActiveMode.QUICK else ActiveMode.EXPERT + ) scope.launch { configRepository.setUseSimplifiedMode(simplified) Logger.info("Mode changed to: ${if (simplified) "Simplified" else "Full"}") } } + // Callback for the auto-start ADB toggle. Persists the preference AND + // applies the change immediately: ON spins up DeviceMonitor (which + // explicitly start-server's adb and records ownership); OFF cancels + // polling and kill-server's the daemon if Morphe owns it. + val onAutoStartAdbChange: (Boolean) -> Unit = { enabled -> + autoStartAdb = enabled + scope.launch { + configRepository.setAutoStartAdb(enabled) + if (enabled) { + DeviceMonitor.startMonitoring() + } else { + DeviceMonitor.stopMonitoringAndKillIfOwned() + } + Logger.info("Auto-start ADB ${if (enabled) "enabled" else "disabled"}") + } + } + val themeState = ThemeState( current = themePreference, onChange = onThemeChange @@ -115,9 +163,24 @@ private fun AppContent( onChange = onModeChange ) - // Start/stop DeviceMonitor with app lifecycle + val adbPreferenceState = AdbPreferenceState( + enabled = autoStartAdb, + onChange = onAutoStartAdbChange + ) + + // Initial DeviceMonitor start. Gated on autoStartAdb so users who left + // the toggle OFF don't spawn an unwanted adb daemon at launch. Runs once + // after config finishes loading. Subsequent live toggles go through + // [onAutoStartAdbChange], not this effect. + LaunchedEffect(isLoading, autoStartAdb) { + if (!isLoading && autoStartAdb) { + DeviceMonitor.startMonitoring() + } + } + // On Compose teardown (window close → exitApplication), cancel polling. + // The kill-if-owned half runs from the JVM shutdown hook in [GuiMain.kt] + // so it works even when the user quits via Cmd+Q without disposing. DisposableEffect(Unit) { - DeviceMonitor.startMonitoring() onDispose { DeviceMonitor.stopMonitoring() } @@ -126,7 +189,8 @@ private fun AppContent( MorpheTheme(themePreference = themePreference) { CompositionLocalProvider( LocalThemeState provides themeState, - LocalModeState provides modeState + LocalModeState provides modeState, + LocalAdbPreference provides adbPreferenceState ) { // Tint the OS title bar (Windows DWM caption color, macOS traffic // light contrast) to match the active theme's surface color. diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt index 002e2c9a..6e1b77cd 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import app.morphe.gui.data.model.AppConfig +import app.morphe.gui.util.DeviceMonitor +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import org.jetbrains.skia.Image import app.morphe.gui.util.FileUtils @@ -34,6 +36,18 @@ fun launchGui(args: Array) = application { else -> loadConfigSync().useSimplifiedMode } + // Belt-and-braces: on any JVM-normal exit path (window close, Cmd+Q, + // SIGTERM), kill the ADB daemon if Morphe spawned it. Compose's + // DisposableEffect already cancels polling; this hook covers shutdown + // routes where Compose teardown doesn't reach the suspend kill call. + remember { + Runtime.getRuntime().addShutdownHook(Thread { + runCatching { + runBlocking { DeviceMonitor.stopMonitoringAndKillIfOwned() } + } + }) + } + val windowState = rememberWindowState( size = DpSize(1024.dp, 768.dp), position = WindowPosition(Alignment.Center) diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index 001ccf85..4ef8ed1a 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -7,9 +7,11 @@ package app.morphe.gui.data.model import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD +import app.morphe.engine.util.PortablePaths import app.morphe.gui.ui.theme.ThemePreference import app.morphe.gui.util.FileUtils.ANDROID_ARCHITECTURES import kotlinx.serialization.Serializable +import java.io.File /** * Application configuration stored in config.json @@ -27,7 +29,20 @@ val DEFAULT_PATCH_SOURCE = PatchSource( data class AppConfig( val themePreference: String = ThemePreference.SYSTEM.name, val lastCliVersion: String? = null, + /** + * LEGACY single-source version pin. Kept only for one-version migration into + * [lastPatchesVersionBySource] — read it on first load if the map is empty, + * then phase out. Do not read this directly anywhere new — go through + * [ConfigRepository.getLastPatchesVersionsBySource]. + */ val lastPatchesVersion: String? = null, + /** + * Per-source version pin: sourceId → release tag. Absence of a key means + * "no pin — use that source's latest stable". Replaces the legacy single + * [lastPatchesVersion] which silently contaminated other sources whose tag + * names happened to overlap. + */ + val lastPatchesVersionBySource: Map = emptyMap(), val preferredPatchChannel: String = PatchChannel.STABLE.name, val defaultOutputDirectory: String? = null, val autoCleanupTempFiles: Boolean = true, // Default ON @@ -57,6 +72,16 @@ data class AppConfig( // user who swaps from a stable build to a dev build sees the right default. // Once they pick one in Settings, this flips to true and we respect their choice. val userDidChooseUpdateChannel: Boolean = false, + // One-shot dismissal flag for the "multiple sources are now active" hint shown + // after upgrading to multi-source builds. Flips to true once the user dismisses + // the banner, never resets. + val multiSourceHintDismissed: Boolean = false, + // Whether Morphe should auto-start the ADB daemon at GUI launch to monitor + // connected devices. Default OFF — many users never push patched APKs to a + // device, so spawning a long-lived adb server unprompted is unwanted noise. + // When ON, DeviceMonitor polls devices; if Morphe was the one that started + // the daemon, it's killed on toggle-OFF and on window close. + val autoStartAdb: Boolean = false, ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { @@ -82,6 +107,20 @@ data class AppConfig( PatchChannel.STABLE } } + + /** + * Resolved live [File] for [defaultOutputDirectory]. Goes through + * [PortablePaths.resolve] so a stored relative value is anchored to the + * bundle, not the JVM's CWD. Use this instead of `File(...)` at call sites. + */ + fun resolvedDefaultOutputDirectory(): File? = + defaultOutputDirectory?.let(PortablePaths::resolve) + + /** + * Resolved live [File] for [keystorePath]. See [resolvedDefaultOutputDirectory]. + */ + fun resolvedKeystorePath(): File? = + keystorePath?.let(PortablePaths::resolve) } @Serializable @@ -89,14 +128,19 @@ data class PatchSource ( val id: String, val name: String, val type: PatchSourceType, - val url: String? = null, // For DEFAULT (morphe) and GITHUB (other source) type + // For DEFAULT (morphe), GITHUB and GITLAB sources: the canonical + // "https://{host}/{owner}/{repo}" URL. + val url: String? = null, val filePath: String? = null, // For local files - val deletable: Boolean = true + val deletable: Boolean = true, + // Multi-source enablement. Default true so old configs migrate to "all enabled" + // on first load (per user choice — see project memory). + val enabled: Boolean = true, ) @Serializable enum class PatchSourceType{ - DEFAULT, GITHUB, LOCAL + DEFAULT, GITHUB, GITLAB, LOCAL } enum class PatchChannel { diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt index 87e83811..e2769a68 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -84,7 +84,8 @@ enum class PatchOptionType { data class PatchConfig( val inputApkPath: String, val outputApkPath: String, - val patchesFilePath: String, + /** One or more .mpp file paths. Multiple = union of patches across sources. */ + val patchesFilePaths: List, val enabledPatches: List = emptyList(), val disabledPatches: List = emptyList(), val patchOptions: Map = emptyMap(), diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt new file mode 100644 index 00000000..235e2706 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigMigration.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.data.repository + +import app.morphe.engine.MorpheData +import app.morphe.gui.util.Logger +import java.io.File +import java.nio.file.Paths + +/** + * One-time migration of GUI persisted state from the legacy per-OS app-data + * folder to the unified `morphe-data/` introduced by the data-location refactor. + * + * Migrates: + * - `config.json` — GUI preferences (theme, enabled patch sources, etc.) + * - `patch-preferences.json` — per-app/per-source saved patch selections + * (the "Your Defaults" data shown on the patches screen) + * + * Behavior (per file): + * 1. If the new file already exists → no-op (assume migrated / fresh install). + * 2. If there's no legacy file either → no-op (genuine fresh install). + * 3. If only the legacy file exists → COPY it (don't move) to the new + * location. The old file stays in place as a safety net; users can + * delete it manually once they've verified the new build works. + * + * Lives outside ConfigRepository so the migration logic is self-contained + * and easy to delete in a future release once enough users have upgraded. + */ +object ConfigMigration { + + private const val APP_NAME = "morphe-gui" + + /** + * Run the migration. Idempotent — safe to call on every app launch. + * Called from ConfigRepository.loadConfig before the existing read. + */ + fun runIfNeeded() { + // config.json → morphe-data/config.json + migrateFileIfNeeded( + legacyFileName = "config.json", + newFile = MorpheData.configFile, + ) + // patch-preferences.json → morphe-data/patch-preferences.json + // Owned by PatchPreferencesRepository — same legacy dir, same name. + // Without this, users lose all saved per-app patch selections on + // first launch after the upgrade. + migrateFileIfNeeded( + legacyFileName = "patch-preferences.json", + newFile = File(MorpheData.root, "patch-preferences.json"), + ) + } + + /** + * Generic per-file migration. Looks for [legacyFileName] inside the + * platform's legacy app-data folder; if found AND the new location is + * empty, copies the file across. + */ + private fun migrateFileIfNeeded(legacyFileName: String, newFile: File) { + if (newFile.exists()) return // already migrated or new install + + val legacyDir = legacyAppDataDir() ?: return + val legacyFile = File(legacyDir, legacyFileName) + if (!legacyFile.exists()) return // nothing to migrate + + try { + // Copy, NOT move — paranoid first release. If anything goes wrong + // with the new path, the user's old file is intact. We can + // tighten this to a move in a future release once stability is + // proven. + newFile.parentFile?.mkdirs() + legacyFile.copyTo(newFile, overwrite = false) + Logger.info( + "Migrated $legacyFileName from ${legacyFile.absolutePath} " + + "to ${newFile.absolutePath} (old file preserved as backup)" + ) + } catch (e: Exception) { + // Non-fatal: if migration fails, we proceed without the file + // and the user falls back to defaults / re-configures. Better + // than crashing on startup over a copy that didn't work. + Logger.warn( + "Could not migrate legacy $legacyFileName from ${legacyFile.absolutePath}: ${e.message}" + ) + } + } + + /** + * Where the GUI used to put its persisted files before the unified-data + * refactor. Returns null on unrecognized platforms (in which case there's + * nothing to migrate from). + */ + private fun legacyAppDataDir(): File? { + val osName = System.getProperty("os.name").lowercase() + val userHome = System.getProperty("user.home") + + return when { + osName.contains("win") -> { + val appData = System.getenv("APPDATA") + ?: Paths.get(userHome, "AppData", "Roaming").toString() + File(appData, APP_NAME) + } + osName.contains("mac") -> { + File(userHome, "Library/Application Support/$APP_NAME") + } + "linux" in osName || "nix" in osName || "nux" in osName -> { + File(userHome, ".config/$APP_NAME") + } + else -> null + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index 5133a440..a0477ffa 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -5,6 +5,7 @@ package app.morphe.gui.data.repository +import app.morphe.engine.util.PortablePaths import app.morphe.gui.data.model.AppConfig import app.morphe.gui.data.model.DEFAULT_PATCH_SOURCE import app.morphe.gui.data.model.PatchChannel @@ -36,6 +37,10 @@ class ConfigRepository { suspend fun loadConfig(): AppConfig = withContext(Dispatchers.IO) { cachedConfig?.let { return@withContext it } + // One-time migration from the legacy per-OS app-data path to the + // unified morphe-data location. Runs once and is a no-op thereafter. + ConfigMigration.runIfNeeded() + val configFile = FileUtils.getConfigFile() try { @@ -97,13 +102,43 @@ class ConfigRepository { } /** - * Update last used patches version. + * LEGACY — kept so single-source callers don't break during the multi-source + * transition. New code should use [setLastPatchesVersionForSource]. */ + @Deprecated("Use setLastPatchesVersionForSource", ReplaceWith("setLastPatchesVersionForSource(sourceId, version)")) suspend fun setLastPatchesVersion(version: String) { val current = loadConfig() saveConfig(current.copy(lastPatchesVersion = version)) } + /** + * Pin a specific release tag for [sourceId]. Used by PatchesScreen when the + * user picks a version. Per-source = no cross-contamination across sources + * with overlapping tag names. + */ + suspend fun setLastPatchesVersionForSource(sourceId: String, version: String) { + val current = loadConfig() + val updated = current.lastPatchesVersionBySource + (sourceId to version) + saveConfig(current.copy(lastPatchesVersionBySource = updated)) + } + + /** + * Returns the per-source version pin map, with one-time migration from the + * legacy [AppConfig.lastPatchesVersion] field: if the map is empty and the + * legacy field is set, it's mapped to the default source. + */ + suspend fun getLastPatchesVersionsBySource(): Map { + val current = loadConfig() + if (current.lastPatchesVersionBySource.isNotEmpty()) { + return current.lastPatchesVersionBySource + } + val legacy = current.lastPatchesVersion ?: return emptyMap() + // Migrate: write the legacy pin onto the default source, return the new map. + val migrated = mapOf(DEFAULT_PATCH_SOURCE.id to legacy) + saveConfig(current.copy(lastPatchesVersionBySource = migrated)) + return migrated + } + /** * Mark the given CLI version as dismissed for the update banner. Pass null to * clear (so the banner reappears for whatever the next-available version is). @@ -158,7 +193,7 @@ class ConfigRepository { */ suspend fun setDefaultOutputDirectory(path: String?) { val current = loadConfig() - saveConfig(current.copy(defaultOutputDirectory = path)) + saveConfig(current.copy(defaultOutputDirectory = path?.let(PortablePaths::storableForm))) } /** @@ -200,7 +235,7 @@ class ConfigRepository { */ suspend fun setKeystorePath(path: String?) { val current = loadConfig() - saveConfig(current.copy(keystorePath = path)) + saveConfig(current.copy(keystorePath = path?.let(PortablePaths::storableForm))) } /** @@ -214,7 +249,7 @@ class ConfigRepository { ) { val current = loadConfig() saveConfig(current.copy( - keystorePath = path, + keystorePath = path?.let(PortablePaths::storableForm), keystorePassword = password, keystoreAlias = alias, keystoreEntryPassword = entryPassword @@ -261,6 +296,52 @@ class ConfigRepository { saveConfig(current.copy(patchSource = updatedSources)) } + /** + * Update whether Morphe auto-starts the ADB daemon at GUI launch. + */ + suspend fun setAutoStartAdb(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(autoStartAdb = enabled)) + } + + /** + * Mark the multi-source upgrade hint as dismissed. One-shot — never resets. + */ + suspend fun setMultiSourceHintDismissed() { + val current = loadConfig() + if (current.multiSourceHintDismissed) return + saveConfig(current.copy(multiSourceHintDismissed = true)) + } + + /** + * Toggle enablement of a patch source. Safety net: if disabling would leave zero + * enabled sources, the default source is force-enabled (mirrors morphe-manager + * SourceManagementSheet.kt:142-149 LaunchedEffect). + */ + suspend fun setPatchSourceEnabled(id: String, enabled: Boolean) { + val current = loadConfig() + val updatedSources = current.patchSource.map { + if (it.id == id) it.copy(enabled = enabled) else it + } + val anyEnabled = updatedSources.any { it.enabled } + val finalSources = if (!anyEnabled) { + // Safety net: force-enable the default + updatedSources.map { + if (it.id == DEFAULT_PATCH_SOURCE.id) it.copy(enabled = true) else it + } + } else { + updatedSources + } + saveConfig(current.copy(patchSource = finalSources)) + } + + /** + * Get the list of currently enabled patch sources (in config order). + */ + suspend fun getEnabledPatchSources(): List { + return loadConfig().patchSource.filter { it.enabled } + } + /** * Remove a patch source by ID. Cannot remove non-deletable sources. */ diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt index fcd03087..fcfc1fa4 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -5,180 +5,155 @@ package app.morphe.gui.data.repository -import app.morphe.gui.data.model.Release -import app.morphe.gui.data.model.ReleaseAsset -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import app.morphe.engine.model.Release +import app.morphe.engine.model.ReleaseAsset +import app.morphe.engine.patches.RemotePatchSource +import app.morphe.engine.patches.findPatchAsset import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File /** - * Repository for fetching patches from GitHub releases. - * @param repoPath GitHub repo in "owner/repo" format (e.g. "MorpheApp/morphe-patches") + * GUI-side wrapper around an engine [RemotePatchSource]. Adds: + * - 5-minute in-memory TTL on the release listing (so repeated UI calls + * don't re-hit the API every time) + * - Disk cache for downloaded .mpp files keyed by source's repoPath + * - Filter helpers (stable/dev) and cache lookup helpers tailored to the + * GUI's needs + * + * The remote provider logic itself (URL construction, HTTP, JSON shape) is + * NOT here — it lives in the engine. This class is purely a caching + + * convenience layer. */ class PatchRepository( - private val httpClient: HttpClient, - private val repoPath: String = DEFAULT_REPO + private val remoteSource: RemotePatchSource, ) { + val repoPath: String get() = remoteSource.repoPath + companion object { - private const val GITHUB_API_BASE = "https://api.github.com" - private const val DEFAULT_REPO = "MorpheApp/morphe-patches" private const val CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes - } - private val releasesEndpoint = "$GITHUB_API_BASE/repos/$repoPath/releases" + /** + * Per-release filename used in the disk cache. + * + * Many patch source maintainers (including MorpheApp/morphe-patches) + * name their `.mpp` release asset the SAME string across versions, + * e.g. `morphe-patches.mpp`. Storing them by their bare asset name + * means each new download overwrites the previous version — only ONE + * file ever lives in the cache. Worse, the size-match check made + * `checkCachedPatches` return a "hit" for the latest version (whose + * size happened to match the on-disk file) while older versions + * correctly returned a miss — so the patches-screen UI showed + * SELECT for the latest and DOWNLOAD for everything else, even + * right after a Clear Cache. + * + * Prepending the release tag (`v1.5.0__morphe-patches.mpp`) gives + * each version its own file. Cache hits are now per-version exactly. + * The double-underscore is a deliberate visual delimiter — easier + * to eyeball when grepping the cache directory than a single dash. + */ + fun cachedFileName(release: Release, asset: ReleaseAsset): String = + "${release.tagName}__${asset.name}" + } - // In-memory cache so multiple callers (both modes) don't re-fetch from GitHub + // In-memory cache so multiple callers don't re-fetch from the remote API private var cachedReleases: List? = null private var cacheTimestamp: Long = 0L /** - * Fetch all releases from GitHub. Returns cached result if still fresh. + * Fetch all releases. Returns cached result if still fresh. * @param forceRefresh bypass the in-memory cache */ - suspend fun fetchReleases(forceRefresh: Boolean = false): Result> = withContext(Dispatchers.IO) { - // Return cached releases if still fresh - val cached = cachedReleases - if (!forceRefresh && cached != null && (System.currentTimeMillis() - cacheTimestamp) < CACHE_TTL_MS) { - Logger.info("Using cached releases (${cached.size} releases, age=${(System.currentTimeMillis() - cacheTimestamp) / 1000}s)") - return@withContext Result.success(cached) - } - - try { - Logger.info("Fetching releases from $releasesEndpoint") - val response: HttpResponse = httpClient.get(releasesEndpoint) { - headers { - append(HttpHeaders.Accept, "application/vnd.github+json") - append("X-GitHub-Api-Version", "2022-11-28") - } + suspend fun fetchReleases(forceRefresh: Boolean = false): Result> = + withContext(Dispatchers.IO) { + val cached = cachedReleases + if (!forceRefresh && cached != null && + (System.currentTimeMillis() - cacheTimestamp) < CACHE_TTL_MS + ) { + Logger.info("Using cached releases (${cached.size} releases, age=${(System.currentTimeMillis() - cacheTimestamp) / 1000}s)") + return@withContext Result.success(cached) } - if (response.status.isSuccess()) { - val releases: List = response.body() - Logger.info("Fetched ${releases.size} releases from $releasesEndpoint") - cachedReleases = releases + val result = remoteSource.listReleases() + result.onSuccess { fresh -> + cachedReleases = fresh cacheTimestamp = System.currentTimeMillis() - Result.success(releases) - } else { - val error = "Failed to fetch releases: ${response.status}" - Logger.error(error) - Result.failure(Exception(error)) } - } catch (e: Exception) { - Logger.error("Error fetching releases", e) - // If we have stale cached data, return it rather than failing - val stale = cachedReleases - if (stale != null) { - Logger.info("Returning stale cached releases after fetch failure") - Result.success(stale) - } else { - Result.failure(e) + // If fetch failed but we still have stale data, prefer the stale + // data over a hard error. Matches the previous behavior — keeps + // offline / flaky-network sessions usable. + if (result.isFailure) { + val stale = cachedReleases + if (stale != null) { + Logger.info("Returning stale cached releases after fetch failure") + return@withContext Result.success(stale) + } } + result } - } - /** - * Get stable releases only (non-prerelease). - */ - suspend fun fetchStableReleases(): Result> { - return fetchReleases().map { releases -> - releases.filter { !it.isDevRelease() } - } - } + /** Stable releases only (non-prerelease). */ + suspend fun fetchStableReleases(): Result> = + fetchReleases().map { releases -> releases.filter { !it.isDevRelease() } } - /** - * Get dev/prerelease versions only. - */ - suspend fun fetchDevReleases(): Result> { - return fetchReleases().map { releases -> - releases.filter { it.isDevRelease() } - } - } + /** Dev / prerelease versions only. */ + suspend fun fetchDevReleases(): Result> = + fetchReleases().map { releases -> releases.filter { it.isDevRelease() } } - /** - * Get the latest stable release. - */ - suspend fun getLatestStableRelease(): Result { - return fetchStableReleases().map { it.firstOrNull() } - } + suspend fun getLatestStableRelease(): Result = + fetchStableReleases().map { it.firstOrNull() } - /** - * Get the latest dev release. - */ - suspend fun getLatestDevRelease(): Result { - return fetchDevReleases().map { it.firstOrNull() } - } + suspend fun getLatestDevRelease(): Result = + fetchDevReleases().map { it.firstOrNull() } - /** - * Find the patch .mpp asset in a release. - */ - fun findPatchAsset(release: Release): ReleaseAsset? { - return release.assets.find { it.isPatchFile() } - } + /** Find the patch .mpp asset in a release. */ + fun findPatchAsset(release: Release): ReleaseAsset? = release.findPatchAsset() /** - * Download the patch .mpp file from a release. - * Returns the path to the downloaded file. + * Download the patch .mpp file from a release. Handles the disk cache — + * if a matching file is already present, skips the network call entirely. */ - suspend fun downloadPatches(release: Release, onProgress: (Float) -> Unit = {}): Result = withContext(Dispatchers.IO) { - val asset = findPatchAsset(release) - if (asset == null) { - val error = "No .mpp patch files found in release ${release.tagName}" - Logger.error(error) - return@withContext Result.failure(Exception(error)) - } + suspend fun downloadPatches( + release: Release, + onProgress: (Float) -> Unit = {}, + ): Result = withContext(Dispatchers.IO) { + val asset = release.findPatchAsset() + ?: return@withContext Result.failure( + Exception("No .mpp patch files found in release ${release.tagName}") + ) val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) patchesDir.mkdirs() - val targetFile = File(patchesDir, asset.name) - - // Check if already cached - if (targetFile.exists() && targetFile.length() == asset.size) { - Logger.info("Using cached patches: ${targetFile.absolutePath}") + val targetFile = File(patchesDir, cachedFileName(release, asset)) + + // Cache hit rules: + // - If we know the asset's expected size (GitHub provides it), + // the cached file must match exactly. + // - If size is unknown (some GitLab cases), fall back to "file + // exists and is non-empty". A zero-byte file is always treated + // as a miss so a previously-failed download doesn't masquerade + // as a cache hit. + val isCached = when { + !targetFile.exists() -> false + targetFile.length() == 0L -> false + asset.size > 0L -> targetFile.length() == asset.size + else -> true + } + if (isCached) { + Logger.info("Using cached patches: ${targetFile.absolutePath} (${targetFile.length()} bytes)") onProgress(1f) return@withContext Result.success(targetFile) } - try { - Logger.info("Downloading patches from ${asset.downloadUrl}") - - val response: HttpResponse = httpClient.get(asset.downloadUrl) { - headers { - append(HttpHeaders.Accept, "application/octet-stream") - } - } - - if (!response.status.isSuccess()) { - val error = "Failed to download patches: ${response.status}" - Logger.error(error) - return@withContext Result.failure(Exception(error)) - } - - val bytes = response.readRawBytes() - targetFile.writeBytes(bytes) - onProgress(1f) - - Logger.info("Patches downloaded to ${targetFile.absolutePath}") - Result.success(targetFile) - } catch (e: Exception) { - Logger.error("Error downloading patches", e) - // Clean up partial download - if (targetFile.exists()) { - targetFile.delete() - } - Result.failure(e) - } + // Delegate the actual network IO to the engine source. + val result = remoteSource.downloadAsset(asset, targetFile) + if (result.isSuccess) onProgress(1f) + result } - /** - * Get cached patch file for a specific version. - */ + /** Get cached patch file for a specific version. */ fun getCachedPatches(version: String): File? { val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) return patchesDir.listFiles()?.find { @@ -186,30 +161,23 @@ class PatchRepository( } } - private fun isPatchFileName(name: String): Boolean { - return name.endsWith(".mpp", ignoreCase = true) - } + private fun isPatchFileName(name: String): Boolean = + name.endsWith(".mpp", ignoreCase = true) - /** - * List all cached patch versions. - */ + /** List all cached patch versions. */ fun listCachedPatches(): List { val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) return patchesDir.listFiles()?.filter { isPatchFileName(it.name) } ?: emptyList() } - /** - * Get the per-source cache directory for this repository. - */ + /** Get the per-source cache directory for this repository. */ fun getCacheDir(): File { val dir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) dir.mkdirs() return dir } - /** - * Delete cached patches. - */ + /** Delete cached patches (both in-memory release list and on-disk files). */ fun clearCache(): Boolean { cachedReleases = null cacheTimestamp = 0L diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt index 0a540b03..496c62a4 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt @@ -5,6 +5,8 @@ package app.morphe.gui.data.repository +import app.morphe.engine.patches.PatchProvider +import app.morphe.engine.patches.RemotePatchSourceFactory import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.util.Logger @@ -13,6 +15,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +/** + * Which top-level UI mode is currently visible. Used by [PatchSourceManager] + * to gate per-VM patch loading so only the visible mode's VM does the work. + */ +enum class ActiveMode { QUICK, EXPERT } + /** * Manages PatchRepository instances for different patch sources. * Creates and caches a PatchRepository per GitHub-based source. @@ -28,10 +36,42 @@ class PatchSourceManager( private var cachedActiveRepo: PatchRepository? = null private var cachedActiveSource: PatchSource? = null - // Incremented on every source switch so Compose can key on it + // Snapshot of currently-enabled sources for sync access. Updated on initialize() + // and whenever setSourceEnabled / addSource / removeSource fires. + private var cachedEnabledSources: List = emptyList() + + // Incremented on every source switch / enable change so Compose can key on it private val _sourceVersion = MutableStateFlow(0) val sourceVersion: StateFlow = _sourceVersion.asStateFlow() + // Observable list of enabled sources for UI + private val _enabledSources = MutableStateFlow>(emptyList()) + val enabledSources: StateFlow> = _enabledSources.asStateFlow() + + // Observable list of ALL sources (enabled + disabled) — drives the + // SourceManagementSheet which needs to render every source with a toggle. + private val _allSources = MutableStateFlow>(emptyList()) + val allSources: StateFlow> = _allSources.asStateFlow() + + /** + * Which mode's ViewModel is currently driving the UI. Used by both + * [HomeViewModel] (EXPERT) and [QuickPatchViewModel] (QUICK) to skip + * patch-loading when they're not visible — both VMs can be alive + * simultaneously (QuickVM is `remember`-scoped to App.kt; HomeVM is + * created by Voyager when the Navigator branch composes), and without + * this gate they'd race to download the same sources twice on every + * cache clear / source toggle. + */ + private val _activeMode = MutableStateFlow(ActiveMode.QUICK) + val activeMode: StateFlow = _activeMode.asStateFlow() + + fun setActiveMode(mode: ActiveMode) { + if (_activeMode.value != mode) { + Logger.info("PatchSourceManager: active mode → $mode") + _activeMode.value = mode + } + } + /** * Load the active source from config and cache its PatchRepository. * Call once at app startup (from a LaunchedEffect). @@ -40,7 +80,9 @@ class PatchSourceManager( val source = configRepository.getActivePatchSource() cachedActiveSource = source cachedActiveRepo = getRepositoryForSource(source) + refreshEnabledSources() Logger.info("PatchSourceManager initialized with source '${source.name}' (type=${source.type})") + Logger.info("Enabled sources: ${cachedEnabledSources.joinToString { it.name }}") } /** @@ -90,11 +132,25 @@ class PatchSourceManager( * Falls back to default repo if not yet initialized and source is not LOCAL. */ fun getActiveRepositorySync(): PatchRepository { - return cachedActiveRepo ?: PatchRepository(httpClient).also { + return cachedActiveRepo ?: defaultMorpheRepository().also { if (!isLocalSource()) cachedActiveRepo = it } } + /** + * Build the fallback PatchRepository pointed at the built-in Morphe + * repo (`MorpheApp/morphe-patches` on GitHub). Used when the active + * source isn't yet known. + */ + private fun defaultMorpheRepository(): PatchRepository { + val remote = RemotePatchSourceFactory.build( + PatchProvider.GITHUB, + "MorpheApp/morphe-patches", + httpClient, + ) + return PatchRepository(remote) + } + /** * Get the PatchRepository for the currently active source (suspend version). * For LOCAL sources, returns null (caller should use the file path directly). @@ -106,15 +162,22 @@ class PatchSourceManager( /** * Get the PatchRepository for a specific source. - * Returns null for LOCAL sources (no GitHub API needed). + * Returns null for LOCAL sources (no remote API needed). */ fun getRepositoryForSource(source: PatchSource): PatchRepository? { if (source.type == PatchSourceType.LOCAL) return null return repositories.getOrPut(source.id) { val repoPath = extractRepoPath(source) - Logger.info("Creating PatchRepository for source '${source.name}' (repo=$repoPath)") - PatchRepository(httpClient, repoPath) + // Map the GUI's persisted source type to the engine's provider + // enum. DEFAULT inherits GitHub (Morphe Patches lives there). + val provider = when (source.type) { + PatchSourceType.GITLAB -> PatchProvider.GITLAB + else -> PatchProvider.GITHUB + } + Logger.info("Creating PatchRepository for source '${source.name}' (repo=$repoPath, provider=$provider)") + val remote = RemotePatchSourceFactory.build(provider, repoPath, httpClient) + PatchRepository(remote) } } @@ -126,14 +189,17 @@ class PatchSourceManager( } /** - * Extract "owner/repo" from a PatchSource's URL. - * e.g. "https://github.com/MorpheApp/morphe-patches" -> "MorpheApp/morphe-patches" + * Extract "owner/repo" from a PatchSource's URL. Works for both GitHub + * and GitLab hosts. Falls back to the built-in default repo when no URL + * is configured (e.g. for the DEFAULT source on first launch). */ private fun extractRepoPath(source: PatchSource): String { val url = source.url ?: return "MorpheApp/morphe-patches" return url .removePrefix("https://github.com/") .removePrefix("http://github.com/") + .removePrefix("https://gitlab.com/") + .removePrefix("http://gitlab.com/") .removeSuffix("/") .removeSuffix(".git") } @@ -153,4 +219,69 @@ class PatchSourceManager( cachedActiveRepo?.clearCache() _sourceVersion.value++ } + + // ── Multi-source API ────────────────────────────────────────────────────── + + /** + * Snapshot of currently-enabled sources, in config order. Synchronous. + */ + fun getEnabledSourcesSync(): List = cachedEnabledSources + + /** + * Pair each enabled source with its [PatchRepository]. The repo is null for LOCAL + * sources — callers should use [PatchSource.filePath] directly in that case. + */ + fun getEnabledRepositories(): List> = + cachedEnabledSources.map { it to getRepositoryForSource(it) } + + /** + * Toggle enablement of a source. Persists, refreshes the cached snapshot, and + * bumps [sourceVersion] so consumers reload. Default-source safety net is + * applied at the [ConfigRepository] layer. + */ + suspend fun setSourceEnabled(id: String, enabled: Boolean) { + configRepository.setPatchSourceEnabled(id, enabled) + refreshEnabledSources() + _sourceVersion.value++ + Logger.info("Source '$id' enabled=$enabled. Enabled now: ${cachedEnabledSources.joinToString { it.name }}") + } + + /** + * Add a new source. Persists and refreshes the cached snapshot. + */ + suspend fun addSource(source: PatchSource) { + configRepository.addPatchSource(source) + refreshEnabledSources() + _sourceVersion.value++ + } + + /** + * Remove a source by id. Refuses non-deletable (default) sources. Drops the + * cached repo for that id so a re-add doesn't reuse stale state. + */ + suspend fun removeSource(id: String) { + configRepository.removePatchSource(id) + repositories.remove(id) + refreshEnabledSources() + _sourceVersion.value++ + } + + /** + * Update an existing source (e.g. rename). Refuses non-deletable sources. + */ + suspend fun updateSource(updated: PatchSource) { + configRepository.updatePatchSource(updated) + // Drop the cached repo so the new url/name is picked up on next access. + repositories.remove(updated.id) + refreshEnabledSources() + _sourceVersion.value++ + } + + private suspend fun refreshEnabledSources() { + val all = configRepository.loadConfig().patchSource + val enabled = all.filter { it.enabled } + cachedEnabledSources = enabled + _enabledSources.value = enabled + _allSources.value = all + } } diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index ce47921d..9c2a68f5 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -93,7 +93,9 @@ val appModule = module { get(), get(), psm.getActiveSourceName(), - psm.getLocalFilePath() + psm.getLocalFilePath(), + params.get(), + params.get(), ) } factory { params -> diff --git a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt index 1b3c0bd5..a4498f97 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.UsbOff import androidx.compose.material3.* import androidx.compose.runtime.* @@ -31,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheCorners @@ -42,8 +44,10 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current + val adbPreference = LocalAdbPreference.current val monitorState by DeviceMonitor.state.collectAsState() + val isAdbDisabledByUser = !adbPreference.enabled val isAdbAvailable = monitorState.isAdbAvailable val readyDevices = monitorState.devices.filter { it.isReady } val unauthorizedDevices = monitorState.devices.filter { it.status == DeviceStatus.UNAUTHORIZED } @@ -55,6 +59,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { val isHovered by hoverInteraction.collectIsHoveredAsState() val dotColor = when { + isAdbDisabledByUser -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) selectedDevice != null && selectedDevice.isReady -> accents.secondary unauthorizedDevices.isNotEmpty() -> accents.warning @@ -94,6 +99,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { ) val displayText = when { + isAdbDisabledByUser -> "ADB OFF" isAdbAvailable == null -> "Checking…" isAdbAvailable == false -> "No ADB" selectedDevice != null -> { @@ -110,6 +116,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { fontWeight = FontWeight.Medium, fontFamily = mono, color = when { + isAdbDisabledByUser -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) selectedDevice != null -> MaterialTheme.colorScheme.onSurface unauthorizedDevices.isNotEmpty() -> accents.warning @@ -138,6 +145,67 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) ) { when { + isAdbDisabledByUser -> { + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Column { + Text( + text = "ADB is off", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Morphe is not monitoring connected devices", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + }, + onClick = { showPopup = false } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = accents.primary + ) + Text( + text = "Enable ADB", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + fontFamily = mono, + color = accents.primary + ) + } + }, + onClick = { + adbPreference.onChange(true) + showPopup = false + } + ) + } + isAdbAvailable == false -> { DropdownMenuItem( text = { diff --git a/src/main/kotlin/app/morphe/gui/ui/components/MorpheErrorBar.kt b/src/main/kotlin/app/morphe/gui/ui/components/MorpheErrorBar.kt new file mode 100644 index 00000000..2a97d2bd --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/MorpheErrorBar.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont + +/** + * Cyberdeck-aesthetic error/warning bar — single-line message with an accent + * stripe and a DISMISS action. Shared across screens so error feedback looks + * identical everywhere. + * + * Intentionally uses raw Compose primitives (Box/Row/Text) instead of + * Material3 [androidx.compose.material3.SnackbarHost], because the latter + * reaches `SnackbarKt` through Compose-generated invocation paths that the + * shadow `minimize` reachability analyzer can't trace — it gets stripped and + * the GUI crashes with NoClassDefFoundError at runtime. Keeping this custom + * lets us drop the material3 minimize exclude and shrink the shadow jar. + * + * Callers control positioning via [modifier] (usually `Modifier.align(...)` + * inside a Box, plus padding). + */ +@Composable +fun MorpheErrorBar( + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + isWarning: Boolean = false, +) { + val accents = LocalMorpheAccents.current + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + + val accentColor = if (isWarning) accents.warning else MaterialTheme.colorScheme.error + val borderCol = accentColor.copy(alpha = 0.4f) + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderCol, RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .drawBehind { + drawRect( + color = accentColor, + size = Size(3.dp.toPx(), size.height) + ) + } + .padding(start = 3.dp) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(accentColor, RoundedCornerShape(1.dp)) + ) + Spacer(Modifier.width(12.dp)) + Text( + text = message, + fontFamily = mono, + fontSize = 12.sp, + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(12.dp)) + + val dismissHover = remember { MutableInteractionSource() } + val isDismissHovered by dismissHover.collectIsHoveredAsState() + val dismissBg by animateColorAsState( + if (isDismissHovered) accentColor.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .height(28.dp) + .hoverable(dismissHover) + .clip(RoundedCornerShape(corners.small)) + .background(dismissBg) + .clickable { onDismiss() } + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "DISMISS", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (isDismissHovered) accentColor + else accentColor.copy(alpha = 0.7f), + letterSpacing = 1.sp + ) + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt b/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt new file mode 100644 index 00000000..d7c295a6 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt @@ -0,0 +1,498 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.LocalMorpheFont +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.util.UUID + +@Composable +internal fun AddPatchSourceDialog( + onDismiss: () -> Unit, + onAdd: (PatchSource) -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + var name by remember { mutableStateOf("") } + var sourceType by remember { mutableStateOf(PatchSourceType.GITHUB) } + var url by remember { mutableStateOf("") } + var filePath by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "ADD SOURCE", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.widthIn(min = 300.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf(PatchSourceType.GITHUB, PatchSourceType.LOCAL).forEach { type -> + val isSelected = sourceType == type + Box( + modifier = Modifier + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (isSelected) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + RoundedCornerShape(corners.small) + ) + .background( + if (isSelected) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + ) + .clickable { sourceType = type } + .padding(horizontal = 14.dp, vertical = 7.dp) + ) { + Text( + text = when (type) { + // The "REMOTE" tab covers both GitHub and + // GitLab — the resolver picks the right + // provider from the URL the user pastes. + PatchSourceType.GITHUB -> "REMOTE" + PatchSourceType.LOCAL -> "LOCAL FILE" + else -> "" + }, + fontSize = 10.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = if (isSelected) accents.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + LabeledField(label = "NAME", mono = mono) { + SlimTextField( + value = name, + onValueChange = { name = it; error = null }, + placeholder = "My Custom Patches", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + + when (sourceType) { + PatchSourceType.GITHUB -> { + LabeledField(label = "REPOSITORY URL", mono = mono) { + SlimTextField( + value = url, + onValueChange = { newUrl -> + url = newUrl + error = null + // Auto-suggest the name from the repo basename as soon as the URL + // parses cleanly exactly like the LOCAL file case which derives the name + // from the .mpp filename. It tires its best :) + if (name.isBlank()) { + suggestNameFromUrl(newUrl)?.let { name = it } + } + }, + placeholder = "github.com/owner/repo or gitlab.com/owner/repo", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "Accepts GitHub, GitLab, or morphe.software/add-source link", + fontFamily = mono, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 0.3.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + PatchSourceType.LOCAL -> { + LabeledField(label = ".MPP FILE", mono = mono) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SlimTextField( + value = filePath, + onValueChange = { filePath = it; error = null }, + placeholder = "Path to .mpp", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.weight(1f), + readOnly = true, + ) + DialogActionButton( + label = "BROWSE", + mono = mono, + corners = corners, + onClick = { + val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { + setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + filePath = File(dialog.directory, dialog.file).absolutePath + if (name.isBlank()) name = dialog.file.removeSuffix(".mpp") + error = null + } + }, + ) + } + } + } + else -> {} + } + + error?.let { + Text( + text = it, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + val dimens = LocalMorpheDimens.current + Button( + onClick = { + if (name.isBlank()) { error = "Name is required"; return@Button } + when (sourceType) { + PatchSourceType.GITHUB -> { + // sourceType is the UI's "REMOTE" mode placeholder; + // the actual provider (GITHUB vs GITLAB) is decided + // by the resolver based on the URL the user pasted. + val resolved = resolveRemoteSourceUrl(url.trim()) + if (resolved == null) { + error = "Enter a valid GitHub or GitLab URL"; return@Button + } + onAdd(PatchSource( + id = UUID.randomUUID().toString(), + name = name.trim(), + type = resolved.provider, + url = resolved.canonicalUrl, + deletable = true + )) + return@Button + } + PatchSourceType.LOCAL -> { + if (filePath.isBlank() || !File(filePath).exists()) { + error = "Select a valid .mpp file"; return@Button + } + } + else -> {} + } + onAdd(PatchSource( + id = UUID.randomUUID().toString(), + name = name.trim(), + type = sourceType, + url = null, + filePath = if (sourceType == PatchSourceType.LOCAL) filePath.trim() else null, + deletable = true + )) + }, + colors = ButtonDefaults.buttonColors(containerColor = accents.primary), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "ADD", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + }, + dismissButton = { + val dimens = LocalMorpheDimens.current + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) +} + +@Composable +internal fun EditPatchSourceDialog( + source: PatchSource, + onDismiss: () -> Unit, + onSave: (PatchSource) -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + var name by remember { mutableStateOf(source.name) } + var url by remember { mutableStateOf(source.url ?: "") } + var filePath by remember { mutableStateOf(source.filePath ?: "") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "EDIT SOURCE", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.widthIn(min = 300.dp) + ) { + Text( + text = when (source.type) { + PatchSourceType.GITHUB -> "GITHUB REPOSITORY" + PatchSourceType.LOCAL -> "LOCAL FILE" + else -> "" + }, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 1.sp + ) + + LabeledField(label = "NAME", mono = mono) { + SlimTextField( + value = name, + onValueChange = { name = it; error = null }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + + when (source.type) { + PatchSourceType.GITHUB, PatchSourceType.GITLAB -> { + LabeledField(label = "REPOSITORY URL", mono = mono) { + SlimTextField( + value = url, + onValueChange = { url = it; error = null }, + placeholder = "github.com/owner/repo or gitlab.com/owner/repo", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + } + PatchSourceType.LOCAL -> { + LabeledField(label = ".MPP FILE", mono = mono) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SlimTextField( + value = filePath, + onValueChange = { filePath = it; error = null }, + placeholder = "Path to .mpp", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.weight(1f), + readOnly = true, + ) + DialogActionButton( + label = "BROWSE", + mono = mono, + corners = corners, + onClick = { + val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { + setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + filePath = File(dialog.directory, dialog.file).absolutePath + error = null + } + }, + ) + } + } + } + else -> {} + } + + error?.let { + Text(text = it, fontSize = 11.sp, fontFamily = mono, color = MaterialTheme.colorScheme.error) + } + } + }, + confirmButton = { + val dimens = LocalMorpheDimens.current + Button( + onClick = { + if (name.isBlank()) { error = "Name is required"; return@Button } + when (source.type) { + PatchSourceType.GITHUB, PatchSourceType.GITLAB -> { + // Re-resolve on save so the user can switch hosts + // by editing the URL (e.g. github → gitlab). The + // provider type updates with the detected host. + val resolved = resolveRemoteSourceUrl(url.trim()) + if (resolved == null) { + error = "Enter a valid GitHub or GitLab URL"; return@Button + } + onSave(source.copy( + name = name.trim(), + type = resolved.provider, + url = resolved.canonicalUrl, + )) + return@Button + } + PatchSourceType.LOCAL -> { + if (filePath.isBlank() || !File(filePath).exists()) { + error = "Select a valid .mpp file"; return@Button + } + } + else -> {} + } + onSave(source.copy( + name = name.trim(), + filePath = if (source.type == PatchSourceType.LOCAL) filePath.trim() else source.filePath + )) + }, + colors = ButtonDefaults.buttonColors(containerColor = accents.primary), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "SAVE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + }, + dismissButton = { + val dimens = LocalMorpheDimens.current + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) +} + +/** + * Result of parsing a user-entered remote source URL. The detected + * [provider] is the GUI-side persisted type that will be stored on the + * [PatchSource] config (GITHUB or GITLAB only — never DEFAULT or LOCAL). + */ +internal data class ResolvedRemoteSource( + val canonicalUrl: String, + val provider: PatchSourceType, // GITHUB or GITLAB only +) + +/** + * Thin GUI-side wrapper around the engine's [RemotePatchSourceFactory.parse]. + * Returns `null` if the engine can't classify the input. The engine owns + * the actual URL-parsing logic — this function only translates the engine's + * [app.morphe.engine.patches.PatchProvider] back to the GUI's persisted + * [PatchSourceType] (which carries DEFAULT/LOCAL too). + */ +internal fun resolveRemoteSourceUrl(input: String): ResolvedRemoteSource? { + val parsed = app.morphe.engine.patches.RemotePatchSourceFactory.parse(input) ?: return null + val type = when (parsed.provider) { + app.morphe.engine.patches.PatchProvider.GITHUB -> PatchSourceType.GITHUB + app.morphe.engine.patches.PatchProvider.GITLAB -> PatchSourceType.GITLAB + } + return ResolvedRemoteSource(canonicalUrl = parsed.canonicalUrl, provider = type) +} + +/** + * Suggest a friendly source name from a typed/pasted URL — used to populate + * the NAME field while the user is filling in REPOSITORY URL, so they don't + * have to think one up themselves. Returns `/` so two sources + * with similarly-named repos (e.g. forks of `morphe-patches`) stay + * distinguishable. Returns null when the URL doesn't parse cleanly yet + * (partial typing, invalid host, etc.). + */ +private fun suggestNameFromUrl(input: String): String? { + val parsed = app.morphe.engine.patches.RemotePatchSourceFactory.parse(input) ?: return null + return parsed.repoPath.takeIf { it.isNotBlank() } +} + +// LabeledField, SlimTextField, DialogActionButton moved to SlimInputs.kt for +// reuse across the codebase (SettingsDialog uses them too). diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index 042515b9..daa15b8f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -5,6 +5,7 @@ package app.morphe.gui.ui.components +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.LocalModeState import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -60,6 +61,7 @@ fun SettingsButton( val corners = LocalMorpheCorners.current val themeState = LocalThemeState.current val modeState = LocalModeState.current + val adbPreference = LocalAdbPreference.current val configRepository: ConfigRepository = koinInject() val patchSourceManager: PatchSourceManager = koinInject() val updateCheckRepository: UpdateCheckRepository = koinInject() @@ -68,8 +70,6 @@ fun SettingsButton( var showSettingsDialog by remember { mutableStateOf(false) } var autoCleanupTempFiles by remember { mutableStateOf(true) } var defaultOutputDirectory by remember { mutableStateOf(null) } - var patchSources by remember { mutableStateOf>(emptyList()) } - var activePatchSourceId by remember { mutableStateOf("") } var keystorePath by remember { mutableStateOf(null) } var keystorePassword by remember { mutableStateOf(null) } var keystoreAlias by remember { mutableStateOf(DEFAULT_KEYSTORE_ALIAS) } @@ -82,10 +82,11 @@ fun SettingsButton( if (showSettingsDialog) { val config = configRepository.loadConfig() autoCleanupTempFiles = config.autoCleanupTempFiles - defaultOutputDirectory = config.defaultOutputDirectory - patchSources = config.patchSource - activePatchSourceId = config.activePatchSourceId - keystorePath = config.keystorePath + // Display the resolved absolute form even though storage may be + // bundle-relative — users expect to see a real filesystem path in + // the field, not a cryptic basename. + defaultOutputDirectory = config.resolvedDefaultOutputDirectory()?.absolutePath + keystorePath = config.resolvedKeystorePath()?.absolutePath keystorePassword = config.keystorePassword keystoreAlias = config.keystoreAlias keystoreEntryPassword = config.keystoreEntryPassword @@ -151,43 +152,6 @@ fun SettingsButton( }, allowCacheClear = allowCacheClear, isPatching = isPatching, - patchSources = patchSources, - activePatchSourceId = activePatchSourceId, - onActivePatchSourceChange = { id -> - if (id != activePatchSourceId) { - activePatchSourceId = id - scope.launch { - withContext(NonCancellable) { - patchSourceManager.switchSource(id) - } - } - } - }, - onAddPatchSource = { source -> - patchSources = patchSources + source - scope.launch { - configRepository.addPatchSource(source) - } - }, - onEditPatchSource = { updated -> - patchSources = patchSources.map { if (it.id == updated.id) updated else it } - scope.launch { - configRepository.updatePatchSource(updated) - if (updated.id == activePatchSourceId) { - patchSourceManager.clearAll() - patchSourceManager.switchSource(updated.id) - } - } - }, - onRemovePatchSource = { id -> - patchSources = patchSources.filter { it.id != id } - if (activePatchSourceId == id) { - activePatchSourceId = "morphe-default" - } - scope.launch { - configRepository.removePatchSource(id) - } - }, onCacheCleared = { patchSourceManager.notifyCacheCleared() }, @@ -232,6 +196,8 @@ fun SettingsButton( } } }, + autoStartAdb = adbPreference.enabled, + onAutoStartAdbChange = { adbPreference.onChange(it) }, collapsibleSectionStates = collapsibleSectionStates, onCollapsibleSectionToggle = { id, expanded -> collapsibleSectionStates = collapsibleSectionStates + (id to expanded) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index de463055..db4e5f10 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -30,12 +30,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.engine.MorpheData import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.MorpheColors @@ -94,12 +96,6 @@ fun SettingsDialog( onDismiss: () -> Unit, allowCacheClear: Boolean = true, isPatching: Boolean = false, - patchSources: List = emptyList(), - activePatchSourceId: String = "", - onActivePatchSourceChange: (String) -> Unit = {}, - onAddPatchSource: (PatchSource) -> Unit = {}, - onEditPatchSource: (PatchSource) -> Unit = {}, - onRemovePatchSource: (String) -> Unit = {}, onCacheCleared: () -> Unit = {}, keystorePath: String? = null, keystorePassword: String? = null, @@ -111,6 +107,8 @@ fun SettingsDialog( onKeepArchitecturesChange: (Set) -> Unit = {}, updateChannelPreference: app.morphe.gui.data.model.UpdateChannelPreference = app.morphe.gui.data.model.UpdateChannelPreference.STABLE, onUpdateChannelChange: (app.morphe.gui.data.model.UpdateChannelPreference) -> Unit = {}, + autoStartAdb: Boolean = false, + onAutoStartAdbChange: (Boolean) -> Unit = {}, collapsibleSectionStates: Map = emptyMap(), onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> } ) { @@ -123,8 +121,6 @@ fun SettingsDialog( var showLicensesDialog by remember { mutableStateOf(false) } var cacheCleared by remember { mutableStateOf(false) } var cacheClearFailed by remember { mutableStateOf(false) } - var showAddSourceDialog by remember { mutableStateOf(false) } - var editingSource by remember { mutableStateOf(null) } AlertDialog( onDismissRequest = onDismiss, @@ -283,35 +279,28 @@ fun SettingsDialog( SettingsDivider(borderColor) - // ── Patched App Runtime Logs ── - PatchedAppRuntimeLogsSection( - mono = mono, + // ── Auto-start ADB ── + SettingToggleRow( + label = "Auto-start ADB", + description = "Spawn the ADB daemon on launch so connected devices are monitored. " + + "When off, Morphe never starts the server, and install/push features are disabled.", + checked = autoStartAdb, + onCheckedChange = onAutoStartAdbChange, accentColor = accents.primary, - borderColor = borderColor, - enabled = !isPatching, - expanded = collapsibleSectionStates["RUNTIME LOGS"] == true, - onExpandedChange = { onCollapsibleSectionToggle("RUNTIME LOGS", it) } + mono = mono, + enabled = !isPatching ) SettingsDivider(borderColor) - // ── Patch Sources ── - PatchSourcesSection( - sources = patchSources, - activeSourceId = activePatchSourceId, - onActiveChange = { id -> - onActivePatchSourceChange(id) - onDismiss() - }, - onRemove = onRemovePatchSource, - onEdit = { source -> editingSource = source }, - onAddClick = { showAddSourceDialog = true }, + // ── Patched App Runtime Logs ── + PatchedAppRuntimeLogsSection( mono = mono, accentColor = accents.primary, borderColor = borderColor, enabled = !isPatching, - expanded = collapsibleSectionStates["PATCH SOURCES"] == true, - onExpandedChange = { onCollapsibleSectionToggle("PATCH SOURCES", it) } + expanded = collapsibleSectionStates["RUNTIME LOGS"] == true, + onExpandedChange = { onCollapsibleSectionToggle("RUNTIME LOGS", it) } ) SettingsDivider(borderColor) @@ -490,30 +479,9 @@ fun SettingsDialog( ) } - if (showAddSourceDialog) { - AddPatchSourceDialog( - onDismiss = { showAddSourceDialog = false }, - onAdd = { source -> - onAddPatchSource(source) - showAddSourceDialog = false - } - ) - } - if (showLicensesDialog) { LicensesDialog(onDismiss = { showLicensesDialog = false }) } - - editingSource?.let { source -> - EditPatchSourceDialog( - source = source, - onDismiss = { editingSource = null }, - onSave = { updated -> - onEditPatchSource(updated) - editingSource = null - } - ) - } } @Composable @@ -1507,6 +1475,7 @@ private fun OutputFolderSection( enabled: Boolean = true ) { val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current val alpha = if (enabled) 1f else 0.4f val outputDir = defaultOutputDirectory?.let { File(it) } val outputDirExists = outputDir?.isDirectory == true @@ -1526,7 +1495,7 @@ private fun OutputFolderSection( Spacer(Modifier.height(8.dp)) Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { @@ -1605,15 +1574,30 @@ private fun OutputFolderSection( ) } + // Stored form first (mirrors config.json), absolute resolution second. + // Hides the second line entirely when storage IS absolute, repeating + // the same path twice would make no sense now, innit. if (defaultOutputDirectory != null) { + val stored = app.morphe.engine.util.PortablePaths.storableForm(defaultOutputDirectory) + val isBundleRelative = stored != defaultOutputDirectory Text( - text = defaultOutputDirectory, + text = stored, fontSize = 9.sp, fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), maxLines = 1, overflow = TextOverflow.Ellipsis ) + if (isBundleRelative) { + Text( + text = "Resolves to: $defaultOutputDirectory", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } } @@ -1665,545 +1649,6 @@ private fun ActionButton( } } -// ── Patch Sources Section ── - -@Composable -private fun PatchSourcesSection( - sources: List, - activeSourceId: String, - onActiveChange: (String) -> Unit, - onRemove: (String) -> Unit, - onEdit: (PatchSource) -> Unit, - onAddClick: () -> Unit, - mono: androidx.compose.ui.text.font.FontFamily, - accentColor: Color, - borderColor: Color, - enabled: Boolean = true, - expanded: Boolean = false, - onExpandedChange: (Boolean) -> Unit = {} -) { - val corners = LocalMorpheCorners.current - val alpha = if (enabled) 1f else 0.4f - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - CollapsibleSection( - title = "PATCH SOURCES", - mono = mono, - expanded = expanded, - onExpandedChange = onExpandedChange - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = if (!enabled) "Disabled while patching" else "Select where patches are loaded from", - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - sources.forEach { source -> - val isActive = source.id == activeSourceId - val hoverInteraction = remember(source.id) { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(corners.medium)) - .border( - 1.dp, - when { - isActive -> accentColor.copy(alpha = 0.4f) - isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - else -> borderColor - }, - RoundedCornerShape(corners.medium) - ) - .background( - if (isActive) accentColor.copy(alpha = 0.08f) - else Color.Transparent - ) - .hoverable(hoverInteraction) - .then(if (enabled) Modifier.clickable { onActiveChange(source.id) } else Modifier) - .padding(horizontal = 12.dp, vertical = 10.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - // Active indicator dot - Box( - modifier = Modifier - .size(6.dp) - .background( - if (isActive) accentColor - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f), - RoundedCornerShape(1.dp) - ) - ) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = source.name, - fontSize = 12.sp, - fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = when (source.type) { - PatchSourceType.DEFAULT -> "Default" - PatchSourceType.GITHUB -> source.url?.removePrefix("https://github.com/") ?: "GitHub" - PatchSourceType.LOCAL -> source.filePath?.let { File(it).name } ?: "Local file" - }, - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (source.deletable && enabled) { - IconButton( - onClick = { onEdit(source) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = "Edit", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.size(14.dp) - ) - } - Spacer(Modifier.width(2.dp)) - IconButton( - onClick = { onRemove(source.id) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.size(14.dp) - ) - } - } - } - } - Spacer(modifier = Modifier.height(4.dp)) - } - - // Add source - OutlinedButton( - onClick = onAddClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small), - border = BorderStroke(1.dp, borderColor), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(14.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "ADD SOURCE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } // inner Column - } // CollapsibleSection - } -} - -// ── Add / Edit Source Dialogs ── - -@Composable -private fun AddPatchSourceDialog( - onDismiss: () -> Unit, - onAdd: (PatchSource) -> Unit -) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - var name by remember { mutableStateOf("") } - var sourceType by remember { mutableStateOf(PatchSourceType.GITHUB) } - var url by remember { mutableStateOf("") } - var filePath by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - shape = RoundedCornerShape(corners.medium), - containerColor = MaterialTheme.colorScheme.surface, - title = { - Text( - "ADD SOURCE", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.sp - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.widthIn(min = 300.dp) - ) { - // Type toggle - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - listOf(PatchSourceType.GITHUB, PatchSourceType.LOCAL).forEach { type -> - val isSelected = sourceType == type - Box( - modifier = Modifier - .clip(RoundedCornerShape(corners.small)) - .border( - 1.dp, - if (isSelected) accents.primary.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), - RoundedCornerShape(corners.small) - ) - .background( - if (isSelected) accents.primary.copy(alpha = 0.08f) - else Color.Transparent - ) - .clickable { sourceType = type } - .padding(horizontal = 14.dp, vertical = 7.dp) - ) { - Text( - text = when (type) { - PatchSourceType.GITHUB -> "GITHUB" - PatchSourceType.LOCAL -> "LOCAL FILE" - else -> "" - }, - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, - fontFamily = mono, - letterSpacing = 0.5.sp, - color = if (isSelected) accents.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - OutlinedTextField( - value = name, - onValueChange = { name = it; error = null }, - label = { Text("Name", fontFamily = mono, fontSize = 11.sp) }, - placeholder = { Text("My Custom Patches", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - when (sourceType) { - PatchSourceType.GITHUB -> { - OutlinedTextField( - value = url, - onValueChange = { url = it; error = null }, - label = { Text("Repository URL", fontFamily = mono, fontSize = 11.sp) }, - placeholder = { Text("github.com/owner/repo", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - Text( - "Accepts GitHub URL or morphe.software/add-source link", - fontFamily = mono, - fontSize = 9.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - letterSpacing = 0.3.sp - ) - } - PatchSourceType.LOCAL -> { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = filePath, - onValueChange = { filePath = it; error = null }, - label = { Text(".mpp file", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(corners.small), - readOnly = true - ) - OutlinedButton( - onClick = { - val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { - setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } - isVisible = true - } - if (dialog.directory != null && dialog.file != null) { - filePath = File(dialog.directory, dialog.file).absolutePath - if (name.isBlank()) name = dialog.file.removeSuffix(".mpp") - error = null - } - }, - shape = RoundedCornerShape(corners.small) - ) { - Text( - "BROWSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } - } - else -> {} - } - - error?.let { - Text( - text = it, - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.error - ) - } - } - }, - confirmButton = { - Button( - onClick = { - if (name.isBlank()) { error = "Name is required"; return@Button } - when (sourceType) { - PatchSourceType.GITHUB -> { - val trimmedUrl = url.trim() - val resolvedUrl = resolveGitHubUrl(trimmedUrl) - if (resolvedUrl == null) { - error = "Enter a valid GitHub URL or Morphe source link"; return@Button - } - onAdd(PatchSource( - id = UUID.randomUUID().toString(), - name = name.trim(), - type = sourceType, - url = resolvedUrl, - deletable = true - )) - return@Button - } - PatchSourceType.LOCAL -> { - if (filePath.isBlank() || !File(filePath).exists()) { - error = "Select a valid .mpp file"; return@Button - } - } - else -> {} - } - onAdd(PatchSource( - id = UUID.randomUUID().toString(), - name = name.trim(), - type = sourceType, - url = null, - filePath = if (sourceType == PatchSourceType.LOCAL) filePath.trim() else null, - deletable = true - )) - }, - colors = ButtonDefaults.buttonColors(containerColor = accents.primary), - shape = RoundedCornerShape(corners.small) - ) { - Text( - "ADD", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - "CANCEL", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - } - ) -} - -@Composable -private fun EditPatchSourceDialog( - source: PatchSource, - onDismiss: () -> Unit, - onSave: (PatchSource) -> Unit -) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - var name by remember { mutableStateOf(source.name) } - var url by remember { mutableStateOf(source.url ?: "") } - var filePath by remember { mutableStateOf(source.filePath ?: "") } - var error by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - shape = RoundedCornerShape(corners.medium), - containerColor = MaterialTheme.colorScheme.surface, - title = { - Text( - "EDIT SOURCE", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.sp - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.widthIn(min = 300.dp) - ) { - // Type indicator - Text( - text = when (source.type) { - PatchSourceType.GITHUB -> "GITHUB REPOSITORY" - PatchSourceType.LOCAL -> "LOCAL FILE" - else -> "" - }, - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accents.primary, - letterSpacing = 1.sp - ) - - OutlinedTextField( - value = name, - onValueChange = { name = it; error = null }, - label = { Text("Name", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - when (source.type) { - PatchSourceType.GITHUB -> { - OutlinedTextField( - value = url, - onValueChange = { url = it; error = null }, - label = { Text("Repository URL", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - } - PatchSourceType.LOCAL -> { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = filePath, - onValueChange = { filePath = it; error = null }, - label = { Text(".mpp file", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(corners.small), - readOnly = true - ) - OutlinedButton( - onClick = { - val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { - setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } - isVisible = true - } - if (dialog.directory != null && dialog.file != null) { - filePath = File(dialog.directory, dialog.file).absolutePath - error = null - } - }, - shape = RoundedCornerShape(corners.small) - ) { - Text( - "BROWSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } - } - else -> {} - } - - error?.let { - Text(text = it, fontSize = 11.sp, fontFamily = mono, color = MaterialTheme.colorScheme.error) - } - } - }, - confirmButton = { - Button( - onClick = { - if (name.isBlank()) { error = "Name is required"; return@Button } - when (source.type) { - PatchSourceType.GITHUB -> { - val resolvedUrl = resolveGitHubUrl(url.trim()) - if (resolvedUrl == null) { - error = "Enter a valid GitHub URL or Morphe source link"; return@Button - } - onSave(source.copy( - name = name.trim(), - url = resolvedUrl - )) - return@Button - } - PatchSourceType.LOCAL -> { - if (filePath.isBlank() || !File(filePath).exists()) { - error = "Select a valid .mpp file"; return@Button - } - } - else -> {} - } - onSave(source.copy( - name = name.trim(), - filePath = if (source.type == PatchSourceType.LOCAL) filePath.trim() else source.filePath - )) - }, - colors = ButtonDefaults.buttonColors(containerColor = accents.primary), - shape = RoundedCornerShape(corners.small) - ) { - Text( - "SAVE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - "CANCEL", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - } - ) -} // ── Strip Libs Section ── @@ -2282,6 +1727,8 @@ private fun SigningSection( onExpandedChange: (Boolean) -> Unit = {} ) { val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current + val accents = LocalMorpheAccents.current val alpha = if (enabled) 1f else 0.4f var localPassword by remember(keystorePassword) { mutableStateOf(keystorePassword ?: "") } @@ -2315,7 +1762,7 @@ private fun SigningSection( // Keystore path row Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { @@ -2397,10 +1844,12 @@ private fun SigningSection( } } - // Warning if keystore path set but file doesn't exist + // Warning if keystore path set but file doesn't exist. Patching will + // refuse to start with this configured (see PatchingViewModel) — user + // must restore the file, pick another, or reset to use Morphe's default. if (keystorePath != null && !keystoreExists) { Text( - text = "Keystore not found — will be created on next patch", + text = "Keystore not found — patching will fail until you restore it, pick another, or reset", fontSize = 10.sp, fontFamily = mono, color = Color(0xFFE0A030) @@ -2417,98 +1866,144 @@ private fun SigningSection( ) } - // Full path tooltip + // Either: stored form (relative when inside the bundle, absolute otherwise) + // with a "Resolves to: ..." subtitle when relative. Mirrors config.json + // so users can see which paths follow the bundle vs which are pinned. + // Or: "using default" hint when no user-configured path is set. if (keystorePath != null) { + val stored = app.morphe.engine.util.PortablePaths.storableForm(keystorePath) + val isBundleRelative = stored != keystorePath Text( - text = keystorePath, + text = stored, fontSize = 9.sp, fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), maxLines = 1, overflow = TextOverflow.Ellipsis ) + if (isBundleRelative) { + Text( + text = "Resolves to: $keystorePath", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } else { + // Mirror the storage form treatment used for user-configured paths above. + // The default keystore lives in the bundle (`morphe-data/`) in the happy case, + // so the storable form will be relative. + // Verb is conditional on file existence. Patcher creates the file on first sign, + // so on a fresh install the hint accurately says "Will create..." + // instead of making up claims like "Using..." an absent file. + val defaultAbs = MorpheData.defaultKeystoreFile.absolutePath + val defaultStored = app.morphe.engine.util.PortablePaths.storableForm(defaultAbs) + val isBundleRelative = defaultStored != defaultAbs + val verb = if (MorpheData.defaultKeystoreFile.exists()) "Using" + else "Will create" + Text( + text = "$verb Morphe's default keystore at $defaultStored", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (isBundleRelative) { + Text( + text = "Resolves to: $defaultAbs", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - Spacer(Modifier.height(4.dp)) - - // Keystore password - OutlinedTextField( - value = localPassword, - onValueChange = { - localPassword = it - onCredentialsChange(it.ifEmpty { null }, localAlias, localEntryPassword) - }, - label = { Text("Keystore password", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - visualTransformation = if (showPassword) androidx.compose.ui.text.input.VisualTransformation.None - else androidx.compose.ui.text.input.PasswordVisualTransformation(), - trailingIcon = { - IconButton( - onClick = { showPassword = !showPassword }, - modifier = Modifier.size(20.dp) - ) { - Icon( - imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showPassword) "Hide" else "Show", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - }, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(8.dp)) - // Key alias - OutlinedTextField( - value = localAlias, - onValueChange = { - localAlias = it - onCredentialsChange(localPassword.ifEmpty { null }, it, localEntryPassword) - }, - label = { Text("Key alias", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + LabeledField(label = "KEYSTORE PASSWORD", mono = mono) { + SlimTextField( + value = localPassword, + onValueChange = { + localPassword = it + onCredentialsChange(it.ifEmpty { null }, localAlias, localEntryPassword) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + visualTransformation = if (showPassword) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + trailing = { + IconButton( + onClick = { showPassword = !showPassword }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showPassword) "Hide" else "Show", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + }, + ) + } - Spacer(Modifier.height(4.dp)) + LabeledField(label = "KEY ALIAS", mono = mono) { + SlimTextField( + value = localAlias, + onValueChange = { + localAlias = it + onCredentialsChange(localPassword.ifEmpty { null }, it, localEntryPassword) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) + } - // Key entry password - OutlinedTextField( - value = localEntryPassword, - onValueChange = { - localEntryPassword = it - onCredentialsChange(localPassword.ifEmpty { null }, localAlias, it) - }, - label = { Text("Key password", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - visualTransformation = if (showEntryPassword) androidx.compose.ui.text.input.VisualTransformation.None - else androidx.compose.ui.text.input.PasswordVisualTransformation(), - trailingIcon = { - IconButton( - onClick = { showEntryPassword = !showEntryPassword }, - modifier = Modifier.size(20.dp) - ) { - Icon( - imageVector = if (showEntryPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showEntryPassword) "Hide" else "Show", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - }, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) + LabeledField(label = "KEY PASSWORD", mono = mono) { + SlimTextField( + value = localEntryPassword, + onValueChange = { + localEntryPassword = it + onCredentialsChange(localPassword.ifEmpty { null }, localAlias, it) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + visualTransformation = if (showEntryPassword) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + trailing = { + IconButton( + onClick = { showEntryPassword = !showEntryPassword }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = if (showEntryPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showEntryPassword) "Hide" else "Show", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + }, + ) + } + } // Verify credentials button var verifyResult by remember { mutableStateOf(null) } @@ -2539,7 +2034,7 @@ private fun SigningSection( } }, enabled = enabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), shape = RoundedCornerShape(corners.small), border = BorderStroke( 1.dp, @@ -2549,7 +2044,7 @@ private fun SigningSection( else -> borderColor } ), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), ) { Icon( imageVector = Icons.Default.Check, @@ -2635,14 +2130,14 @@ private fun SigningSection( } }, enabled = enabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), shape = RoundedCornerShape(corners.small), border = BorderStroke( 1.dp, if (generateSuccess) MorpheColors.Teal.copy(alpha = 0.4f) else accentColor.copy(alpha = 0.3f) ), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), ) { Icon( imageVector = Icons.Default.Add, @@ -3027,6 +2522,7 @@ private fun ThemePreference.toDisplayName(): String { ThemePreference.CATPPUCCIN -> "Catppuccin" ThemePreference.SAKURA -> "Sakura" ThemePreference.MATCHA -> "Matcha" + ThemePreference.DEEPSPACE -> "Deepspace" ThemePreference.SYSTEM -> "System" } } @@ -3040,6 +2536,7 @@ private fun ThemePreference.iconSymbol(): String { ThemePreference.CATPPUCCIN -> "🐱" ThemePreference.SAKURA -> "🌸" ThemePreference.MATCHA -> "🍵" + ThemePreference.DEEPSPACE -> "✦" ThemePreference.SYSTEM -> "⚙" } } @@ -3053,6 +2550,7 @@ private fun ThemePreference.accentColor(): Color { ThemePreference.CATPPUCCIN -> Color(0xFFCBA6F7) ThemePreference.SAKURA -> Color(0xFFB43A67) ThemePreference.MATCHA -> Color(0xFF4C7A35) + ThemePreference.DEEPSPACE -> Color(0xFF00D9FF) ThemePreference.SYSTEM -> MorpheColors.Blue } } @@ -3095,44 +2593,6 @@ private fun clearAllCache(): Boolean { } } -/** - * Resolves a URL to a GitHub repository URL. - * Supports: - * - Direct GitHub URLs: https://github.com/owner/repo - * - Morphe source links: https://morphe.software/add-source?github=owner/repo - * - Short form: owner/repo (assumed GitHub) - * Returns a normalized https://github.com/owner/repo URL, or null if invalid. - */ -private fun resolveGitHubUrl(input: String): String? { - val trimmed = input.trim() - if (trimmed.isBlank()) return null - - // Morphe source link: morphe.software/add-source?github=owner/repo - if (trimmed.contains("morphe.software/add-source")) { - val match = Regex("[?&]github=([^&]+)").find(trimmed) - val repoPath = match?.groupValues?.get(1) ?: return null - val clean = repoPath.trimEnd('/') - return if (clean.contains('/') && clean.split('/').size == 2) { - "https://github.com/$clean" - } else null - } - - // Direct GitHub URL: https://github.com/owner/repo - if (trimmed.contains("github.com/")) { - // Extract owner/repo from full URL - val match = Regex("github\\.com/([^/]+/[^/]+)").find(trimmed) - return if (match != null) { - "https://github.com/${match.groupValues[1].trimEnd('/')}" - } else null - } - - // Short form: owner/repo - if (trimmed.matches(Regex("[\\w.-]+/[\\w.-]+"))) { - return "https://github.com/$trimmed" - } - - return null -} // ── Patched App Runtime Logs Section ── diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt b/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt new file mode 100644 index 00000000..bab56a1a --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.MorpheAccentColors +import app.morphe.gui.ui.theme.MorpheCornerStyle + +/** + * Label-and-input group rendered as a tight Column. Use inside a parent Column + * that has its own `verticalArrangement = spacedBy(...)` for between-group + * spacing — this composable's internal label↔field gap stays a fixed 4dp. + */ +@Composable +internal fun LabeledField( + label: String, + mono: FontFamily, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = label, + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + letterSpacing = 1.2.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + content() + } +} + +/** + * Slim text input matching the cyberdeck aesthetic across the app — pinned to + * [LocalMorpheDimens.controlHeight] so it lines up with the project's standard + * button height. Uses [BasicTextField] with a custom decoration so we get full + * control of the height (Material 3's [androidx.compose.material3.OutlinedTextField] + * has a 56dp minimum that's too chunky for this app). + * + * Optional [trailing] slot for things like password-visibility toggles. + */ +@Composable +internal fun SlimTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + mono: FontFamily, + accents: MorpheAccentColors, + corners: MorpheCornerStyle, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + enabled: Boolean = true, + visualTransformation: VisualTransformation = VisualTransformation.None, + trailing: (@Composable () -> Unit)? = null, +) { + val dimens = LocalMorpheDimens.current + val muted = MaterialTheme.colorScheme.onSurfaceVariant + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor by animateColorAsState( + if (isFocused) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.18f), + animationSpec = tween(150), + label = "slimFieldBorder", + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + readOnly = readOnly, + enabled = enabled, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(accents.primary), + modifier = modifier + .height(dimens.controlHeight) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 10.dp, end = if (trailing != null) 4.dp else 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + if (value.isEmpty() && placeholder.isNotEmpty()) { + Text( + text = placeholder, + fontSize = 11.sp, + fontFamily = mono, + color = muted.copy(alpha = 0.4f), + ) + } + innerTextField() + } + if (trailing != null) trailing() + } + }, + ) +} + +/** + * Compact OutlinedButton pinned to [LocalMorpheDimens.controlHeight]. Used for + * BROWSE / RESET / similar inline action buttons next to a [SlimTextField]. + */ +@Composable +internal fun DialogActionButton( + label: String, + mono: FontFamily, + corners: MorpheCornerStyle, + onClick: () -> Unit, +) { + val dimens = LocalMorpheDimens.current + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + label, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp, + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt new file mode 100644 index 00000000..759275d7 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt @@ -0,0 +1,492 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +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.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import java.io.File + +/** + * Multi-source management sheet, summoned from the home header `+` button. + * Lists every configured patch source with an enable toggle. Default source + * cannot be deleted or renamed (mirrors morphe-manager rules); other sources + * can be edited or removed. + * + * Caller wires actions to [PatchSourceManager] / [ConfigRepository] equivalents. + */ +/** + * How rows in the management sheet behave: + * - [MULTI_TOGGLE]: each source has an enable Switch. Used by Expert mode where + * patches from all enabled sources are unioned. + * - [SINGLE_SELECT]: each row is a radio. Used by Quick Patch mode where exactly + * one source is "active" at a time. + */ +enum class SourceSheetMode { MULTI_TOGGLE, SINGLE_SELECT } + +@Composable +fun SourceManagementSheet( + sources: List, + onToggleEnabled: (id: String, enabled: Boolean) -> Unit, + onAdd: (PatchSource) -> Unit, + onEdit: (PatchSource) -> Unit, + onRemove: (id: String) -> Unit, + onOpenPatches: (sourceId: String) -> Unit, + onDismiss: () -> Unit, + enabled: Boolean = true, + /** sourceId → resolved version label (e.g. "v1.27.0-dev.2"). Empty when not loaded. */ + sourceVersions: Map = emptyMap(), + /** sourceId → channel classification of the resolved release. Drives the badge. */ + sourceChannels: Map = emptyMap(), + /** True while patches are being (re)loaded. Drives the per-row spinner shown + * in place of the version/badge for enabled sources whose data isn't yet + * in [sourceVersions]. */ + isLoading: Boolean = false, + /** Selection semantics. Defaults to multi-toggle (Expert mode). */ + mode: SourceSheetMode = SourceSheetMode.MULTI_TOGGLE, + /** sourceId of the currently picked source — only used when [mode] is SINGLE_SELECT. */ + activeSourceId: String? = null, + /** Called when the user picks a source — only used when [mode] is SINGLE_SELECT. */ + onSelectSingle: (sourceId: String) -> Unit = {}, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + + var showAddDialog by remember { mutableStateOf(false) } + var editingSource by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "PATCH SOURCES", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 2.sp, + ) + }, + text = { + // Hoisted so the scrollbar can share the same state as the + // scrolling Column. The scrollbar renders only when the + // content actually overflows (maxValue > 0) — keeps the + // dialog clean for the common case of a handful of sources. + val scrollState = rememberScrollState() + Box { + Column( + modifier = Modifier + .verticalScroll(scrollState) + .widthIn(min = 360.dp) + // Reserve space so rows don't get covered by the + // scrollbar when it appears, plus a bit of breathing + // room so the scrollbar isn't flush against the rows. + .padding(end = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = when { + !enabled -> "Disabled while patching" + mode == SourceSheetMode.SINGLE_SELECT -> + "Pick which source Quick Patch uses. Multi-source is available in Expert mode." + else -> "Enable/Disable any combination. Patches from all enabled sources are unioned." + }, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + ) + + Spacer(Modifier.height(4.dp)) + + sources.forEach { source -> + SourceRow( + source = source, + version = sourceVersions[source.id], + channel = sourceChannels[source.id], + isLoading = isLoading, + accentColor = accents.primary, + borderColor = borderColor, + mono = mono, + enabled = enabled, + mode = mode, + isActiveSelection = source.id == activeSourceId, + onSelectSingle = { onSelectSingle(source.id) }, + onToggleEnabled = { newVal -> onToggleEnabled(source.id, newVal) }, + onEdit = { editingSource = source }, + onRemove = { onRemove(source.id) }, + onOpenPatches = { onOpenPatches(source.id) }, + ) + } + + Spacer(Modifier.height(2.dp)) + + OutlinedButton( + onClick = { showAddDialog = true }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + "ADD SOURCE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp + ) + } + } + + if (scrollState.maxValue > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 4.dp), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle() + ) + } + } + }, + confirmButton = { + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + ) { + Text( + "DONE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + ) + } + } + ) + + if (showAddDialog) { + AddPatchSourceDialog( + onDismiss = { showAddDialog = false }, + onAdd = { + onAdd(it) + showAddDialog = false + } + ) + } + + editingSource?.let { src -> + EditPatchSourceDialog( + source = src, + onDismiss = { editingSource = null }, + onSave = { + onEdit(it) + editingSource = null + } + ) + } +} + +@Composable +private fun SourceRow( + source: PatchSource, + version: String?, + channel: app.morphe.gui.util.EnabledSourcesLoader.Channel?, + isLoading: Boolean, + accentColor: Color, + borderColor: Color, + mono: androidx.compose.ui.text.font.FontFamily, + enabled: Boolean, + onToggleEnabled: (Boolean) -> Unit, + onEdit: () -> Unit, + onRemove: () -> Unit, + onOpenPatches: () -> Unit, + mode: SourceSheetMode, + isActiveSelection: Boolean, + onSelectSingle: () -> Unit, +) { + val corners = LocalMorpheCorners.current + val hoverInteraction = remember(source.id) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val isEnabled = source.enabled + val isDefault = !source.deletable + // Card click works regardless of enable state. In MULTI_TOGGLE mode it opens + // patches for the source (PatchesScreen). In SINGLE_SELECT mode it picks the + // source as the active one for Quick Patch. Disabled only while patching. + val canInteract = enabled + // For visual highlight: in MULTI mode highlight when source is enabled; in + // SINGLE_SELECT highlight when this row is the picked one. + val isHighlighted = if (mode == SourceSheetMode.SINGLE_SELECT) isActiveSelection else isEnabled + + val animatedBorder by animateColorAsState( + targetValue = when { + isHovered && canInteract -> accentColor.copy(alpha = if (isHighlighted) 0.7f else 0.45f) + isHighlighted -> accentColor.copy(alpha = 0.35f) + else -> borderColor + }, + animationSpec = tween(150) + ) + val animatedBg by animateColorAsState( + targetValue = when { + isHovered && canInteract -> accentColor.copy(alpha = if (isHighlighted) 0.12f else 0.05f) + isHighlighted -> accentColor.copy(alpha = 0.06f) + else -> Color.Transparent + }, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, animatedBorder, RoundedCornerShape(corners.medium)) + .background(animatedBg) + .hoverable(hoverInteraction) + .then( + if (canInteract) Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = if (mode == SourceSheetMode.SINGLE_SELECT) onSelectSingle else onOpenPatches) + else Modifier + ) + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // LED indicator — glows when enabled (MULTI) or selected (SINGLE). + LedIndicator(isOn = isHighlighted, isHot = isHovered && canInteract, accentColor = accentColor) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = source.name, + fontSize = 12.sp, + fontWeight = if (isEnabled) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (isDefault) { + Text( + "DEFAULT", + fontSize = 8.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = when (source.type) { + PatchSourceType.DEFAULT -> source.url?.removePrefix("https://github.com/") ?: "Built-in" + PatchSourceType.GITHUB -> source.url?.removePrefix("https://github.com/") ?: "GitHub" + PatchSourceType.GITLAB -> source.url?.removePrefix("https://gitlab.com/") ?: "GitLab" + PatchSourceType.LOCAL -> source.filePath?.let { File(it).name } ?: "Local file" + }, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (isEnabled && version != null) { + Text( + text = "·", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + Text( + text = version, + fontSize = 10.sp, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + color = accentColor.copy(alpha = 0.9f) + ) + ChannelBadge(channel = channel, mono = mono) + } else if (isEnabled && isLoading) { + Text( + text = "·", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 1.5.dp, + color = accentColor, + ) + Spacer(Modifier.width(2.dp)) + Text( + text = "RESOLVING...", + fontSize = 9.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + color = accentColor.copy(alpha = 0.8f), + letterSpacing = 1.sp, + ) + } + } + } + + // Edit + delete are hidden for default; toggle is always shown + if (!isDefault && enabled) { + IconButton(onClick = onEdit, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.size(14.dp) + ) + } + IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.size(14.dp) + ) + } + Spacer(Modifier.width(4.dp)) + } + when (mode) { + SourceSheetMode.MULTI_TOGGLE -> Switch( + checked = isEnabled, + onCheckedChange = onToggleEnabled, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedTrackColor = accentColor.copy(alpha = 0.5f), + checkedThumbColor = accentColor, + ), + modifier = Modifier.scale(0.8f) + ) + SourceSheetMode.SINGLE_SELECT -> RadioButton( + selected = isActiveSelection, + onClick = onSelectSingle, + enabled = enabled, + colors = RadioButtonDefaults.colors( + selectedColor = accentColor, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ), + ) + } + } + } +} + + +@Composable +private fun ChannelBadge( + channel: app.morphe.gui.util.EnabledSourcesLoader.Channel?, + mono: androidx.compose.ui.text.font.FontFamily, +) { + val corners = LocalMorpheCorners.current + val accents = LocalMorpheAccents.current + val (label, color) = when (channel) { + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_LATEST -> "STABLE LATEST" to accents.secondary + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_OLDER -> "STABLE OLDER" to accents.warning + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_LATEST -> "DEV LATEST" to androidx.compose.ui.graphics.Color(0xFFFFD43B) + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_OLDER -> "DEV OLDER" to accents.warning + else -> "STABLE LATEST" to accents.secondary + } + Box( + modifier = Modifier + .border(1.dp, color.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) + .background(color.copy(alpha = 0.08f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = label, + fontSize = 8.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = color, + ) + } +} + +/** + * Tiny status LED on the left of each source row. Solid glow when the source is + * enabled; dim ring when off. Brightens on hover for the click-to-open affordance. + */ +@Composable +private fun LedIndicator(isOn: Boolean, isHot: Boolean, accentColor: Color) { + val color by animateColorAsState( + targetValue = when { + isOn && isHot -> accentColor + isOn -> accentColor.copy(alpha = 0.85f) + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + }, + animationSpec = tween(200) + ) + val haloAlpha by animateColorAsState( + targetValue = if (isOn) accentColor.copy(alpha = if (isHot) 0.35f else 0.18f) else Color.Transparent, + animationSpec = tween(200) + ) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(14.dp)) { + // Soft halo ring + Box( + modifier = Modifier + .size(12.dp) + .background(haloAlpha, shape = androidx.compose.foundation.shape.CircleShape) + ) + // Core dot + Box( + modifier = Modifier + .size(7.dp) + .background(color, shape = androidx.compose.foundation.shape.CircleShape) + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt new file mode 100644 index 00000000..7eb200e7 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.MorpheAccentColors +import app.morphe.gui.util.EnabledSourcesLoader + +/** Per-source LED state surfaced in [SourcesCountPill]. */ +enum class SourceLedState { DISABLED, STABLE_LATEST, OLDER, DEV } + +/** + * Header pill showing source count + per-source channel LEDs + trailing "+". + * Used in expert mode (clickable, opens [SourceManagementSheet]) and in Quick + * Patch mode (purely informational — pass `onClick = null`). + */ +@Composable +fun SourcesCountPill( + sourceStates: List, + onClick: (() -> Unit)? = null, +) { + val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val interactive = onClick != null + val borderColor by animateColorAsState( + if (isHovered && interactive) accents.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.10f), + animationSpec = tween(200) + ) + val tint = if (isHovered && interactive) accents.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f) + val count = sourceStates.size.coerceAtLeast(1) + val label = if (count == 1) "1 SOURCE" else "$count SOURCES" + Row( + modifier = Modifier + .height(dimens.controlHeight) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surface) + .then( + if (interactive) Modifier + .hoverable(hoverInteraction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + else Modifier + ) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.5.sp, + color = tint, + ) + if (sourceStates.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + sourceStates.forEach { state -> SourceLed(state = state, accents = accents) } + } + } + if (interactive) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Manage patch sources", + tint = tint, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Composable +private fun SourceLed(state: SourceLedState, accents: MorpheAccentColors) { + val color = when (state) { + SourceLedState.DISABLED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + SourceLedState.STABLE_LATEST -> accents.primary + SourceLedState.OLDER -> accents.warning + SourceLedState.DEV -> Color(0xFFFFD43B) + } + Box( + modifier = Modifier + .size(6.dp) + .background(color, shape = CircleShape) + ) +} + +/** Map a [PatchSource] + its resolved channel to a UI LED state. */ +fun sourceLedState( + source: PatchSource, + channel: EnabledSourcesLoader.Channel?, +): SourceLedState { + if (!source.enabled) return SourceLedState.DISABLED + return when (channel) { + EnabledSourcesLoader.Channel.STABLE_LATEST -> SourceLedState.STABLE_LATEST + EnabledSourcesLoader.Channel.STABLE_OLDER -> SourceLedState.OLDER + EnabledSourcesLoader.Channel.DEV_LATEST, + EnabledSourcesLoader.Channel.DEV_OLDER -> SourceLedState.DEV + // No load yet — assume latest until we know otherwise. + null, EnabledSourcesLoader.Channel.UNKNOWN -> SourceLedState.STABLE_LATEST + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 5f06b25b..6a56799d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -7,7 +7,11 @@ package app.morphe.gui.ui.screens.home import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -22,6 +26,8 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.* import androidx.compose.foundation.HorizontalScrollbar import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.ui.text.style.TextOverflow @@ -30,12 +36,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Warning import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,6 +51,8 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -55,6 +65,7 @@ import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalThemeState @@ -65,10 +76,19 @@ import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.SupportedApp +import app.morphe.gui.data.repository.PatchSourceManager +import app.morphe.gui.ui.components.SourceLedState +import app.morphe.gui.ui.components.SourceManagementSheet +import app.morphe.gui.ui.components.SourcesCountPill +import app.morphe.gui.ui.components.sourceLedState import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.morpheScrollbarStyle +import kotlinx.coroutines.launch +import org.koin.compose.koinInject import app.morphe.gui.ui.screens.home.components.ApkInfoCard import app.morphe.gui.ui.screens.home.components.FullScreenDropZone +import app.morphe.gui.ui.screens.home.components.SupportedAppListRow +import app.morphe.gui.ui.components.MorpheErrorBar import app.morphe.gui.ui.components.OfflineBanner import app.morphe.gui.ui.components.UpdateBanner import app.morphe.gui.ui.screens.patches.PatchesScreen @@ -98,20 +118,80 @@ fun HomeScreenContent( val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() + val patchSourceManager: PatchSourceManager = koinInject() + val allSources by patchSourceManager.allSources.collectAsState() + val coroutineScope = rememberCoroutineScope() + // Two-flag pattern for smooth navigation in/out of the sheet: + // - showSourceManagementSheet: actually visible right now + // - pendingReopenSheet: user navigated away from the sheet via a row click; + // we should reopen it once they pop back AND the screen transition settles. + // rememberSaveable on both so they survive Voyager's push/pop teardown. + var showSourceManagementSheet by rememberSaveable { mutableStateOf(false) } + var pendingReopenSheet by rememberSaveable { mutableStateOf(false) } + + // Re-show the sheet after the pop animation finishes, NOT immediately on + // re-entry. Without the delay the sheet flashes in mid-transition. + LaunchedEffect(Unit) { + if (pendingReopenSheet) { + kotlinx.coroutines.delay(220) + showSourceManagementSheet = true + pendingReopenSheet = false + } + } + val navStackSize = navigator.items.size LaunchedEffect(navStackSize) { viewModel.refreshPatchesIfNeeded() } - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(uiState.error) { - uiState.error?.let { error -> - snackbarHostState.showSnackbar( - message = error, - duration = SnackbarDuration.Short - ) - viewModel.clearError() - } + if (showSourceManagementSheet) { + val snapshot = viewModel.getResolvedSourcesSnapshot() + val versions: Map = snapshot + ?.resolved + ?.associate { it.source.id to it.resolvedVersion } + ?: emptyMap() + val channels: Map = snapshot + ?.resolved + ?.associate { it.source.id to it.channel } + ?: emptyMap() + SourceManagementSheet( + sources = allSources, + sourceVersions = versions, + sourceChannels = channels, + isLoading = uiState.isLoadingPatches, + onToggleEnabled = { id, enabled -> + coroutineScope.launch { + patchSourceManager.setSourceEnabled(id, enabled) + // Re-resolve releases + reload patches so badges, versions, + // and the union app list reflect the new enabled set. + viewModel.retryLoadPatches() + } + }, + onAdd = { source -> + coroutineScope.launch { patchSourceManager.addSource(source) } + }, + onEdit = { updated -> + coroutineScope.launch { patchSourceManager.updateSource(updated) } + }, + onRemove = { id -> + coroutineScope.launch { patchSourceManager.removeSource(id) } + }, + onOpenPatches = { sourceId -> + // Hide sheet immediately so it doesn't ride the push animation. + // Mark it as pending-reopen so it returns smoothly after pop. + showSourceManagementSheet = false + pendingReopenSheet = true + coroutineScope.launch { + patchSourceManager.switchSource(sourceId) + navigator.push(PatchesScreen( + apkPath = uiState.apkInfo?.filePath ?: "", + apkName = uiState.apkInfo?.appName ?: "Select APK first" + )) + } + }, + onDismiss = { showSourceManagementSheet = false }, + enabled = !uiState.isAnalyzing, + ) } // Full screen drop zone wrapper @@ -125,7 +205,17 @@ fun HomeScreenContent( modifier = Modifier .fillMaxSize() ) { - val useSplitLayout = maxWidth >= 720.dp + // Side-by-side layout: drop zone / APK info on the left, vertical + // supported-apps list on the right. Falls back to top/bottom on + // narrower windows. Hysteresis (switch up at 920dp, down at 880dp) + // prevents flicker when the user resizes near the threshold. + var splitLayoutState by remember { mutableStateOf(maxWidth >= 900.dp) } + splitLayoutState = when { + maxWidth >= 920.dp -> true + maxWidth < 880.dp -> false + else -> splitLayoutState + } + val useSplitLayout = splitLayoutState val isCompact = maxWidth < 500.dp val isSmall = maxHeight < 600.dp val padding = if (isCompact) 16.dp else 24.dp @@ -148,7 +238,9 @@ fun HomeScreenContent( apkName = uiState.apkInfo!!.appName, patchesFilePath = patchesFile.absolutePath, packageName = uiState.apkInfo!!.packageName, - apkArchitectures = uiState.apkInfo!!.architectures + apkArchitectures = uiState.apkInfo!!.architectures, + patchesFilePaths = viewModel.getAllResolvedPatchFiles().map { it.absolutePath }, + patchSourceNames = viewModel.getAllResolvedPatchSourceNames(), )) } }, @@ -178,6 +270,50 @@ fun HomeScreenContent( } } + val resolvedSnapshot = viewModel.getResolvedSourcesSnapshot() + val versionsBySource: Map = resolvedSnapshot + ?.resolved + ?.associate { it.source.id to it.resolvedVersion } + ?: emptyMap() + val channelsBySource: Map = + resolvedSnapshot + ?.resolved + ?.associate { it.source.id to it.channel } + ?: emptyMap() + // Source names whose patches target the currently-selected APK's package. + // Used by ApkInfoCard's "FROM" row to surface multi-source provenance. + val patchSourcesForSelectedApk: List = uiState.apkInfo?.let { info -> + val snapshot = resolvedSnapshot ?: return@let null + snapshot.guiPatchesBySource.entries + .filter { (_, patches) -> + patches.any { p -> p.compatiblePackages.any { it.name == info.packageName } } + } + .mapNotNull { (sourceId, _) -> + allSources.firstOrNull { it.id == sourceId }?.name + } + } ?: emptyList() + + // Per-package source attribution map used by the supported-apps cards. + // Built once per recomposition so each card just looks up its own list. + val sourceNamesByPackage: Map> = if (resolvedSnapshot == null) { + emptyMap() + } else { + val sourceIdToName = allSources.associate { it.id to it.name } + val accum = mutableMapOf>() + resolvedSnapshot.guiPatchesBySource.forEach { (sourceId, patches) -> + val name = sourceIdToName[sourceId] ?: return@forEach + val packages = patches.flatMap { it.compatiblePackages.map { p -> p.name } } + .filter { it.isNotBlank() } + .toSet() + packages.forEach { pkg -> + accum.getOrPut(pkg) { mutableListOf() }.add(name) + } + } + accum + } + val sourceStates: List = allSources.map { src -> + sourceLedState(src, channelsBySource[src.id]) + } val headerContent: @Composable ColumnScope.() -> Unit = { if (useHorizontalHeader) { HeaderBar( @@ -186,6 +322,8 @@ fun HomeScreenContent( onChangePatchesClick = onChangePatchesClick, onRetry = onRetry, onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, + onManageSourcesClick = { showSourceManagementSheet = true }, + sourceStates = sourceStates, ) } else { Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) @@ -237,7 +375,8 @@ fun HomeScreenContent( patchesLoaded = patchesLoaded, onClearClick = onClearClick, onChangeClick = onChangeClick, - onContinueClick = onContinueClick + onContinueClick = onContinueClick, + patchSourceNames = patchSourcesForSelectedApk, ) } } @@ -258,7 +397,8 @@ fun HomeScreenContent( isDefaultSource = uiState.isDefaultSource, supportedApps = uiState.supportedApps, loadError = uiState.patchLoadError, - onRetry = onRetry + onRetry = onRetry, + sourceNamesByPackage = sourceNamesByPackage, ) } } @@ -273,6 +413,8 @@ fun HomeScreenContent( onChangePatchesClick = onChangePatchesClick, onRetry = onRetry, onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, + onManageSourcesClick = { showSourceManagementSheet = true }, + sourceStates = sourceStates, ) } else { Column( @@ -316,22 +458,12 @@ fun HomeScreenContent( } } - // ── Scrollable body ── - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) { - val bodyMaxHeight = this.maxHeight - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .heightIn(min = bodyMaxHeight), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = if (pinSupportedAppsToBottom) Arrangement.SpaceBetween else Arrangement.Top - ) { + // ── Body ── + if (useSplitLayout) { + // Side-by-side: drop zone / APK info on the left, + // vertical supported-apps list on the right. The list pane + // owns its own scroll; the rest stays static. + Column(modifier = Modifier.weight(1f).fillMaxWidth()) { if (uiState.showUpdateBanner) { UpdateBanner( info = uiState.updateInfo!!, @@ -339,57 +471,156 @@ fun HomeScreenContent( onDismissForVersion = { viewModel.dismissUpdateForVersion() }, modifier = Modifier .fillMaxWidth() - .padding(start = padding, end = padding, top = 8.dp) + .padding(start = padding, end = padding, top = 8.dp), ) } - - // ── Main workspace area ── - Box( + if (uiState.showMultiSourceHint) { + MultiSourceHintBanner( + onDismiss = { viewModel.dismissMultiSourceHint() }, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, end = padding, top = 8.dp), + ) + } + Row( modifier = Modifier + .weight(1f) .fillMaxWidth() - .padding(padding), - contentAlignment = Alignment.Center + // Small cute padding for small cute space + // between the HeaderBar's bottom + // divider and the actual body section. + .padding( + start = if (isCompact) 12.dp else 10.dp, + end = padding, + top = 4.dp, + bottom = padding, + ), + horizontalArrangement = Arrangement.spacedBy(padding), ) { - MiddleContent( - uiState = uiState, + // Left: browse/discover supported apps (wizard step 1). + SupportedAppsListPane( + supportedApps = uiState.supportedApps, + sourceNamesByPackage = sourceNamesByPackage, + isLoading = uiState.isLoadingPatches, + loadError = uiState.patchLoadError, + onRetry = onRetry, isCompact = isCompact, - patchesLoaded = patchesLoaded, - onClearClick = onClearClick, - onChangeClick = onChangeClick, - onContinueClick = onContinueClick + modifier = Modifier + .weight(1.2f) + .fillMaxHeight(), ) + // Right: APK info / drop zone (wizard step 2 — pick the + // APK you want patched). Content centers vertically when + // it fits, scrolls when it doesn't, so the CONTINUE + // button is never clipped off the bottom. + BoxWithConstraints( + modifier = Modifier.weight(1f).fillMaxHeight(), + ) { + val viewport = this.maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = viewport), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = patchesLoaded, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick, + patchSourceNames = patchSourcesForSelectedApk, + ) + } + } } - - // ── Supported apps ── + } + } else { + // ── Scrollable top/bottom body (narrow windows) ── + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + val bodyMaxHeight = this.maxHeight + val scrollState = rememberScrollState() Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .heightIn(min = bodyMaxHeight), horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding( - start = padding, - end = padding, - bottom = if (isSmall) 8.dp else 16.dp - ) + verticalArrangement = if (pinSupportedAppsToBottom) Arrangement.SpaceBetween else Arrangement.Top, ) { - SupportedAppsSection( - isCompact = isCompact, - maxWidth = outerMaxWidth, - isLoading = uiState.isLoadingPatches, - isDefaultSource = uiState.isDefaultSource, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = onRetry - ) + if (uiState.showUpdateBanner) { + UpdateBanner( + info = uiState.updateInfo!!, + onDismissForSession = { viewModel.dismissUpdateForSession() }, + onDismissForVersion = { viewModel.dismissUpdateForVersion() }, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, end = padding, top = 8.dp), + ) + } + if (uiState.showMultiSourceHint) { + MultiSourceHintBanner( + onDismiss = { viewModel.dismissMultiSourceHint() }, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, end = padding, top = 8.dp), + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = patchesLoaded, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick, + patchSourceNames = patchSourcesForSelectedApk, + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding( + start = padding, + end = padding, + bottom = if (isSmall) 8.dp else 16.dp, + ), + ) { + SupportedAppsSection( + isCompact = isCompact, + maxWidth = outerMaxWidth, + isLoading = uiState.isLoadingPatches, + isDefaultSource = uiState.isDefaultSource, + supportedApps = uiState.supportedApps, + loadError = uiState.patchLoadError, + onRetry = onRetry, + sourceNamesByPackage = sourceNamesByPackage, + ) + } } - } - // Show scrollbar only when content overflows - if (scrollState.maxValue > 0) { - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter(scrollState), - style = morpheScrollbarStyle() - ) + if (scrollState.maxValue > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle(), + ) + } } } } @@ -405,11 +636,19 @@ fun HomeScreenContent( ) } - // Snackbar host - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter) - ) + // Error/warning bar — custom Morphe-styled, avoids Material3 + // SnackbarHost (whose internal SnackbarKt invocation path the + // shadow `minimize` analyzer can't trace, causing runtime + // NoClassDefFoundError in the packaged jar). + uiState.error?.let { error -> + MorpheErrorBar( + message = error, + onDismiss = { viewModel.clearError() }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 24.dp, vertical = 20.dp) + ) + } // Drag overlay if (uiState.isDragHovering) { @@ -437,7 +676,9 @@ private fun handleContinue( apkName = info.appName, patchesFilePath = patchesFile.absolutePath, packageName = info.packageName, - apkArchitectures = info.architectures + apkArchitectures = info.architectures, + patchesFilePaths = viewModel.getAllResolvedPatchFiles().map { it.absolutePath }, + patchSourceNames = viewModel.getAllResolvedPatchSourceNames(), )) } } @@ -454,6 +695,8 @@ private fun HeaderBar( onChangePatchesClick: () -> Unit, onRetry: () -> Unit, onUpdateChannelChanged: () -> Unit = {}, + onManageSourcesClick: () -> Unit = {}, + sourceStates: List = emptyList(), ) { val mono = LocalMorpheFont.current val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) @@ -495,20 +738,12 @@ private fun HeaderBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - PatchesVersionInline( - patchesVersion = uiState.patchesVersion!!, - latestLabel = uiState.latestPatchesLabel, - onChangePatchesClick = onChangePatchesClick, - patchSourceName = uiState.patchSourceName - ) - } else if (uiState.isLoadingPatches) { + if (uiState.isLoadingPatches) { PatchesLoadingIndicator() - } else if (uiState.patchLoadError != null) { - PatchesVersionInline( - patchesVersion = "NOT LOADED", - latestLabel = null, - onChangePatchesClick = onChangePatchesClick + } else { + SourcesCountPill( + sourceStates = sourceStates, + onClick = onManageSourcesClick, ) } @@ -612,6 +847,48 @@ private fun PatchesVersionInline( } } +/** One-time intro banner shown when the user first sees multi-source mode. + * Persists dismissal in ConfigRepository so it never reappears once dismissed. */ +@Composable +private fun MultiSourceHintBanner( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + Row( + modifier = modifier + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accents.primary.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) + .background(accents.primary.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "MULTIPLE SOURCES ACTIVE — patches from every enabled source are unioned. Manage from the SOURCES button above.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + letterSpacing = 0.2.sp, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Dismiss", + tint = accents.primary, + modifier = Modifier.size(14.dp), + ) + } + } +} + +// SourcesCountPill, SourceLed, SourceLedState, sourceLedState moved to +// gui/ui/components/SourcesPill.kt for reuse across modes (Quick Patch uses +// a non-clickable variant). + @Composable private fun PatchesLoadingIndicator() { val mono = LocalMorpheFont.current @@ -680,7 +957,8 @@ private fun MiddleContent( patchesLoaded: Boolean, onClearClick: () -> Unit, onChangeClick: () -> Unit, - onContinueClick: () -> Unit + onContinueClick: () -> Unit, + patchSourceNames: List = emptyList(), ) { when { uiState.isAnalyzing -> { @@ -693,7 +971,8 @@ private fun MiddleContent( isCompact = isCompact, onClearClick = onClearClick, onChangeClick = onChangeClick, - onContinueClick = onContinueClick + onContinueClick = onContinueClick, + patchSourceNames = patchSourceNames, ) } else -> { @@ -817,7 +1096,8 @@ private fun ApkSelectedSection( isCompact: Boolean, onClearClick: () -> Unit, onChangeClick: () -> Unit, - onContinueClick: () -> Unit + onContinueClick: () -> Unit, + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -834,7 +1114,8 @@ private fun ApkSelectedSection( ApkInfoCard( apkInfo = apkInfo, onClearClick = onClearClick, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + patchSourceNames = patchSourceNames, ) Spacer(modifier = Modifier.height(if (isCompact) 16.dp else 20.dp)) @@ -989,8 +1270,219 @@ private fun AnalyzingSection(isCompact: Boolean = false) { // ════════════════════════════════════════════════════════════════════ /** - * Bottom section — horizontal scrolling cards. + * Vertical-list variant of the supported-apps display used in the side-by-side + * layout. Search field at top, scrollable LazyColumn of [SupportedAppListRow] + * below. Single-expand semantics — clicking a row expands it and collapses any + * previously-expanded one. */ +@Composable +private fun SupportedAppsListPane( + supportedApps: List, + sourceNamesByPackage: Map>, + isLoading: Boolean, + loadError: String?, + onRetry: () -> Unit, + isCompact: Boolean, + modifier: Modifier = Modifier, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + var searchQuery by remember { mutableStateOf("") } + var expandedPackage by remember { mutableStateOf(null) } + + val filtered = if (searchQuery.isBlank()) supportedApps + else supportedApps.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + } + + // Collapse if the currently expanded app filters out. + LaunchedEffect(searchQuery, filtered) { + if (expandedPackage != null && filtered.none { it.packageName == expandedPackage }) { + expandedPackage = null + } + } + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val paneMaxHeight = maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .align(Alignment.Center), + ) { + // ── Header row: SUPPORTED APPS · count ── + // end = 12.dp matches the LazyColumn's right padding so "X apps" + // visually aligns with the right edge of the cards. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(end = 12.dp, bottom = 4.dp), + ) { + Text( + text = "SUPPORTED APPS", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.5.sp, + color = homeMutedTextColor(0.4f), + ) + Spacer(Modifier.weight(1f)) + if (!isLoading && supportedApps.isNotEmpty()) { + Text( + text = "${supportedApps.size} apps", + fontSize = 9.sp, + fontFamily = mono, + color = homeMutedTextColor(0.4f), + ) + } + } + + // ── Search field ── + if (supportedApps.size > 4) { + // Match the LazyColumn's right padding so the field aligns with cards. + // Dp.Unspecified disables the default 340dp cap so the field fills + // the pane width like the cards below it. + Box(modifier = Modifier.fillMaxWidth().padding(end = 12.dp)) { + SlimSearchField( + value = searchQuery, + onValueChange = { searchQuery = it }, + mono = mono, + corners = corners, + accents = accents, + maxWidth = Dp.Unspecified, + ) + } + Spacer(modifier = Modifier.height(10.dp)) + } + + when { + isLoading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(4) { idx -> + SkeletonAppRow( + corners = corners, + // Slight stagger: each row pulses 120ms after the previous + // so the skeleton list feels alive instead of lock-step. + staggerOffsetMs = idx * 120, + ) + } + } + } + loadError != null -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + ) { + Text( + text = "LOAD FAILED", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + letterSpacing = 1.sp, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = loadError, + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.6f), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + OutlinedButton( + onClick = onRetry, + shape = RoundedCornerShape(corners.small), + ) { + Text( + "RETRY", + fontFamily = mono, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + ) + } + } + } + filtered.isEmpty() -> { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (searchQuery.isBlank()) "No supported apps" + else "No apps match \"$searchQuery\"", + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f), + ) + } + } + else -> { + val listState = rememberLazyListState() + // Cap the list at the pane's available height (minus a header + // + optional search allowance) so it scrolls when there are + // many apps but wraps tight + lets the Column center when few. + // Tight estimate: header ~22dp; search field (only shown when + // >4 apps) ~46dp. Anything over-budgeted leaves dead space + // above the list when content fills, so be precise. + val headerSearchAllowance = + if (supportedApps.size > 4) 68.dp else 22.dp + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn( + max = (paneMaxHeight - headerSearchAllowance) + .coerceAtLeast(120.dp) + ), + ) { + androidx.compose.foundation.lazy.LazyColumn( + state = listState, + // Scrollbar is 6dp wide and sits at the Box's right edge. + // 6 (scrollbar width) + 6 (visible gap) = 12dp keeps content + // fully clear of the scrollbar with breathing room. + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(items = filtered, key = { it.packageName }) { app -> + SupportedAppListRow( + app = app, + isExpanded = expandedPackage == app.packageName, + onClick = { + expandedPackage = if (expandedPackage == app.packageName) null + else app.packageName + }, + patchSourceNames = sourceNamesByPackage[app.packageName] ?: emptyList(), + ) + } + } + // Wrap the scrollbar in a matchParentSize Box so it + // tracks the LazyColumn's wrapped height WITHOUT forcing + // the outer Box to fill its heightIn(max=…) cap. Then + // align CenterEnd + wrap width to keep it pinned at the + // right edge at its natural 6dp thickness. + Box( + modifier = Modifier.matchParentSize(), + contentAlignment = Alignment.CenterEnd, + ) { + VerticalScrollbar( + modifier = Modifier.fillMaxHeight(), + adapter = rememberScrollbarAdapter(listState), + style = morpheScrollbarStyle(), + ) + } + } + } + } + } + } +} + @Composable private fun SupportedAppsSection( isCompact: Boolean = false, @@ -999,7 +1491,10 @@ private fun SupportedAppsSection( isDefaultSource: Boolean = true, supportedApps: List = emptyList(), loadError: String? = null, - onRetry: () -> Unit = {} + onRetry: () -> Unit = {}, + /** packageName → source display names contributing patches. Used to badge + * cards with their source attribution in multi-source mode. */ + sourceNamesByPackage: Map> = emptyMap(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -1114,65 +1609,13 @@ private fun SupportedAppsSection( } if (supportedApps.size > 4) { - if (isDefaultSource) { - // Default search field for Morphe-source patches. - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - placeholder = { - Text( - "Filter apps…", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.4f) - ) - }, - leadingIcon = { - Icon( - Icons.Default.Search, - contentDescription = null, - tint = homeMutedTextColor(0.6f), - modifier = Modifier.size(16.dp) - ) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { searchQuery = "" }) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = homeMutedTextColor(0.5f), - modifier = Modifier.size(14.dp) - ) - } - } - }, - singleLine = true, - textStyle = MaterialTheme.typography.bodySmall.copy( - fontFamily = mono, - fontSize = 11.sp - ), - shape = RoundedCornerShape(corners.small), - modifier = Modifier - .widthIn(max = 260.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), - cursorColor = accents.primary - ) - ) - } else { - // Slim, elongated search field for third-party patches. - // Uses BasicTextField + a custom decoration so we can break - // out of OutlinedTextField's 56dp minimum height. - SlimSearchField( - value = searchQuery, - onValueChange = { searchQuery = it }, - mono = mono, - corners = corners, - accents = accents - ) - } + SlimSearchField( + value = searchQuery, + onValueChange = { searchQuery = it }, + mono = mono, + corners = corners, + accents = accents + ) Spacer(modifier = Modifier.height(12.dp)) } @@ -1208,6 +1651,7 @@ private fun SupportedAppsSection( onClose = { selectedApp = null }, isDefaultSource = isDefaultSource, useVerticalLayout = useVerticalLayout, + sourceNamesByPackage = sourceNamesByPackage, modifier = Modifier .fillMaxWidth() .padding(horizontal = if (isCompact) 8.dp else 16.dp) @@ -1424,7 +1868,8 @@ private fun SupportedAppsMasterDetail( onClose: () -> Unit, isDefaultSource: Boolean, useVerticalLayout: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + sourceNamesByPackage: Map> = emptyMap(), ) { val cardSpacing = 10.dp @@ -1446,7 +1891,8 @@ private fun SupportedAppsMasterDetail( app = app, isSelected = app.packageName == selectedApp?.packageName, onClick = { onSelect(app) }, - isDefaultSource = isDefaultSource + isDefaultSource = isDefaultSource, + patchSourceNames = sourceNamesByPackage[app.packageName] ?: emptyList(), ) } } @@ -1481,7 +1927,8 @@ private fun SupportedAppVerticalCard( isSelected: Boolean, onClick: () -> Unit, isDefaultSource: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -1490,7 +1937,9 @@ private fun SupportedAppVerticalCard( // ── Dimensions ── val collapsedWidth = 188.dp val expandedExtraWidth = 320.dp - val cardHeight = if (isDefaultSource) 250.dp else 190.dp + // Uniform height across all cards — every card shows the EXPERIMENTAL row + // (with "—" when none) so they line up visually in the row. + val cardHeight = 250.dp // ── Animations ── val animatedExtraWidth by animateDpAsState( @@ -1600,19 +2049,17 @@ private fun SupportedAppVerticalCard( nullLabel = "Any version" ) - // Experimental row only for default (Morphe) patch sources. - // Third-party patches don't get experimental support here. - if (isDefaultSource) { - Spacer(modifier = Modifier.height(12.dp)) - VersionWithDownload( - channelLabel = "EXPERIMENTAL LATEST", - channelColor = accents.warning, - version = latestExperimental, - downloadUrl = if (hasExperimental) app.experimentalDownloadUrl else null, - mono = mono, - corners = corners - ) - } + // Always show the EXPERIMENTAL row — when the app has no experimental + // version, VersionWithDownload renders "—" via its nullLabel default. + Spacer(modifier = Modifier.height(12.dp)) + VersionWithDownload( + channelLabel = "EXPERIMENTAL LATEST", + channelColor = accents.warning, + version = if (hasExperimental) latestExperimental else null, + downloadUrl = if (hasExperimental) app.experimentalDownloadUrl else null, + mono = mono, + corners = corners + ) } // ════════════════════════════════════════════════ @@ -1627,11 +2074,20 @@ private fun SupportedAppVerticalCard( .background(borderColor) ) - Column( + // Right-panel content can overflow the fixed cardHeight when an app + // has lots of versions or sources. Wrap in a Box with a scrollable + // Column + vertical scrollbar so users can reach everything. + val rightPanelScroll = rememberScrollState() + Box( modifier = Modifier .width((animatedExtraWidth - 1.dp).coerceAtLeast(0.dp)) .fillMaxHeight() - .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rightPanelScroll) + .padding(start = 16.dp, end = 22.dp, top = 16.dp, bottom = 16.dp) ) { // ── Package name + close ── Row( @@ -1686,6 +2142,61 @@ private fun SupportedAppVerticalCard( Spacer(modifier = Modifier.height(12.dp)) + // ── PATCHES FROM (sources contributing patches for this app) ── + // Always shown for visual consistency. Renders "—" if no source + // attribution data is available for this app. + Text( + text = "PATCHES FROM", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary.copy(alpha = 0.85f), + letterSpacing = 1.2.sp + ) + Spacer(modifier = Modifier.height(6.dp)) + if (patchSourceNames.isNotEmpty()) { + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + patchSourceNames.forEach { name -> + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small), + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small), + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = name, + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 0.3.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + } + } else { + Text( + text = "—", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.35f) + ) + } + Spacer(modifier = Modifier.height(14.dp)) + // ── ALSO STABLE tags ── Text( text = "ALSO STABLE", @@ -1730,54 +2241,65 @@ private fun SupportedAppVerticalCard( ) } - if (isDefaultSource) { - Spacer(modifier = Modifier.height(14.dp)) - - // ── EXPERIMENTAL tags (Morphe-source patches only) ── + // ── EXPERIMENTAL tags ── + // Always shown for visual consistency across cards. Renders "—" + // when this app has no experimental versions in the loaded patches. + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = "EXPERIMENTAL", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.warning.copy(alpha = 0.85f), + letterSpacing = 1.2.sp + ) + Spacer(modifier = Modifier.height(6.dp)) + if (app.experimentalVersions.isNotEmpty()) { + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + app.experimentalVersions.take(8).forEach { version -> + VersionPill( + version = version, + color = accents.warning, + mono = mono, + corners = corners + ) + } + if (app.experimentalVersions.size > 8) { + Text( + text = "+${app.experimentalVersions.size - 8}", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) + ) + } + } + } else { Text( - text = "EXPERIMENTAL", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, + text = "—", + fontSize = 10.sp, fontFamily = mono, - color = accents.warning.copy(alpha = 0.85f), - letterSpacing = 1.2.sp + color = homeMutedTextColor(0.35f) ) - Spacer(modifier = Modifier.height(6.dp)) - if (app.experimentalVersions.isNotEmpty()) { - @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - app.experimentalVersions.take(8).forEach { version -> - VersionPill( - version = version, - color = accents.warning, - mono = mono, - corners = corners - ) - } - if (app.experimentalVersions.size > 8) { - Text( - text = "+${app.experimentalVersions.size - 8}", - fontSize = 10.sp, - fontFamily = mono, - color = homeMutedTextColor(0.5f), - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - ) - } - } - } else { - Text( - text = "none", - fontSize = 10.sp, - fontFamily = mono, - color = homeMutedTextColor(0.35f) - ) - } } } + // Vertical scrollbar — only shows when content overflows. + if (rightPanelScroll.maxValue > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 6.dp), + adapter = rememberScrollbarAdapter(rightPanelScroll), + style = morpheScrollbarStyle() + ) + } + } } } } @@ -1886,8 +2408,10 @@ private fun SlimSearchField( onValueChange: (String) -> Unit, mono: androidx.compose.ui.text.font.FontFamily, corners: app.morphe.gui.ui.theme.MorpheCornerStyle, - accents: app.morphe.gui.ui.theme.MorpheAccentColors + accents: app.morphe.gui.ui.theme.MorpheAccentColors, + maxWidth: Dp = 340.dp, ) { + val dimens = LocalMorpheDimens.current val muted = MaterialTheme.colorScheme.onSurfaceVariant val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -1910,9 +2434,9 @@ private fun SlimSearchField( ), cursorBrush = SolidColor(accents.primary), modifier = Modifier - .widthIn(max = 360.dp) + .widthIn(max = maxWidth) .fillMaxWidth() - .height(34.dp) + .height(dimens.controlHeight) .clip(RoundedCornerShape(corners.small)) .border(1.dp, borderColor, RoundedCornerShape(corners.small)), decorationBox = { innerTextField -> @@ -2059,3 +2583,81 @@ private fun openFilePicker(): File? { null } } + +// ════════════════════════════════════════════════════════════════════ +// LOADING SKELETON — ghost row that mimics SupportedAppListRow's shape +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun SkeletonAppRow( + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + staggerOffsetMs: Int, +) { + val infinite = rememberInfiniteTransition(label = "skeletonPulse") + val alpha by infinite.animateFloat( + initialValue = 0.06f, + targetValue = 0.16f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, delayMillis = staggerOffsetMs), + repeatMode = RepeatMode.Reverse, + ), + label = "skeletonAlpha", + ) + val baseColor = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) + val cardBg = MaterialTheme.colorScheme.surface.copy(alpha = 0.4f) + val outline = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .background(cardBg) + .border(1.dp, outline, RoundedCornerShape(corners.medium)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Row 1: avatar + name/package bars + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Spacer(Modifier.width(10.dp)) + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Box( + modifier = Modifier + .height(10.dp) + .width(140.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Box( + modifier = Modifier + .height(8.dp) + .width(180.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor.copy(alpha = alpha * 0.6f)), + ) + } + } + // Row 2: chip placeholders + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .height(20.dp) + .width(110.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Box( + modifier = Modifier + .height(20.dp) + .width(130.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor.copy(alpha = alpha * 0.7f)), + ) + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index f06766f3..3ee85b97 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 @@ -20,14 +20,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import net.dongliu.apk.parser.ApkFile +import app.morphe.engine.util.ApkManifestReader +import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import app.morphe.gui.util.SupportedAppExtractor import app.morphe.gui.util.VersionStatus +import app.morphe.gui.data.repository.ActiveMode +import app.morphe.gui.util.humanizePatchLoadError import java.io.File class HomeViewModel( @@ -47,25 +51,64 @@ class HomeViewModel( // Cached patches and supported apps private var cachedPatches: List = emptyList() private var cachedPatchesFile: File? = null + /** All resolved patch files across enabled sources. Single-element in + * single-source mode. Exposed via [getAllResolvedPatchFiles] for screens + * that navigate downstream and need to pass the full set. */ + private var cachedAllPatchFiles: List = emptyList() private var loadJob: Job? = null - init { - // Auto-fetch patches on startup - loadPatchesAndSupportedApps() + fun getAllResolvedPatchFiles(): List = + cachedAllPatchFiles.takeIf { it.isNotEmpty() } + ?: listOfNotNull(cachedPatchesFile) + + /** Display names for each entry in [getAllResolvedPatchFiles], in the same + * order. Used by PatchSelectionScreen to badge patches with their source. */ + fun getAllResolvedPatchSourceNames(): List = + cachedSourcesResult + ?.resolved + ?.filter { it.patchFile != null } + ?.map { it.source.name } + ?: emptyList() + init { // Background CLI update check — non-blocking, banner only. screenModelScope.launch { + val config = configRepository.loadConfig() val info = updateCheckRepository.getUpdateInfo() - val dismissed = configRepository.loadConfig().dismissedUpdateVersion + val dismissed = config.dismissedUpdateVersion + val multiSourceShouldShow = !config.multiSourceHintDismissed && + patchSourceManager.getEnabledSourcesSync().size > 1 _uiState.value = _uiState.value.copy( updateInfo = info, dismissedUpdateVersion = dismissed, + showMultiSourceHint = multiSourceShouldShow, ) } + // 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 + // unconditional init-block load that used to fire even when the + // user was actually in Quick mode (we don't construct HomeVM in + // pure Quick sessions today, but Voyager keeps it alive across + // mode switches, so the gate prevents wasted reloads on return). + screenModelScope.launch { + patchSourceManager.activeMode.collect { mode -> + if (mode == ActiveMode.EXPERT) { + loadPatchesAndSupportedApps() + } + } + } + // Observe source changes — drop(1) to skip the initial value screenModelScope.launch { patchSourceManager.sourceVersion.drop(1).collect { + // Skip when Quick mode is active — QuickPatchViewModel will + // handle the reload for its (single) active source. Without + // this gate both VMs fire parallel loads on every cache + // clear, doubling network traffic and tripling the + // cancellation cascade surface on slow connections. + if (patchSourceManager.activeMode.value != ActiveMode.EXPERT) return@collect Logger.info("HomeVM: Source changed, reloading patches...") patchRepository = patchSourceManager.getActiveRepositorySync() localPatchFilePath = patchSourceManager.getLocalFilePath() @@ -114,6 +157,16 @@ class HomeViewModel( _uiState.value = _uiState.value.copy(updateBannerSessionDismissed = true) } + /** + * Dismiss the multi-source intro hint persistently. One-shot. + */ + fun dismissMultiSourceHint() { + _uiState.value = _uiState.value.copy(showMultiSourceHint = false) + screenModelScope.launch { + configRepository.setMultiSourceHintDismissed() + } + } + /** * Hide the update banner persistently for the current available version. * The banner will reappear automatically when an even newer version becomes @@ -129,108 +182,45 @@ class HomeViewModel( // Track the last loaded version to avoid reloading unnecessarily private var lastLoadedVersion: String? = null + // Snapshot of per-source pinned versions used in the last load — drives + // refreshPatchesIfNeeded so we reload when ANY source's pin changes. + private var lastLoadedVersionsBySource: Map = emptyMap() /** - * Load patches from GitHub and extract supported apps. - * If a saved version exists in config, load that version instead of latest. + * Load patches from all enabled sources via [EnabledSourcesLoader] and build + * the union supported-apps list. Single-enabled-source case produces output + * equivalent to the pre-multi-source flow. */ private fun loadPatchesAndSupportedApps(forceRefresh: Boolean = false) { loadJob?.cancel() loadJob = screenModelScope.launch { _uiState.value = _uiState.value.copy(isLoadingPatches = true, patchLoadError = null) - // LOCAL source: skip GitHub entirely, load directly from the .mpp file - if (localPatchFilePath != null) { - val localFile = File(localPatchFilePath) - if (localFile.exists()) { - loadPatchesFromFile(localFile, localFile.nameWithoutExtension, latestVersion = null, isOffline = false) - } else { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Local patch file not found: ${localFile.name}" - ) - } - return@launch - } - try { - // Check if there's a saved patches version in config - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion - - // 1. Fetch all releases to find the right one - val releasesResult = patchRepository.fetchReleases() - val releases = releasesResult.getOrNull() - - if (releases.isNullOrEmpty()) { - // Try to fall back to cached .mpp file when offline - val offlinePatchFile = findCachedPatchFile(savedVersion) - if (offlinePatchFile != null) { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile), latestVersion = null) - return@launch - } + val enabled = patchSourceManager.getEnabledRepositories() + if (enabled.isEmpty()) { _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = "Could not fetch patches: ${releasesResult.exceptionOrNull()?.message}" + patchLoadError = "No patch sources enabled. Add or enable a source from the home screen." ) return@launch } - // Find the latest stable release for reference - val latestStable = releases.firstOrNull { !it.isDevRelease() } - val latestVersion = latestStable?.tagName - val latestDevVersion = releases.firstOrNull { it.isDevRelease() }?.tagName - - // 2. Find the release to use - prefer saved version, fallback to latest stable - val release = if (savedVersion != null) { - releases.find { it.tagName == savedVersion } - ?: latestStable // Fallback to latest stable - } else { - latestStable // Latest stable - } - - if (release == null) { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "No suitable release found" - ) - return@launch - } - - // Skip reload if we've already loaded this version (unless forced) - if (!forceRefresh && lastLoadedVersion == release.tagName && cachedPatchesFile?.exists() == true) { - Logger.info("Skipping reload - already loaded version ${release.tagName}") - _uiState.value = _uiState.value.copy(isLoadingPatches = false) - return@launch - } - - Logger.info("Loading patches version: ${release.tagName} (saved=$savedVersion)") - - // 3. Download patches - val patchFileResult = patchRepository.downloadPatches(release) - val patchFile = patchFileResult.getOrNull() - - if (patchFile == null) { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Could not download patches: ${patchFileResult.exceptionOrNull()?.message}" - ) - return@launch - } - - cachedPatchesFile = patchFile - lastLoadedVersion = release.tagName - - // 3. Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches == null || patches.isEmpty()) { - val rawError = patchesResult.exceptionOrNull()?.message ?: "Unknown error" - val friendlyError = if (rawError.contains("zip", ignoreCase = true) || rawError.contains("END header", ignoreCase = true)) { + // Per-source pinned versions (with one-time migration from legacy + // single-source field). Each source's resolver looks up its own pin; + // no cross-source contamination. + val preferredVersions = configRepository.getLastPatchesVersionsBySource() + lastLoadedVersionsBySource = preferredVersions + val result = EnabledSourcesLoader.loadAll(enabled, patchService, preferredVersions) + + if (!result.anyLoaded) { + val firstError = result.resolved.firstNotNullOfOrNull { it.error } + ?: result.loaded.perSource.firstNotNullOfOrNull { it.error?.message } + ?: "Could not load any patches" + val friendlyError = if (firstError.contains("zip", ignoreCase = true) || firstError.contains("END header", ignoreCase = true)) { "Patch file is missing or corrupted. Clear cache and re-download." } else { - "Could not load patches: $rawError" + firstError } _uiState.value = _uiState.value.copy( isLoadingPatches = false, @@ -239,118 +229,70 @@ class HomeViewModel( return@launch } - cachedPatches = patches + cachedPatches = result.unionGuiPatches + // Preserve existing single-file API for downstream navigation. In + // multi-source mode this points at the first resolved source; the + // full list is exposed via [getAllResolvedPatchFiles] and the + // per-source data via [getResolvedSourcesSnapshot]. + val firstResolved = result.resolved.firstOrNull { it.patchFile != null } + cachedPatchesFile = firstResolved?.patchFile + cachedAllPatchFiles = result.resolved.mapNotNull { it.patchFile } + lastLoadedVersion = firstResolved?.resolvedVersion + cachedSourcesResult = result + + val supportedApps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + Logger.info( + "Loaded ${supportedApps.size} supported apps from " + + "${result.resolved.count { it.patchFile != null }} source(s): " + + supportedApps.map { it.displayName } + ) - // 5. Extract supported apps - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - Logger.info("Loaded ${supportedApps.size} supported apps from patches: ${supportedApps.map { "${it.displayName} (${it.recommendedVersion})" }}") + // Only flag the whole UI as offline when EVERY successfully-resolved + // source had to fall back to its cache. One source being offline + // while others are online shouldn't make the whole screen scream + // "offline" — that's a per-source state, surfaced in the sheet. + val resolvedSources = result.resolved.filter { it.patchFile != null } + val isOffline = resolvedSources.isNotEmpty() && resolvedSources.all { it.isOffline } + val displayVersion = firstResolved?.resolvedVersion + val sourceName = if (result.resolved.size == 1) { + firstResolved?.source?.name ?: patchSourceManager.getActiveSourceName() + } else { + "${result.resolved.count { it.patchFile != null }} sources" + } _uiState.value = _uiState.value.copy( isLoadingPatches = false, - isOffline = false, + isOffline = isOffline, supportedApps = supportedApps, - patchesVersion = release.tagName, - latestPatchesVersion = latestVersion, - latestDevPatchesVersion = latestDevVersion, - patchSourceName = patchSourceManager.getActiveSourceName(), + patchesVersion = displayVersion, + latestPatchesVersion = displayVersion, + latestDevPatchesVersion = null, + patchSourceName = sourceName, patchLoadError = null ) reanalyzeSelectedApk() + } catch (e: CancellationException) { + // Cancellation is normal coroutine bookkeeping (a newer load + // superseded this one, or the screen left composition). Do NOT + // write UI state — otherwise a stale "Job was cancelled" can + // clobber the in-flight successor's loading/success state. + throw e } catch (e: Exception) { Logger.error("Failed to load patches and supported apps", e) - // Try to fall back to cached .mpp file - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - try { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile), latestVersion = null) - return@launch - } catch (inner: Exception) { - Logger.error("Failed to load cached patches fallback", inner) - } - } _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = e.message ?: "Unknown error" + patchLoadError = humanizePatchLoadError(e), ) } } } /** - * Find any cached .mpp file when offline. - * Prefers the file matching savedVersion from config. - * Searches the per-source cache directory. - */ - private fun findCachedPatchFile(savedVersion: String?): File? { - val patchesDir = patchRepository.getCacheDir() - val patchFiles = patchesDir.listFiles { file -> - val ext = file.extension.lowercase() - ext == "mpp" || ext == "jar" - }?.filter { it.length() > 0 } ?: return null - - if (patchFiles.isEmpty()) return null - - return if (savedVersion != null) { - // Strip "v" prefix — savedVersion is "v1.13.0" but filenames are "patches-1.13.0.mpp" - val versionNumber = savedVersion.removePrefix("v") - patchFiles.firstOrNull { it.name.contains(versionNumber, ignoreCase = true) } - ?: patchFiles.maxByOrNull { it.lastModified() } - } else { - patchFiles.maxByOrNull { it.lastModified() } - } - } - - /** - * Extract a version string from an .mpp filename (e.g. "morphe-patches-1.3.0.mpp" -> "v1.3.0"). - */ - private fun versionFromFilename(file: File): String { - val name = file.nameWithoutExtension - // Try to find a version pattern like 1.2.3 or v1.2.3 - val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(name) - return match?.value ?: name - } - - /** - * Load patches from a local .mpp file and update UI state. - * Used as fallback when offline with cached patches. + * Snapshot of the most recent multi-source load. Used by 9d's + * PatchSelectionViewModel migration to render badged per-source patches. */ - private suspend fun loadPatchesFromFile(patchFile: File, version: String, latestVersion: String?, isOffline: Boolean = true) { - cachedPatchesFile = patchFile - lastLoadedVersion = version - - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches == null || patches.isEmpty()) { - val rawError = patchesResult.exceptionOrNull()?.message ?: "Unknown error" - val friendlyError = if (rawError.contains("zip", ignoreCase = true) || rawError.contains("END header", ignoreCase = true)) { - "Patch file is missing or corrupted. Clear cache and re-download." - } else { - "Could not load patches: $rawError" - } - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = friendlyError - ) - return - } - - cachedPatches = patches - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - Logger.info("Loaded ${supportedApps.size} supported apps from ${if (isOffline) "cached" else "local"} patches: ${patchFile.name}") - - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - isOffline = isOffline, - supportedApps = supportedApps, - patchesVersion = version, - latestPatchesVersion = latestVersion, - patchSourceName = patchSourceManager.getActiveSourceName(), - patchLoadError = null - ) - reanalyzeSelectedApk() - } + fun getResolvedSourcesSnapshot(): EnabledSourcesLoader.Result? = cachedSourcesResult + private var cachedSourcesResult: EnabledSourcesLoader.Result? = null /** * Re-runs APK analysis against the freshly-loaded `supportedApps` so the info @@ -371,17 +313,14 @@ class HomeViewModel( } /** - * Refresh patches if a different version was selected. - * Called when returning to HomeScreen from PatchesScreen. + * Refresh patches if any source's pinned version was changed (e.g. via + * PatchesScreen). Called when returning to HomeScreen from another screen. */ fun refreshPatchesIfNeeded() { screenModelScope.launch { - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion - - // If saved version differs from currently loaded version, reload - if (savedVersion != null && savedVersion != lastLoadedVersion) { - Logger.info("Patches version changed: $lastLoadedVersion -> $savedVersion, reloading...") + val saved = configRepository.getLastPatchesVersionsBySource() + if (saved != lastLoadedVersionsBySource) { + Logger.info("Patches versions changed across sources: $lastLoadedVersionsBySource -> $saved, reloading...") loadPatchesAndSupportedApps(forceRefresh = true) } } @@ -508,72 +447,206 @@ class HomeViewModel( } return try { - ApkFile(apkToParse).use { apk -> - val meta = apk.apkMeta - - val packageName = meta.packageName - val versionName = meta.versionName ?: "Unknown" - val minSdk = meta.minSdkVersion?.toIntOrNull() - - // Check if package is supported - first check dynamic, then fallback to hardcoded - val dynamicSupportedApp = _uiState.value.supportedApps.find { it.packageName == packageName } - val isSupported = dynamicSupportedApp != null || - packageName in listOf( - app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME, - app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME - ) + // ARSCLib reader (in engine) — same library morphe-patcher uses. + // Handles split APKs cleanly because we only read direct string + // attributes (no resource resolution that crashes apk-parser on + // cross-split references). + val manifest = ApkManifestReader.read(apkToParse) + ?: throw IllegalStateException("ARSCLib couldn't read manifest") + + val packageName = manifest.packageName + val versionName = manifest.versionName ?: "Unknown" + val minSdk = manifest.minSdkVersion + + // Check if package is supported — first check dynamic, then fall back to hardcoded. + val dynamicSupportedApp = _uiState.value.supportedApps.find { it.packageName == packageName } + val isSupported = dynamicSupportedApp != null || + packageName in listOf( + app.morphe.gui.data.constants.AppConstants.YouTube.PACKAGE_NAME, + app.morphe.gui.data.constants.AppConstants.YouTubeMusic.PACKAGE_NAME + ) - if (!isSupported) { - Logger.warn("Unsupported package: $packageName — no compatible patches found") - } + if (!isSupported) { + Logger.warn("Unsupported package: $packageName — no compatible patches found") + } - // Get app display name - prefer dynamic, fallback to hardcoded, then package name - val appName = dynamicSupportedApp?.displayName - ?: SupportedApp.resolveDisplayName(packageName, meta.label) + // Display name: prefer supported app's name. Fall back to ARSCLib's + // literal label (null for resource-referenced labels like SoundCloud's + // `@string/app_name`). Last resort: derived from package. + val appName = dynamicSupportedApp?.displayName + ?: SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) - // Resolve the version against the supported app's stable + - // experimental version lists. - val versionResolution = if (dynamicSupportedApp != null) { - app.morphe.gui.util.resolveVersionStatus(versionName, dynamicSupportedApp) - } else { - app.morphe.gui.util.VersionResolution(VersionStatus.UNKNOWN, null) - } - val suggestedVersion = versionResolution.suggestedVersion - val versionStatus = versionResolution.status - - // Get supported architectures from native libraries - // For split bundles, scan the original bundle (splits contain the native libs, not base.apk) - val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) - - // TODO: Re-enable when checksums are provided via .mpp files - val checksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured - - Logger.info("Parsed APK: $packageName v$versionName (recommended=$suggestedVersion, minSdk=$minSdk, archs=$architectures)") - - ApkInfo( - fileName = file.name, - filePath = file.absolutePath, - fileSize = file.length(), - formattedSize = formatFileSize(file.length()), - appName = appName, - packageName = packageName, - versionName = versionName, - architectures = architectures, - minSdk = minSdk, - suggestedVersion = suggestedVersion, - versionStatus = versionStatus, - checksumStatus = checksumStatus, - isUnsupportedApp = !isSupported - ) + val versionResolution = if (dynamicSupportedApp != null) { + app.morphe.gui.util.resolveVersionStatus(versionName, dynamicSupportedApp) + } else { + app.morphe.gui.util.VersionResolution(VersionStatus.UNKNOWN, null) } + val suggestedVersion = versionResolution.suggestedVersion + val versionStatus = versionResolution.status + + // Get supported architectures from native libraries. + // For split bundles, scan the original bundle (splits hold native libs, not base.apk). + val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) + + // TODO: Re-enable when checksums are provided via .mpp files + val checksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured + + Logger.info("Parsed APK: $packageName v$versionName (recommended=$suggestedVersion, minSdk=$minSdk, archs=$architectures)") + + ApkInfo( + fileName = file.name, + filePath = file.absolutePath, + fileSize = file.length(), + formattedSize = formatFileSize(file.length()), + appName = appName, + packageName = packageName, + versionName = versionName, + architectures = architectures, + minSdk = minSdk, + suggestedVersion = suggestedVersion, + versionStatus = versionStatus, + checksumStatus = checksumStatus, + isUnsupportedApp = !isSupported + ) } catch (e: Exception) { - Logger.error("Failed to parse APK manifest", e) - null + // apk-parser commonly chokes on split-APK base.apks whose resource + // references point into other splits (SoundCloud and similar). The + // base.apk is structurally valid — Android installs it fine, the + // patcher merges + patches it fine — but apk-parser can't resolve + // cross-split references from an isolated file. + // + // Fall back to a "limited info" parse: extract package/version from + // the filename (APKMirror naming convention), fuzzy-match supported + // apps by display name, and let the user proceed to patching + // regardless. ApkInfo.hasLimitedInfo=true so the UI can warn that + // card details may be approximate. + Logger.warn( + "Full APK manifest parse failed for ${file.name}: ${e.message}. " + + "Falling back to limited-info mode (filename heuristics + fuzzy match)." + ) + parseApkManifestMinimal(file, isBundleFormat) } finally { if (isBundleFormat) apkToParse.delete() } } + /** + * Fallback parser when full manifest parsing fails (typically split APKs with + * cross-split resource references). Recovers what it can from the filename and + * the bundle's native libs, fuzzy-matches against the supported-apps list, and + * sets [ApkInfo.hasLimitedInfo] = true so the UI can warn the user. + * + * Patching still works regardless — the patcher merges splits first and reads + * the manifest from the merged APK via its own (working) reader. + */ + private fun parseApkManifestMinimal(file: File, isBundleFormat: Boolean): ApkInfo? { + val (packageFromName, versionFromName) = parseFromApkMirrorFilename(file.name) + val supportedApps = _uiState.value.supportedApps + + // Match against supported apps: by exact package first, then fuzzy name + // on the filename's leading token (handles "soundcloud_..." → "SoundCloud"). + val matched = packageFromName + ?.let { pkg -> supportedApps.firstOrNull { it.packageName == pkg } } + ?: fuzzyMatchSupportedApp(file.name, supportedApps) + + val packageName = packageFromName ?: matched?.packageName.orEmpty() + val displayName = matched?.displayName + ?: packageFromName?.substringAfterLast('.', "") + ?.replaceFirstChar { it.uppercase() } + ?.takeIf { it.isNotBlank() } + ?: file.nameWithoutExtension + + val versionResolution = if (matched != null && versionFromName != null) { + app.morphe.gui.util.resolveVersionStatus(versionFromName, matched) + } else { + app.morphe.gui.util.VersionResolution(VersionStatus.UNKNOWN, null) + } + + // Architectures scan is independent of manifest parsing — still reliable. + val architectures = FileUtils.extractArchitectures(file) + + Logger.info( + "Limited-info parse for ${file.name}: package=$packageName, " + + "version=${versionFromName ?: "unknown"}, matched=${matched?.displayName ?: "none"}" + ) + + return ApkInfo( + fileName = file.name, + filePath = file.absolutePath, + fileSize = file.length(), + formattedSize = formatFileSize(file.length()), + appName = displayName, + packageName = packageName, + versionName = versionFromName ?: "Unknown", + architectures = architectures, + minSdk = null, + suggestedVersion = versionResolution.suggestedVersion, + versionStatus = versionResolution.status, + checksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured, + isUnsupportedApp = matched == null, + hasLimitedInfo = true, + ) + } + + /** + * Best-effort package + version extraction from APKMirror-style filenames: + * com.google.android.youtube_19.20.30-12345.apk + * → ("com.google.android.youtube", "19.20.30") + * + * Returns (null, null) when the filename doesn't look like a package_version + * pattern. The version-only path also tries a generic semver / date regex + * against the whole filename for files like `soundcloud_2026.04.27.apkm`. + */ + private fun parseFromApkMirrorFilename(filename: String): Pair { + val noExt = filename.substringBeforeLast('.') + val splitOnUnderscore = noExt.split('_', limit = 2) + + val packageCandidate = splitOnUnderscore.getOrNull(0) + val afterUnderscore = splitOnUnderscore.getOrNull(1) + + // A package name has at least one dot + only lowercase/digits/underscore in + // each segment. Filters out "soundcloud" while accepting "com.foo.bar". + val looksLikePackage = packageCandidate != null && + packageCandidate.contains('.') && + packageCandidate.split('.').all { segment -> + segment.isNotEmpty() && segment.all { c -> c.isLowerCase() || c.isDigit() || c == '_' } + } + + val packageName = if (looksLikePackage) packageCandidate else null + + // Version: prefer the token right after "_" (APKMirror convention), else + // scan the whole filename for a semver / date pattern. + val versionAfterUnderscore = afterUnderscore?.substringBefore('-')?.takeIf { it.isNotBlank() } + val version = versionAfterUnderscore + ?: Regex("""\d+\.\d+\.\d+(?:-dev\.\d+)?""").find(noExt)?.value + ?: Regex("""\d+\.\d+(?:\.\d+)?""").find(noExt)?.value + + return packageName to version + } + + /** + * Fuzzy-match the filename's leading token against supported apps' display names. + * Used when APKMirror-style filename inference fails to give us a package name. + * Examples: + * "soundcloud_2026.04.27.apkm" → leading token "soundcloud" → matches "SoundCloud" + * "YouTube Music_4.81.apkm" → leading token "youtube music" → matches "YouTube Music" + */ + private fun fuzzyMatchSupportedApp( + filename: String, + supportedApps: List, + ): app.morphe.gui.data.model.SupportedApp? { + val noExt = filename.substringBeforeLast('.').lowercase() + val leadingToken = noExt + .substringBefore('_') + .substringBefore('-') + .replace(" ", "") + if (leadingToken.isBlank()) return null + return supportedApps.firstOrNull { app -> + val name = app.displayName.lowercase().replace(" ", "") + name == leadingToken || name.startsWith(leadingToken) || leadingToken.startsWith(name) + } + } + // TODO: Re-enable checksum verification when checksums are provided via .mpp files // private fun verifyChecksum( // file: File, packageName: String, version: String, @@ -613,6 +686,9 @@ data class HomeUiState( val dismissedUpdateVersion: String? = null, /** Session-only dismiss; cleared on next app start. Not persisted. */ val updateBannerSessionDismissed: Boolean = false, + /** True when more than one source is enabled and the user hasn't dismissed + * the one-time multi-source intro hint yet. */ + val showMultiSourceHint: Boolean = false, ) { /** * Show the update banner only when an update was found AND the user hasn't @@ -655,7 +731,12 @@ data class ApkInfo( val suggestedVersion: String? = null, val versionStatus: VersionStatus = VersionStatus.UNKNOWN, val checksumStatus: app.morphe.gui.util.ChecksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured, - val isUnsupportedApp: Boolean = false + val isUnsupportedApp: Boolean = false, + /** True when full manifest parsing failed and we fell back to filename heuristics + * + fuzzy supported-app matching. Most fields are still populated but may be + * less accurate. UI should surface a banner letting the user know they can + * still proceed but card info is approximate. */ + val hasLimitedInfo: Boolean = false ) data class ApkValidationResult( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt index 3702001c..c28896a2 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* @@ -45,7 +46,10 @@ import app.morphe.gui.util.toColor fun ApkInfoCard( apkInfo: ApkInfo, onClearClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + /** Names of enabled sources whose patches target [apkInfo.packageName]. When + * more than one, surfaces the multi-source provenance directly on the card. */ + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -155,6 +159,46 @@ fun ApkInfoCard( } } + // ── Limited-info warning ── + // Surfaced when full manifest parsing failed (typically split APKs + // like SoundCloud where base.apk references resources living in + // other splits). Patching still works because the patcher merges + // splits first — this banner just tells the user the card details + // are approximate. + if (apkInfo.hasLimitedInfo) { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .background(accents.warning.copy(alpha = 0.06f)) + .padding(start = 23.dp, end = 20.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = accents.warning, + modifier = Modifier.size(16.dp) + ) + Text( + text = + "Couldn't fully read this APK's manifest (common for split bundles). " + + "Details below are approximate, patching should still work.", + fontSize = 11.sp, + color = accents.warning, + lineHeight = 14.sp, + ) + } + } + // ── Unsupported app warning ── if (apkInfo.isUnsupportedApp) { Row( @@ -286,6 +330,66 @@ fun ApkInfoCard( } } + // ── Patch sources providing patches for this app ── + if (patchSourceNames.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .padding(start = 23.dp, end = 20.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "FROM", + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.width(4.dp)) + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + patchSourceNames.forEach { name -> + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small), + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small), + ) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + text = name, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 0.3.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + } + } + } + // ── Status bar ── val statusDisplay = resolveVersionStatusDisplay( apkInfo.versionStatus, apkInfo.checksumStatus, apkInfo.suggestedVersion diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt new file mode 100644 index 00000000..e3ee6afe --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt @@ -0,0 +1,466 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.screens.home.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.SupportedApp +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects + +/** + * Vertical-list-friendly supported-app row. Two-row collapsed layout: + * row 1: initial badge + app name + package name (muted) + * row 2: STABLE LATEST chip + EXPERIMENTAL LATEST chip (or "—") + * + * Whole row is clickable (Phase 3 hooks expansion to it). Version chips are + * also tappable as quick-download shortcuts — their clicks are consumed so + * they don't bubble up and trigger the row click. + */ +@Composable +fun SupportedAppListRow( + app: SupportedApp, + onClick: () -> Unit = {}, + isExpanded: Boolean = false, + /** Source display names whose patches target [app.packageName]. Rendered as + * the FROM chips inside the expanded body. Empty hides the FROM section. */ + patchSourceNames: List = emptyList(), + modifier: Modifier = Modifier, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hoverInteraction = remember(app.packageName) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + val borderColor by animateColorAsState( + targetValue = when { + isExpanded -> accents.primary.copy(alpha = 0.45f) + isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + animationSpec = tween(150), + label = "rowBorder", + ) + val backgroundColor by animateColorAsState( + targetValue = when { + isExpanded -> accents.primary.copy(alpha = 0.05f) + isHovered -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + else -> MaterialTheme.colorScheme.surface + }, + animationSpec = tween(150), + label = "rowBg", + ) + + val initial = app.displayName.firstOrNull()?.uppercase() ?: "?" + val hasStable = app.recommendedVersion != null + val hasExperimental = app.experimentalVersions.isNotEmpty() + val latestExperimental = app.experimentalVersions.firstOrNull() + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(backgroundColor) + .hoverable(hoverInteraction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // ── Row 1: initial + name + package ── + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accents.primary.copy(alpha = 0.35f), RoundedCornerShape(corners.small)) + .background(accents.primary.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = initial, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + ) + } + Spacer(Modifier.width(10.dp)) + Text( + text = app.displayName, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = app.packageName, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + // ── Row 2: STABLE LATEST + EXPERIMENTAL LATEST chips ── + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + VersionChip( + channelLabel = "STABLE LATEST", + version = app.recommendedVersion, + color = accents.secondary, + // Pass the URL through unconditionally — when recommendedVersion + // is null (patches work on Any version), the URL still points to + // the app's general APKMirror page and stays clickable. + downloadUrl = app.apkDownloadUrl, + nullLabel = "Any", + mono = mono, + cornerSmall = corners.small, + ) + VersionChip( + channelLabel = "EXPERIMENTAL LATEST", + version = latestExperimental, + color = accents.warning, + downloadUrl = app.experimentalDownloadUrl, + nullLabel = "—", + mono = mono, + cornerSmall = corners.small, + ) + } + + // ── Expanded body: PATCHES FROM + ALSO STABLE + EXPERIMENTAL pills ── + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(animationSpec = tween(220), expandFrom = Alignment.Top) + + fadeIn(animationSpec = tween(180)), + exit = shrinkVertically(animationSpec = tween(180), shrinkTowards = Alignment.Top) + + fadeOut(animationSpec = tween(120)), + ) { + ExpandedBody( + app = app, + patchSourceNames = patchSourceNames, + accents = accents, + mono = mono, + cornerSmall = corners.small, + ) + } + } +} + +/** + * Channel label + version pair. When [downloadUrl] is non-null and [version] is + * present, the chip becomes a clickable quick-download (with hand cursor + open- + * in-new icon). When [version] is null, renders "—" in a muted style with no + * click affordance. + * + * The chip's clickable consumes the press — clicking it does NOT bubble up to + * the row's clickable, so quick-downloading doesn't accidentally expand the row. + */ +@Composable +private fun VersionChip( + channelLabel: String, + version: String?, + color: Color, + downloadUrl: String?, + nullLabel: String, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, +) { + // A chip is a clickable download link whenever the URL is present, even if + // the version is null ("Any" label still routes to the app's general page). + val isLink = downloadUrl != null + val uriHandler = LocalUriHandler.current + val hoverInteraction = remember(channelLabel) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + targetValue = when { + isLink && isHovered -> color.copy(alpha = 0.55f) + isLink -> color.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + }, + animationSpec = tween(150), + label = "chipBorder", + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(RoundedCornerShape(cornerSmall)) + .border(1.dp, borderColor, RoundedCornerShape(cornerSmall)) + .background( + if (isLink) color.copy(alpha = 0.06f) + else Color.Transparent + ) + .hoverable(hoverInteraction) + .then( + if (isLink) Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + openUrlAndFollowRedirects(downloadUrl!!) { uriHandler.openUri(it) } + } + else Modifier + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + text = channelLabel, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.6.sp, + color = if (isLink) color + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Text( + text = "·", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + ) + Text( + text = version?.let { if (it.startsWith("v")) it else "v$it" } ?: nullLabel, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = if (isLink) color + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), + ) + if (isLink) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Download $channelLabel", + tint = color, + modifier = Modifier.size(10.dp), + ) + } + } +} + +/** Body that drops down below the collapsed row when [SupportedAppListRow.isExpanded] + * is true. Sections: PATCHES FROM, ALSO STABLE, EXPERIMENTAL. */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun ExpandedBody( + app: SupportedApp, + patchSourceNames: List, + accents: app.morphe.gui.ui.theme.MorpheAccentColors, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, +) { + // "Other stable" = supported versions other than the recommended latest. + val otherStable = app.supportedVersions.filter { it != app.recommendedVersion } + val maxPills = 16 + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (patchSourceNames.isNotEmpty()) { + SectionLabel(text = "PATCHES FROM", color = accents.primary, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + patchSourceNames.forEach { name -> + // Source pills use a bright near-white label (vs. the colored + // text used by version pills below) so the source name reads + // crisply without feeling dimmed. The accent still shows in + // the border / subtle background tint. + Pill( + text = name, + color = accents.primary, + mono = mono, + cornerSmall = cornerSmall, + textColor = MaterialTheme.colorScheme.onSurface, + borderAlpha = 0.45f, + backgroundAlpha = 0.10f, + ) + } + } + } + + if (otherStable.isNotEmpty()) { + SectionLabel(text = "ALSO STABLE", color = accents.secondary, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + otherStable.take(maxPills).forEach { v -> + // URL is a pure function of package + version — compute + // per pill rather than pre-storing all of them on the model. + val url = remember(v) { SupportedApp.getDownloadUrl(app.packageName, v) } + Pill( + text = v, + color = accents.secondary, + mono = mono, + cornerSmall = cornerSmall, + onClick = url?.let { { uriHandler.openUri(it) } }, + ) + } + if (otherStable.size > maxPills) { + Text( + text = "+${otherStable.size - maxPills}", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + } + } + + if (app.experimentalVersions.isNotEmpty()) { + SectionLabel(text = "EXPERIMENTAL", color = accents.warning, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + app.experimentalVersions.take(maxPills).forEach { v -> + val url = remember(v) { SupportedApp.getDownloadUrl(app.packageName, v) } + Pill( + text = v, + color = accents.warning, + mono = mono, + cornerSmall = cornerSmall, + onClick = url?.let { { uriHandler.openUri(it) } }, + ) + } + if (app.experimentalVersions.size > maxPills) { + Text( + text = "+${app.experimentalVersions.size - maxPills}", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + } + } + } +} + +@Composable +private fun SectionLabel( + text: String, + color: Color, + mono: androidx.compose.ui.text.font.FontFamily, +) { + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.2.sp, + color = color.copy(alpha = 0.85f), + ) +} + +@Composable +private fun Pill( + text: String, + color: Color, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, + textColor: Color = color, + borderAlpha: Float = 0.3f, + backgroundAlpha: Float = 0.06f, + // When non-null, the pill becomes a tappable download link: gets a hand + // cursor, an OpenInNew icon, a subtle hover lift, and fires onClick on tap. + // detectTapGestures (not .clickable) so scroll wheel / two-finger gestures + // pass through on Linux/Skiko — same reason as the apps-cards Row. + onClick: (() -> Unit)? = null, +) { + val hoverSource = remember { MutableInteractionSource() } + val isHovered by hoverSource.collectIsHoveredAsState() + val isInteractive = onClick != null + val hoveredLift = if (isInteractive && isHovered) 0.20f else 0f + val effectiveBorderAlpha = (borderAlpha + hoveredLift).coerceAtMost(0.85f) + val effectiveBackgroundAlpha = (backgroundAlpha + hoveredLift / 2f).coerceAtMost(0.30f) + + Box( + modifier = Modifier + .hoverable(hoverSource) + .then( + if (isInteractive) Modifier + .pointerHoverIcon(PointerIcon.Hand) + .pointerInput(onClick) { + detectTapGestures(onTap = { onClick?.invoke() }) + } + else Modifier + ) + .border(1.dp, color.copy(alpha = effectiveBorderAlpha), RoundedCornerShape(cornerSmall)) + .background(color.copy(alpha = effectiveBackgroundAlpha), RoundedCornerShape(cornerSmall)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = textColor, + maxLines = 1, + ) + if (isInteractive) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open download page", + tint = textColor.copy(alpha = if (isHovered) 0.9f else 0.5f), + modifier = Modifier.size(9.dp), + ) + } + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt index 91345b5f..9f7bbc59 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -79,15 +79,25 @@ import java.io.File data class PatchSelectionScreen( val apkPath: String, val apkName: String, + /** Primary .mpp file path. Always non-null. In multi-source mode, the first + * enabled source's file. Used for legacy/single-source code paths and as + * the default when [patchesFilePaths] is empty. */ val patchesFilePath: String, val packageName: String, - val apkArchitectures: List = emptyList() + val apkArchitectures: List = emptyList(), + /** All enabled-source .mpp file paths. Single-element in single-source mode. + * Used by the patching pipeline to feed the engine the union of patches. */ + val patchesFilePaths: List = emptyList(), + /** Parallel to [patchesFilePaths] — display name per source. Drives badging + * in the patch list. Empty disables badging (legacy single-source). */ + val patchSourceNames: List = emptyList(), ) : Screen { @Composable override fun Content() { + val effectiveList = patchesFilePaths.takeIf { it.isNotEmpty() } ?: listOf(patchesFilePath) val viewModel = koinScreenModel { - parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures) + parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures, effectiveList, patchSourceNames) } PatchSelectionScreenContent(viewModel = viewModel) } @@ -110,7 +120,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { var keystoreEntryPassword by remember { mutableStateOf(null) } LaunchedEffect(Unit) { val config = configRepository.loadConfig() - keystorePath = config.keystorePath + keystorePath = config.resolvedKeystorePath()?.absolutePath keystorePassword = config.keystorePassword keystoreAlias = config.keystoreAlias keystoreEntryPassword = config.keystoreEntryPassword @@ -350,19 +360,34 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) ) - // Selection-mode chips: Your Defaults · Patch Defaults · All · None + // Global selection-mode chips: only meaningful when there's exactly + // ONE bundle. Multi-bundle moves these chips INTO each bundle box + // so each source can be managed independently. The deprecated + // applySaved/applyDefaults/selectAll/deselectAll methods loop over + // bundles — for a single bundle, they're equivalent to the per- + // bundle methods. + val isSingleBundle = uiState.bundles.size == 1 AnimatedVisibility( - visible = !uiState.isLoading && uiState.allPatches.isNotEmpty(), + visible = !uiState.isLoading && isSingleBundle && uiState.bundles.firstOrNull()?.patches?.isNotEmpty() == true, enter = expandVertically(), exit = shrinkVertically() ) { + val activeBundleId = uiState.bundles.firstOrNull()?.bundleId SelectionModeChips( hasSavedSelection = uiState.hasSavedSelection, - activeMode = uiState.activeSelectionMode, - onApplySaved = { viewModel.applySavedDefaults() }, - onApplyDefaults = { viewModel.applyPatchDefaults() }, - onApplyAll = { viewModel.selectAll() }, - onApplyNone = { viewModel.deselectAll() }, + activeMode = activeBundleId?.let { uiState.selectionModeFor(it) } ?: SelectionMode.CUSTOM, + onApplySaved = { + activeBundleId?.let { viewModel.applySavedDefaultsInBundle(it) } + }, + onApplyDefaults = { + activeBundleId?.let { viewModel.applyPatchDefaultsInBundle(it) } + }, + onApplyAll = { + activeBundleId?.let { viewModel.selectAllInBundle(it) } + }, + onApplyNone = { + activeBundleId?.let { viewModel.deselectAllInBundle(it) } + }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) ) } @@ -394,14 +419,44 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } } - uiState.filteredPatches.isEmpty() && !uiState.isLoading -> { + // Global empty state — when EVERY loaded bundle has zero patches + // compatible with this APK. None of the enabled sources contribute + // anything for this app's package; rendering empty bundle boxes + // would be pure noise. + !uiState.isLoading && uiState.bundles.all { it.patches.isEmpty() } -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Text( - text = if (uiState.searchQuery.isNotBlank()) "No patches match your search" - else "No patches found", + text = if (uiState.bundles.isEmpty()) "No patches found" + else "None of your enabled sources have patches for this app", + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } + + // Global "no matches for search" empty state — only fires when + // EVERY bundle that HAS patches has been filtered to empty by + // the active search. Bundles with 0 patches for this app are + // hidden separately above, so we only consider non-empty sources. + uiState.searchQuery.isNotBlank() && run { + val nonEmptySourceIds = uiState.bundles + .filter { it.patches.isNotEmpty() } + .map { it.bundleId }.toSet() + uiState.filteredBundles + .filter { it.bundleId in nonEmptySourceIds } + .all { it.patches.isEmpty() } + } -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No patches match your search", fontSize = 12.sp, fontFamily = mono, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) @@ -410,9 +465,16 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } else -> { - // Patch list + // Patch list — single-bundle renders flat (no box chrome), + // multi-bundle renders per-bundle collapsible boxes. val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() + // Expand/collapse state for multi-bundle, keyed by bundleId. + // Default: all bundles expanded. Uses plain `remember` — state + // resets if the user backs out and re-enters the screen, which + // is acceptable since "show me everything" is the right default. + val collapsedBundles = remember { mutableStateListOf() } + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { LazyColumn( state = lazyListState, @@ -420,9 +482,6 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - // Strip-libs status banner. Purely informational — the user - // configures their keep-list in Settings, and this banner - // reports what will happen to native libs for the current APK. val showBanner = uiState.stripLibsStatus !is StripLibsStatus.NoNativeLibs if (showBanner) { item(key = "strip_libs_banner") { @@ -430,21 +489,66 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } } - items( - items = uiState.filteredPatches, - key = { it.uniqueId } - ) { patch -> - PatchListItem( - patch = patch, - isSelected = uiState.selectedPatches.contains(patch.uniqueId), - onToggle = { viewModel.togglePatch(patch.uniqueId) }, - getOptionValue = { optionKey, default -> - viewModel.getOptionValue(patch.name, optionKey, default) - }, - onOptionValueChange = { optionKey, value -> - viewModel.setOptionValue(patch.name, optionKey, value) + if (isSingleBundle) { + // ── Flat rendering (single bundle, no chrome) ── + val bundle = uiState.filteredBundles.firstOrNull() ?: return@LazyColumn + val bundleId = bundle.bundleId + val selectedInBundle = uiState.selectedByBundle[bundleId].orEmpty() + items( + items = bundle.patches, + key = { it.uniqueId } + ) { patch -> + PatchListItem( + patch = patch, + isSelected = selectedInBundle.contains(patch.uniqueId), + onToggle = { viewModel.togglePatch(bundleId, patch.uniqueId) }, + sourceName = null, + getOptionValue = { optionKey, default -> + viewModel.getOptionValue(patch.name, optionKey, default) + }, + onOptionValueChange = { optionKey, value -> + viewModel.setOptionValue(patch.name, optionKey, value) + } + ) + } + } else { + // ── Per-bundle collapsible boxes (multi-bundle) ── + // Hide bundles whose pre-filter patches list is empty + // (i.e. the bundle has NO patches compatible with this + // APK at all). Bundles that loaded patches but are + // currently empty due to an active search still + // render — their box shows "no matches in this bundle". + val bundlesById = uiState.bundles.associateBy { it.bundleId } + val visibleBundles = uiState.filteredBundles.filter { fb -> + bundlesById[fb.bundleId]?.patches?.isNotEmpty() == true + } + visibleBundles.forEach { bundle -> + item(key = "bundle-${bundle.bundleId}") { + BundleBox( + bundle = bundle, + selectedInBundle = uiState.selectedByBundle[bundle.bundleId].orEmpty(), + selectionMode = uiState.selectionModeFor(bundle.bundleId), + hasSavedForBundle = uiState.savedSelectedByBundle?.containsKey(bundle.bundleId) == true, + expanded = bundle.bundleId !in collapsedBundles, + searchActive = uiState.searchQuery.isNotBlank(), + onExpandToggle = { + if (bundle.bundleId in collapsedBundles) collapsedBundles.remove(bundle.bundleId) + else collapsedBundles.add(bundle.bundleId) + }, + onTogglePatch = { patchId -> viewModel.togglePatch(bundle.bundleId, patchId) }, + onSelectAll = { viewModel.selectAllInBundle(bundle.bundleId) }, + onDeselectAll = { viewModel.deselectAllInBundle(bundle.bundleId) }, + onApplyDefaults = { viewModel.applyPatchDefaultsInBundle(bundle.bundleId) }, + onApplySaved = { viewModel.applySavedDefaultsInBundle(bundle.bundleId) }, + getOptionValue = { patchName, optionKey, default -> + viewModel.getOptionValue(patchName, optionKey, default) + }, + onOptionValueChange = { patchName, optionKey, value -> + viewModel.setOptionValue(patchName, optionKey, value) + }, + ) } - ) + } } } @@ -660,6 +764,7 @@ private fun PatchListItem( patch: Patch, isSelected: Boolean, onToggle: () -> Unit, + sourceName: String? = null, getOptionValue: (optionKey: String, default: String?) -> String = { _, d -> d ?: "" }, onOptionValueChange: (optionKey: String, value: String) -> Unit = { _, _ -> } ) { @@ -747,6 +852,32 @@ private fun PatchListItem( modifier = Modifier.weight(1f, fill = false) ) + if (sourceName != null) { + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small) + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = sourceName.uppercase(), + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + if (patch.compatiblePackages.isNotEmpty()) { val genericSegments = setOf("com", "org", "net", "android", "google", "apps", "app", "www") patch.compatiblePackages.take(2).forEach { pkg -> @@ -1604,3 +1735,152 @@ private data class BannerDisplay( val stripChips: List = emptyList(), val notInApkChips: List = emptyList() ) + +// ──────────────────────────────────────────────────────────────────────────── +// Per-bundle collapsible box (multi-bundle view) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Collapsible box containing one bundle's patches. Each box has its own + * header (bundle name, count, expand chevron, "Your Defaults" chip), + * per-bundle control buttons (Select all / Deselect / Defaults / Saved), + * and the patches list itself. + * + * In search-active state, the box stays visible even if [BundlePatches.patches] + * is empty — it renders a "no matches in this bundle" inline empty state so + * the structural grouping stays stable while the user iterates on the query. + */ +@Composable +private fun BundleBox( + bundle: BundlePatches, + selectedInBundle: Set, + selectionMode: SelectionMode, + hasSavedForBundle: Boolean, + expanded: Boolean, + searchActive: Boolean, + onExpandToggle: () -> Unit, + onTogglePatch: (String) -> Unit, + onSelectAll: () -> Unit, + onDeselectAll: () -> Unit, + onApplyDefaults: () -> Unit, + onApplySaved: () -> Unit, + getOptionValue: (patchName: String, optionKey: String, default: String?) -> String, + onOptionValueChange: (patchName: String, optionKey: String, value: String) -> Unit, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + val enabledCount = selectedInBundle.size + val totalCount = bundle.patches.size + + val outlineColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.20f) + val bgColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.35f) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .background(bgColor) + .border(1.dp, outlineColor, RoundedCornerShape(corners.medium)) + ) { + // ── Header ── + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onExpandToggle() } + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Chevron + Text( + text = if (expanded) "▼" else "▶", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + fontFamily = mono, + ) + Text( + text = bundle.bundleName, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + // Count chip — "Your Defaults" badge lives in SelectionModeChips + // below so we don't duplicate the signal here. + Text( + text = "$enabledCount / $totalCount", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f), + letterSpacing = 0.5.sp, + ) + Spacer(Modifier.weight(1f)) + } + + // ── Body (controls + patches) ── + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Per-bundle control row — REUSES the same SelectionModeChips + // composable the single-bundle path uses, so icons, hover + // states, "Your Defaults" badge, and full-width layout match + // exactly. Callbacks scope each action to THIS bundle. + SelectionModeChips( + hasSavedSelection = hasSavedForBundle, + activeMode = selectionMode, + onApplySaved = onApplySaved, + onApplyDefaults = onApplyDefaults, + onApplyAll = onSelectAll, + onApplyNone = onDeselectAll, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + + // Patches inside this bundle. Note: this is a regular Column, + // NOT a LazyColumn — bundles aren't typically huge enough + // (tens of patches) to justify lazy rendering, and nesting + // LazyColumns inside a LazyColumn is unsupported. + if (bundle.patches.isEmpty() && searchActive) { + Text( + text = "No matches in this bundle", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + bundle.patches.forEach { patch -> + PatchListItem( + patch = patch, + isSelected = selectedInBundle.contains(patch.uniqueId), + onToggle = { onTogglePatch(patch.uniqueId) }, + // Bundle context is implicit from the box header + sourceName = null, + getOptionValue = { optionKey, default -> + getOptionValue(patch.name, optionKey, default) + }, + onOptionValueChange = { optionKey, value -> + onOptionValueChange(patch.name, optionKey, value) + }, + ) + } + } + } + } + } + } +} + diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt index ce2e1e3e..5ec69c7b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -22,8 +22,30 @@ import app.morphe.gui.util.PatchService import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.util.FileUtils.ANDROID_ARCHITECTURES import app.morphe.patcher.resource.CpuArchitecture +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import java.io.File +/** + * Per-bundle view of one source's contribution to the patches-selection screen. + * + * - [bundleId] is an internal handle stable for the screen lifetime; the screen + * uses it as a map key for selection state and the LazyColumn item key. + * - [bundleName] is the display label AND the persistence key (matches the + * `sourceName` slot inside [PatchPreferencesRepository]). Renaming a source + * carries its saved selection with it. + * - [patches] holds the patches from this bundle ALONE — no cross-bundle dedup. + * When two sources ship an identical patch (same name/body/options), each + * bundle still has its own entry here, and the user toggles them + * independently. The patcher dedups at apply time so this doesn't double-apply. + */ +data class BundlePatches( + val bundleId: String, + val bundleName: String, + val patches: List, +) + class PatchSelectionViewModel( private val apkPath: String, private val apkName: String, @@ -35,19 +57,29 @@ class PatchSelectionViewModel( private val configRepository: ConfigRepository, private val preferencesRepository: PatchPreferencesRepository, private val patchSourceName: String, - private val localPatchFilePath: String? = null + private val localPatchFilePath: String? = null, + /** All enabled-source .mpp file paths. Single-element in single-source mode. */ + private val patchesFilePaths: List = listOf(patchesFilePath), + /** Parallel to [patchesFilePaths] — display name of each source. Used as the + * per-bundle label AND persistence key. */ + private val patchSourceNames: List = emptyList(), ) : ScreenModel { - // Actual path to use - may differ from patchesFilePath if we had to re-download + // Actual path to use for the primary file — may differ from patchesFilePath + // if we had to re-download (cache cleared, etc.) private var actualPatchesFilePath: String = patchesFilePath + // All resolved file paths — drives multi-source patching when invoking the engine. + private var actualPatchesFilePaths: List = patchesFilePaths // User-configured output folder; null means save next to the input APK. private var defaultOutputDirectory: String? = null - private val _uiState = MutableStateFlow(PatchSelectionUiState( - apkArchitectures = apkArchitectures, - stripLibsStatus = computeStripLibsStatus(apkArchitectures, ANDROID_ARCHITECTURES) - )) + private val _uiState = MutableStateFlow( + PatchSelectionUiState( + apkArchitectures = apkArchitectures, + stripLibsStatus = computeStripLibsStatus(apkArchitectures, ANDROID_ARCHITECTURES), + ) + ) val uiState: StateFlow = _uiState.asStateFlow() init { @@ -58,7 +90,9 @@ class PatchSelectionViewModel( private fun loadStripLibsPreference() { screenModelScope.launch { val config = configRepository.loadConfig() - defaultOutputDirectory = config.defaultOutputDirectory + // Store the resolved absolute path so the lookup at line ~487 can + // pass it straight into File(...) without re-resolving. + defaultOutputDirectory = config.resolvedDefaultOutputDirectory()?.absolutePath _uiState.value = _uiState.value.copy( stripLibsStatus = computeStripLibsStatus(apkArchitectures, config.keepArchitectures) ) @@ -67,6 +101,9 @@ class PatchSelectionViewModel( fun getApkPath(): String = apkPath fun getPatchesFilePath(): String = actualPatchesFilePath + fun getApkName(): String = apkName + + // ── Loading ────────────────────────────────────────────────────────────── fun loadPatches() { screenModelScope.launch { @@ -77,177 +114,277 @@ class PatchSelectionViewModel( if (!patchesFile.exists()) { Logger.info("Patches file not found at $patchesFilePath, attempting to download...") - // Try to extract version from the filename and find a matching release - // Filename format: morphe-patches-x.x.x.mpp or similar val downloadResult = downloadMissingPatches(patchesFile.name) if (downloadResult.isFailure) { _uiState.value = _uiState.value.copy( isLoading = false, - error = "Patches file missing and could not be downloaded: ${downloadResult.exceptionOrNull()?.message}" + error = "Patches file missing and could not be downloaded: ${downloadResult.exceptionOrNull()?.message}", ) return@launch } actualPatchesFilePath = downloadResult.getOrNull()!!.absolutePath + // Mirror the swap into the multi-paths list so the union load uses + // the freshly-downloaded file rather than the missing one. + actualPatchesFilePaths = actualPatchesFilePaths.map { + if (it == patchesFilePath) actualPatchesFilePath else it + } } - // Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(actualPatchesFilePath, packageName.ifEmpty { null }) + val patchesResult = loadFromAllPaths() patchesResult.fold( - onSuccess = { patches -> - // Deduplicate by uniqueId in case of true duplicates - val deduplicatedPatches = patches.distinctBy { it.uniqueId } - - Logger.info("Loaded ${deduplicatedPatches.size} patches for $packageName") - - // Compute the .mpp's default selection (patches with use=true) - val defaultSelected = deduplicatedPatches - .filter { it.isEnabled } - .map { it.uniqueId } - .toSet() - - // If a saved selection exists for this source+package, silently apply it. - // Otherwise fall back to .mpp defaults. - val savedBundle = preferencesRepository.get(patchSourceName, packageName) - val (initialSelected, savedOptions) = if (savedBundle != null) { - val byName = deduplicatedPatches.associateBy { it.name } - val selected = savedBundle.patches - .filter { (_, entry) -> entry.enabled } - .keys - .mapNotNull { byName[it]?.uniqueId } + onSuccess = { bundles -> + Logger.info( + "Loaded ${bundles.size} bundle(s), " + + "${bundles.sumOf { it.patches.size }} total patches for $packageName" + ) + + // For each bundle, derive its default selection (use=true) and + // its saved selection (if any). Persistence is per-bundle + // keyed by bundleName — single load per bundle. + val defaultsByBundle = bundles.associate { bundle -> + bundle.bundleId to bundle.patches + .filter { it.isEnabled } + .map { it.uniqueId } .toSet() - // Materialize saved option values into the patchOptionValues map - // (which is keyed "patchName.optionKey" → string). - val opts = mutableMapOf() - for ((patchName, entry) in savedBundle.patches) { - for ((optKey, jsonValue) in entry.options) { - opts["$patchName.$optKey"] = jsonValue.toString().trim('"') + } + val savedByBundle = mutableMapOf>() + val initialOptions = mutableMapOf() + var anyBundleHasSaved = false + for (bundle in bundles) { + val saved = preferencesRepository.get(bundle.bundleName, packageName) + if (saved != null) { + anyBundleHasSaved = true + val byName = bundle.patches.associateBy { it.name } + val selected = saved.patches + .filter { (_, entry) -> entry.enabled } + .keys + .mapNotNull { byName[it]?.uniqueId } + .toSet() + savedByBundle[bundle.bundleId] = selected + // Materialize saved option values ("patchName.optionKey" → string). + // Options are per-patch-name so they're naturally global here; + // identical patches in two bundles share option values, which + // is fine — same option means same thing. + for ((patchName, entry) in saved.patches) { + for ((optKey, jsonValue) in entry.options) { + initialOptions["$patchName.$optKey"] = jsonValue.toString().trim('"') + } } } - Logger.info("Applied saved patch preferences for $patchSourceName / $packageName") - selected to opts.toMap() - } else { - defaultSelected to emptyMap() } - val savedSelectedIds: Set? = if (savedBundle != null) { - val byName = deduplicatedPatches.associateBy { it.name } - savedBundle.patches - .filter { (_, entry) -> entry.enabled } - .keys - .mapNotNull { byName[it]?.uniqueId } - .toSet() - } else null + // Initial selection for each bundle: saved if present, else .mpp defaults. + val initialSelectedByBundle = bundles.associate { bundle -> + bundle.bundleId to (savedByBundle[bundle.bundleId] + ?: defaultsByBundle[bundle.bundleId].orEmpty()) + } + + if (anyBundleHasSaved) { + Logger.info("Applied saved patch preferences for $packageName " + + "(${savedByBundle.size}/${bundles.size} bundle(s))") + } _uiState.value = _uiState.value.copy( isLoading = false, - allPatches = deduplicatedPatches, - filteredPatches = deduplicatedPatches, - selectedPatches = initialSelected, - patchOptionValues = savedOptions, - hasSavedSelection = savedBundle != null, - savedSelectedIds = savedSelectedIds + bundles = bundles, + filteredBundles = bundles, + selectedByBundle = initialSelectedByBundle, + savedSelectedByBundle = if (savedByBundle.isNotEmpty()) savedByBundle else null, + hasSavedSelection = anyBundleHasSaved, + patchOptionValues = initialOptions, ) }, onFailure = { e -> _uiState.value = _uiState.value.copy( isLoading = false, - error = "Failed to list patches: ${e.message}" + error = "Failed to list patches: ${e.message}", ) Logger.error("Failed to list patches", e) - } + }, ) } } + /** + * Load patches from every resolved enabled-source file in parallel. Returns + * one [BundlePatches] entry per source — NO cross-bundle dedup. Bundles + * whose load failed are dropped; the call fails only when ALL bundles fail. + */ + private suspend fun loadFromAllPaths(): Result> = coroutineScope { + val pkgFilter = packageName.ifEmpty { null } + val perFile = actualPatchesFilePaths.mapIndexed { idx, path -> + async { + val result = patchService.listPatches(path, pkgFilter) + Triple(idx, path, result) + } + }.awaitAll() + + val bundles: List = perFile.mapNotNull { (idx, path, result) -> + val patches = result.getOrNull() ?: return@mapNotNull null + val displayName = patchSourceNames.getOrNull(idx) + ?: File(path).nameWithoutExtension + BundlePatches( + bundleId = "bundle-$idx-${File(path).nameWithoutExtension}", + bundleName = displayName, + patches = patches, + ) + } + + if (bundles.isEmpty()) { + val firstError = perFile.firstNotNullOfOrNull { (_, _, r) -> r.exceptionOrNull() } + return@coroutineScope if (firstError != null) Result.failure(firstError) + else Result.success(emptyList()) + } + Result.success(bundles) + } + + // ── Legacy flat API (shims) ───────────────────────────────────────────── + // + // These shim methods keep the existing PatchSelectionScreen rendering + // compiling while the per-bundle UI is built out in a follow-up commit. + // Once the screen renders collapsible bundle boxes, these can be deleted. + // + // Behavior is best-effort: `togglePatch(patchId)` toggles in EVERY bundle + // that contains the patch (so old single-list UI matches old behavior: + // one click flips state everywhere). `selectAll`/`deselectAll`/etc. apply + // across all bundles in one go. + + @Deprecated("Per-bundle UI: use togglePatch(bundleId, patchId)") fun togglePatch(patchId: String) { - val current = _uiState.value.selectedPatches - val newSelection = if (current.contains(patchId)) { - current - patchId - } else { - current + patchId + val state = _uiState.value + val newMap = state.selectedByBundle.toMutableMap() + for (bundle in state.bundles) { + if (bundle.patches.none { it.uniqueId == patchId }) continue + val cur = newMap[bundle.bundleId].orEmpty() + newMap[bundle.bundleId] = if (patchId in cur) cur - patchId else cur + patchId } - _uiState.value = _uiState.value.copy(selectedPatches = newSelection) + _uiState.value = state.copy(selectedByBundle = newMap) } + @Deprecated("Per-bundle UI: use selectAllInBundle") fun selectAll() { - val allIds = _uiState.value.allPatches.map { it.uniqueId }.toSet() - _uiState.value = _uiState.value.copy(selectedPatches = allIds) + val state = _uiState.value + _uiState.value = state.copy( + selectedByBundle = state.bundles.associate { bundle -> + bundle.bundleId to bundle.patches.map { it.uniqueId }.toSet() + } + ) } + @Deprecated("Per-bundle UI: use deselectAllInBundle") fun deselectAll() { - _uiState.value = _uiState.value.copy(selectedPatches = emptySet()) + val state = _uiState.value + _uiState.value = state.copy( + selectedByBundle = state.bundles.associate { it.bundleId to emptySet() } + ) } - /** - * Reset the selection to the .mpp's per-patch `use=true/false` defaults. - */ + @Deprecated("Per-bundle UI: use applyPatchDefaultsInBundle") fun applyPatchDefaults() { - val defaults = _uiState.value.allPatches - .filter { it.isEnabled } - .map { it.uniqueId } - .toSet() - _uiState.value = _uiState.value.copy(selectedPatches = defaults) + val state = _uiState.value + _uiState.value = state.copy( + selectedByBundle = state.bundles.associate { bundle -> + bundle.bundleId to bundle.patches.filter { it.isEnabled } + .map { it.uniqueId }.toSet() + } + ) } - /** - * Apply the user's previously-saved selection for this source+package, if any. - * No-op if no saved selection exists. - */ + @Deprecated("Per-bundle UI: use applySavedDefaultsInBundle") fun applySavedDefaults() { - screenModelScope.launch { - val saved = preferencesRepository.get(patchSourceName, packageName) ?: return@launch - val byName = _uiState.value.allPatches.associateBy { it.name } - val selected = saved.patches - .filter { (_, entry) -> entry.enabled } - .keys - .mapNotNull { byName[it]?.uniqueId } - .toSet() - val opts = mutableMapOf() - for ((patchName, entry) in saved.patches) { - for ((optKey, jsonValue) in entry.options) { - opts["$patchName.$optKey"] = jsonValue.toString().trim('"') - } - } - _uiState.value = _uiState.value.copy( - selectedPatches = selected, - patchOptionValues = opts - ) - } + val saved = _uiState.value.savedSelectedByBundle ?: return + _uiState.value = _uiState.value.copy(selectedByBundle = saved) + } + + @Deprecated("Per-bundle UI: sourceName is implicit from the bundle context") + fun getSourceNameFor(patchId: String): String? { + val bundles = _uiState.value.bundles + if (bundles.size <= 1) return null + return bundles.firstOrNull { it.patches.any { p -> p.uniqueId == patchId } } + ?.bundleName } + // ── Per-bundle selection methods ──────────────────────────────────────── + + fun togglePatch(bundleId: String, patchId: String) { + val current = _uiState.value.selectedByBundle + val bundleSet = current[bundleId].orEmpty() + val newSet = if (patchId in bundleSet) bundleSet - patchId else bundleSet + patchId + _uiState.value = _uiState.value.copy( + selectedByBundle = current + (bundleId to newSet), + ) + } + + fun selectAllInBundle(bundleId: String) { + val bundle = _uiState.value.bundles.firstOrNull { it.bundleId == bundleId } ?: return + val all = bundle.patches.map { it.uniqueId }.toSet() + _uiState.value = _uiState.value.copy( + selectedByBundle = _uiState.value.selectedByBundle + (bundleId to all), + ) + } + + fun deselectAllInBundle(bundleId: String) { + _uiState.value = _uiState.value.copy( + selectedByBundle = _uiState.value.selectedByBundle + (bundleId to emptySet()), + ) + } + + /** Reset this bundle's selection to its .mpp `use=true/false` defaults. */ + fun applyPatchDefaultsInBundle(bundleId: String) { + val bundle = _uiState.value.bundles.firstOrNull { it.bundleId == bundleId } ?: return + val defaults = bundle.patches.filter { it.isEnabled }.map { it.uniqueId }.toSet() + _uiState.value = _uiState.value.copy( + selectedByBundle = _uiState.value.selectedByBundle + (bundleId to defaults), + ) + } + + /** Restore this bundle's saved selection. No-op if no saved state for this bundle. */ + fun applySavedDefaultsInBundle(bundleId: String) { + val bundle = _uiState.value.bundles.firstOrNull { it.bundleId == bundleId } ?: return + val saved = _uiState.value.savedSelectedByBundle?.get(bundleId) ?: return + _uiState.value = _uiState.value.copy( + selectedByBundle = _uiState.value.selectedByBundle + (bundleId to saved), + ) + } + + // ── Filter / search ───────────────────────────────────────────────────── + fun setSearchQuery(query: String) { - val filtered = if (query.isBlank()) { - _uiState.value.allPatches - } else { - _uiState.value.allPatches.filter { - it.name.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true) - } - } _uiState.value = _uiState.value.copy( searchQuery = query, - filteredPatches = filtered + filteredBundles = computeFilteredBundles(query, _uiState.value.showOnlySelected), ) } fun setShowOnlySelected(show: Boolean) { - val filtered = if (show) { - _uiState.value.allPatches.filter { _uiState.value.selectedPatches.contains(it.uniqueId) } - } else if (_uiState.value.searchQuery.isNotBlank()) { - _uiState.value.allPatches.filter { - it.name.contains(_uiState.value.searchQuery, ignoreCase = true) || - it.description.contains(_uiState.value.searchQuery, ignoreCase = true) - } - } else { - _uiState.value.allPatches - } _uiState.value = _uiState.value.copy( showOnlySelected = show, - filteredPatches = filtered + filteredBundles = computeFilteredBundles(_uiState.value.searchQuery, show), ) } + /** + * Per-bundle filter — preserves bundle grouping, so a bundle that has zero + * matches still appears in [PatchSelectionUiState.filteredBundles] with an + * empty `patches` list. The UI uses that to render the "no matches in this + * bundle" empty state inside the box. + */ + private fun computeFilteredBundles(query: String, showOnlySelected: Boolean): List { + val q = query.trim() + return _uiState.value.bundles.map { bundle -> + val selectedInBundle = _uiState.value.selectedByBundle[bundle.bundleId].orEmpty() + val patches = bundle.patches.filter { patch -> + val matchesSearch = q.isBlank() || + patch.name.contains(q, ignoreCase = true) || + patch.description.contains(q, ignoreCase = true) + val matchesSelection = !showOnlySelected || patch.uniqueId in selectedInBundle + matchesSearch && matchesSelection + } + bundle.copy(patches = patches) + } + } + fun clearError() { _uiState.value = _uiState.value.copy(error = null) } @@ -261,106 +398,103 @@ class PatchSelectionViewModel( } /** - * Set a patch option value. Key format: "patchName.optionKey" + * Set a patch option value. Key format: "patchName.optionKey". Options are + * keyed by patch name, so identical patches across bundles share option + * values — intentional, since the patch IS the same patch. */ fun setOptionValue(patchName: String, optionKey: String, value: String) { val key = "$patchName.$optionKey" val current = _uiState.value.patchOptionValues.toMutableMap() - if (value.isBlank()) { - current.remove(key) - } else { - current[key] = value - } + if (value.isBlank()) current.remove(key) else current[key] = value _uiState.value = _uiState.value.copy(patchOptionValues = current) } - /** - * Get a patch option value. Returns the user-set value, or the default if not set. - */ fun getOptionValue(patchName: String, optionKey: String, default: String?): String { val key = "$patchName.$optionKey" return _uiState.value.patchOptionValues[key] ?: default ?: "" } - /** - * Count of patches that are disabled by default (from .mpp metadata). - */ - fun getDefaultDisabledCount(): Int { - return _uiState.value.allPatches.count { !it.isEnabled } - } + /** Total count of patches across all bundles that ship disabled by default. */ + fun getDefaultDisabledCount(): Int = + _uiState.value.bundles.sumOf { bundle -> bundle.patches.count { !it.isEnabled } } + + // ── Persistence ───────────────────────────────────────────────────────── /** - * Persist the current selection + option values as the user's saved preference - * for this source+package. Called from createPatchConfig (auto-save on Patch click). + * Persist each bundle's current selection + option values under its own + * source name. Called from createPatchConfig (auto-save on Patch click). */ private fun saveCurrentSelection() { val state = _uiState.value - val enabledNames = state.allPatches - .filter { state.selectedPatches.contains(it.uniqueId) } - .map { it.name } - .toSet() - val disabledNames = state.allPatches - .filter { !state.selectedPatches.contains(it.uniqueId) } - .map { it.name } - .toSet() - - // Group "patchName.optionKey" -> JsonElement under each patch name. - val grouped = mutableMapOf>() + // Group "patchName.optionKey" -> JsonElement under each patch name, ONCE. + // (Option values are global by design — see setOptionValue.) + val groupedOptions = mutableMapOf>() for ((compoundKey, value) in state.patchOptionValues) { val dotIdx = compoundKey.indexOf('.') if (dotIdx <= 0) continue val patchName = compoundKey.substring(0, dotIdx) val optKey = compoundKey.substring(dotIdx + 1) - grouped.getOrPut(patchName) { mutableMapOf() }[optKey] = + groupedOptions.getOrPut(patchName) { mutableMapOf() }[optKey] = kotlinx.serialization.json.JsonPrimitive(value) } screenModelScope.launch { - preferencesRepository.save( - sourceName = patchSourceName, - packageName = packageName, - enabledPatchNames = enabledNames, - disabledPatchNames = disabledNames, - options = grouped - ) - // After saving, the live selection IS the saved selection — so update - // the snapshot so the "YOUR DEFAULTS" chip stays highlighted post-patch. + for (bundle in state.bundles) { + val selected = state.selectedByBundle[bundle.bundleId].orEmpty() + val enabledNames = bundle.patches + .filter { selected.contains(it.uniqueId) } + .map { it.name } + .toSet() + val disabledNames = bundle.patches + .filterNot { selected.contains(it.uniqueId) } + .map { it.name } + .toSet() + + // Only save options for patches actually in this bundle — avoids + // bleeding bundle A's option into bundle B's preferences. + val patchNamesInBundle = bundle.patches.mapNotNull { it.name }.toSet() + val scopedOptions = groupedOptions.filterKeys { it in patchNamesInBundle } + + preferencesRepository.save( + sourceName = bundle.bundleName, + packageName = packageName, + enabledPatchNames = enabledNames, + disabledPatchNames = disabledNames, + options = scopedOptions, + ) + } + + // After saving, the live selection IS the saved selection — refresh + // the snapshot so the per-bundle "Your Defaults" chips stay + // highlighted post-patch. _uiState.value = _uiState.value.copy( hasSavedSelection = true, - savedSelectedIds = state.selectedPatches + savedSelectedByBundle = state.selectedByBundle, ) } } + // ── Patcher integration ───────────────────────────────────────────────── + fun createPatchConfig(continueOnError: Boolean = false): PatchConfig { - // Auto-save the current selection as the user's "Your Defaults" before patching. saveCurrentSelection() + // Delegate to the shared engine helper — same path the CLI computes. + // Passing apkName as the display name preserves the friendly label + // (e.g. "Youtube") instead of falling back to the filename. val inputFile = File(apkPath) - val appFolderName = apkName.replace(" ", "-") - val baseOutputDir = defaultOutputDirectory?.let { File(it) } ?: inputFile.parentFile - val outputDir = File(baseOutputDir, appFolderName) - outputDir.mkdirs() + val outputPath = app.morphe.engine.util.ApkOutputNaming.outputApkPath( + inputApk = inputFile, + patchesFile = File(actualPatchesFilePath), + baseOutputDir = defaultOutputDirectory?.let { File(it) }, + appDisplayName = apkName, + ).absolutePath + + // Flatten across bundles: the engine takes a single flat enable/disable + // list and dedups identical patches at apply time, so the union of + // selected patches across bundles is the right input. + val (selectedPatchNames, disabledPatchNames) = flattenSelection() - // Extract version from APK filename and patches version for output name - val version = extractVersionFromFilename(inputFile.name) ?: "patched" - val patchesVersion = extractPatchesVersion(File(actualPatchesFilePath).name) - val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" - val outputFileName = "${appFolderName}-Morphe-${version}${patchesSuffix}.apk" - val outputPath = File(outputDir, outputFileName).absolutePath - - // Convert unique IDs back to patch names for CLI - val selectedPatchNames = _uiState.value.allPatches - .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } - .map { it.name } - - val disabledPatchNames = _uiState.value.allPatches - .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } - .map { it.name } - - // Only ship a non-empty keepArchitectures set when the current status actually - // prescribes stripping. All other states (no native libs, universal, keep-all, - // fallback) → empty set → patcher leaves native libs untouched. val keepArches = (uiState.value.stripLibsStatus as? StripLibsStatus.WillStrip) ?.keeping ?.mapNotNull { CpuArchitecture.valueOfOrNull(it) } @@ -370,33 +504,46 @@ class PatchSelectionViewModel( return PatchConfig( inputApkPath = apkPath, outputApkPath = outputPath, - patchesFilePath = actualPatchesFilePath, + patchesFilePaths = actualPatchesFilePaths, enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, patchOptions = _uiState.value.patchOptionValues, useExclusiveMode = true, keepArchitectures = keepArches, - continueOnError = continueOnError + continueOnError = continueOnError, ) } - private fun extractVersionFromFilename(fileName: String): String? { - // Extract version from APKMirror format: com.google.android.youtube_20.40.45-xxx - return try { - val afterPackage = fileName.substringAfter("_") - afterPackage.substringBefore("-").takeIf { it.isNotEmpty() } - } catch (e: Exception) { - null + /** + * Flatten per-bundle selection into the patcher's flat (enabled, disabled) + * pair of patch-name lists. `.distinct()` is belt-and-suspenders — the + * engine deduplicates again at apply time. + */ + private fun flattenSelection(): Pair, List> { + val state = _uiState.value + val selected = mutableSetOf() + val disabled = mutableSetOf() + for (bundle in state.bundles) { + val bundleSelected = state.selectedByBundle[bundle.bundleId].orEmpty() + for (patch in bundle.patches) { + if (patch.uniqueId in bundleSelected) selected.add(patch.name) + else disabled.add(patch.name) + } } + // A patch enabled in any bundle wins over its disabled-in-another counterpart + // (engine dedup means the patch is one entity at apply time). + disabled.removeAll(selected) + return selected.toList() to disabled.toList() } - private fun extractPatchesVersion(patchesFileName: String): String? { - // Extract version from patches filename: morphe-patches-1.13.0-dev.11.mpp -> 1.13.0-dev.11 - val regex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") - return regex.find(patchesFileName)?.groupValues?.get(1) - } + // Delegate to the shared engine helper so GUI and CLI agree on filename + // parsing. Returning these as instance methods (not direct calls) keeps + // existing call sites in this file unchanged. + private fun extractVersionFromFilename(fileName: String): String? = + app.morphe.engine.util.ApkOutputNaming.extractApkVersionFromFilename(fileName) - fun getApkName(): String = apkName + private fun extractPatchesVersion(patchesFileName: String): String? = + app.morphe.engine.util.ApkOutputNaming.extractPatchesVersion(patchesFileName) /** * Generate a preview of the CLI command that will be executed. @@ -408,7 +555,7 @@ class PatchSelectionViewModel( keystorePath: String? = null, keystorePassword: String? = null, keystoreAlias: String? = null, - keystoreEntryPassword: String? = null + keystoreEntryPassword: String? = null, ): String { val inputFile = File(apkPath) val patchesFile = File(actualPatchesFilePath) @@ -418,23 +565,13 @@ class PatchSelectionViewModel( val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" val outputFileName = "${appFolderName}-Morphe-${version}${patchesSuffix}.apk" - val selectedPatchNames = _uiState.value.allPatches - .filter { _uiState.value.selectedPatches.contains(it.uniqueId) } - .map { it.name } - - val disabledPatchNames = _uiState.value.allPatches - .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } - .map { it.name } + val (selectedPatchNames, disabledPatchNames) = flattenSelection() - // Use whichever produces fewer flags val useExclusive = selectedPatchNames.size <= disabledPatchNames.size - // striplibs flag: only when the computed status prescribes actual stripping val striplibsArg = (uiState.value.stripLibsStatus as? StripLibsStatus.WillStrip) - ?.keeping - ?.joinToString(",") + ?.keeping?.joinToString(",") - // Keystore flags (only if custom keystore is set) val hasCustomKeystore = keystorePath != null return if (cleanMode) { @@ -447,24 +584,12 @@ class PatchSelectionViewModel( --force \ """.trimIndent() ) - - if (continueOnError) { - appendLine(" --continue-on-error \\") - } - - if (useExclusive) { - appendLine(" --exclusive \\") - } - - striplibsArg?.let { - appendLine(" --striplibs $it \\") - } - + if (continueOnError) appendLine(" --continue-on-error \\") + if (useExclusive) appendLine(" --exclusive \\") + striplibsArg?.let { appendLine(" --striplibs $it \\") } if (hasCustomKeystore) { appendLine(" --keystore \"$keystorePath\" \\") - keystorePassword?.let { - appendLine(" --keystore-password \"$it\" \\") - } + keystorePassword?.let { appendLine(" --keystore-password \"$it\" \\") } if (keystoreAlias != null && keystoreAlias != DEFAULT_KEYSTORE_ALIAS) { appendLine(" --keystore-entry-alias \"$keystoreAlias\" \\") } @@ -472,15 +597,12 @@ class PatchSelectionViewModel( appendLine(" --keystore-entry-password \"$keystoreEntryPassword\" \\") } } - val flagPatches = if (useExclusive) selectedPatchNames else disabledPatchNames val flag = if (useExclusive) "-e" else "-d" - flagPatches.forEachIndexed { index, patch -> val suffix = if (index == flagPatches.lastIndex) "" else " \\" appendLine(" $flag \"$patch\"$suffix") } - append(" ${inputFile.name}") } } else { @@ -504,93 +626,124 @@ class PatchSelectionViewModel( /** * Download patches file if it's missing (e.g., after cache clear). * For LOCAL sources, uses the local file directly. - * Tries to find a release matching the expected filename, or falls back to latest stable. */ private suspend fun downloadMissingPatches(expectedFilename: String): Result { - // LOCAL source: use the local file directly instead of downloading if (localPatchFilePath != null) { val localFile = File(localPatchFilePath) - return if (localFile.exists()) { - Result.success(localFile) - } else { - Result.failure(Exception("Local patch file not found: ${localFile.name}")) - } + return if (localFile.exists()) Result.success(localFile) + else Result.failure(Exception("Local patch file not found: ${localFile.name}")) } - // Try to extract version from filename (e.g., "morphe-patches-1.9.0.mpp" -> "1.9.0") val versionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") val versionMatch = versionRegex.find(expectedFilename) val expectedVersion = versionMatch?.groupValues?.get(1) Logger.info("Looking for patches version: ${expectedVersion ?: "latest"}") - // Fetch releases val releasesResult = patchRepository.fetchReleases() if (releasesResult.isFailure) { - return Result.failure(releasesResult.exceptionOrNull() - ?: Exception("Failed to fetch releases")) + return Result.failure( + releasesResult.exceptionOrNull() ?: Exception("Failed to fetch releases") + ) } - val releases = releasesResult.getOrNull() ?: emptyList() - if (releases.isEmpty()) { - return Result.failure(Exception("No releases found")) - } + if (releases.isEmpty()) return Result.failure(Exception("No releases found")) - // Find matching release by version, or use latest stable val targetRelease = if (expectedVersion != null) { releases.find { it.tagName.contains(expectedVersion) } - ?: releases.firstOrNull { !it.isDevRelease() } // Fallback to latest stable + ?: releases.firstOrNull { !it.isDevRelease() } } else { - releases.firstOrNull { !it.isDevRelease() } // Latest stable - } - - if (targetRelease == null) { - return Result.failure(Exception("No suitable release found")) - } + releases.firstOrNull { !it.isDevRelease() } + } ?: return Result.failure(Exception("No suitable release found")) Logger.info("Downloading patches from release: ${targetRelease.tagName}") - - // Download the patches return patchRepository.downloadPatches(targetRelease) } - } +// ── State / supporting types ──────────────────────────────────────────────── + data class PatchSelectionUiState( val isLoading: Boolean = false, - val allPatches: List = emptyList(), - val filteredPatches: List = emptyList(), - val selectedPatches: Set = emptySet(), + /** Per-bundle patches. Each bundle is one source's contribution — NO cross-bundle dedup. */ + val bundles: List = emptyList(), + /** Same shape as [bundles] but each bundle's patches list is post-filter. A bundle with + * zero matches stays in the list with `patches = emptyList()` so the UI can render + * the "no matches in this bundle" empty state inside the box. */ + val filteredBundles: List = emptyList(), + /** bundleId → set of patch uniqueIds enabled in that bundle. Independent across bundles. */ + val selectedByBundle: Map> = emptyMap(), + /** Snapshot of each bundle's saved selection. Null = no saved state for any bundle. */ + val savedSelectedByBundle: Map>? = null, + /** True when at least ONE bundle has a saved selection. Drives the per-box "Your Defaults" + * chip visibility — but per-box highlighting still uses [selectionModeFor]. */ + val hasSavedSelection: Boolean = false, val searchQuery: String = "", val showOnlySelected: Boolean = false, val error: String? = null, val apkArchitectures: List = emptyList(), val stripLibsStatus: StripLibsStatus = StripLibsStatus.NoNativeLibs, + /** "patchName.optionKey" → value. Options keyed by patch name, so identical patches + * across bundles share option values (intentional — same patch means same option). */ val patchOptionValues: Map = emptyMap(), - val hasSavedSelection: Boolean = false, - /** Snapshot of the saved-bundle's selected uniqueIds — used to highlight the - * Your Defaults chip whenever the live selection happens to match. Null when - * no saved selection exists. */ - val savedSelectedIds: Set? = null ) { - val selectedCount: Int get() = selectedPatches.size - val totalCount: Int get() = allPatches.size + /** Total count of patches enabled across all bundles. Patches identical across bundles + * are counted once per bundle they're enabled in — matches what the user toggled. */ + val selectedCount: Int get() = selectedByBundle.values.sumOf { it.size } + + /** Total count of patches across all bundles. */ + val totalCount: Int get() = bundles.sumOf { it.patches.size } + + // ── Legacy flat shims ──────────────────────────────────────────────── + // + // These let the existing PatchSelectionScreen render against the new + // per-bundle state without changes. Deleted once the screen renders + // collapsible bundle boxes. + + @Deprecated("Use bundles directly") + val allPatches: List get() = bundles.flatMap { it.patches } + + @Deprecated("Use filteredBundles directly") + val filteredPatches: List get() = filteredBundles.flatMap { it.patches } + + @Deprecated("Use selectedByBundle directly") + val selectedPatches: Set + get() = selectedByBundle.values.flatten().toSet() + + /** Snapshot of saved selection as a flat uniqueId set, for the legacy chip. Null when no bundle has saved state. */ + @Deprecated("Use savedSelectedByBundle directly") + val savedSelectedIds: Set? + get() = savedSelectedByBundle?.values?.flatten()?.toSet() /** - * Which preset (if any) the current selection matches. Drives chip highlighting: - * the chip whose mode equals `activeSelectionMode` gets the accent border + tint. - * SAVED is checked first so when the saved set happens to also be ALL or DEFAULTS, - * we still attribute the highlight to the user's saved preference. + * Legacy global selection mode — collapsed from per-bundle modes. Used + * only by the temporary flat-rendering path. Returns: + * - SAVED if EVERY bundle is in SAVED mode + * - DEFAULTS if EVERY bundle is in DEFAULTS mode + * - ALL / NONE similarly + * - CUSTOM otherwise (bundles disagree) */ + @Deprecated("Per-bundle UI: use selectionModeFor(bundleId)") val activeSelectionMode: SelectionMode get() { - if (allPatches.isEmpty()) return SelectionMode.CUSTOM - val all = allPatches.map { it.uniqueId }.toSet() - val defaults = allPatches.filter { it.isEnabled }.map { it.uniqueId }.toSet() + if (bundles.isEmpty()) return SelectionMode.CUSTOM + val modes = bundles.map { selectionModeFor(it.bundleId) }.distinct() + return if (modes.size == 1) modes.single() else SelectionMode.CUSTOM + } + + /** Which preset (if any) the SPECIFIED bundle's selection matches. Each box renders + * its own chip highlighting independently. */ + fun selectionModeFor(bundleId: String): SelectionMode { + val bundle = bundles.firstOrNull { it.bundleId == bundleId } ?: return SelectionMode.CUSTOM + if (bundle.patches.isEmpty()) return SelectionMode.CUSTOM + val selected = selectedByBundle[bundleId].orEmpty() + val all = bundle.patches.map { it.uniqueId }.toSet() + val defaults = bundle.patches.filter { it.isEnabled }.map { it.uniqueId }.toSet() + val saved = savedSelectedByBundle?.get(bundleId) return when { - savedSelectedIds != null && selectedPatches == savedSelectedIds -> SelectionMode.SAVED - selectedPatches.isEmpty() -> SelectionMode.NONE - selectedPatches == all -> SelectionMode.ALL - selectedPatches == defaults -> SelectionMode.DEFAULTS + saved != null && selected == saved -> SelectionMode.SAVED + selected.isEmpty() -> SelectionMode.NONE + selected == all -> SelectionMode.ALL + selected == defaults -> SelectionMode.DEFAULTS else -> SelectionMode.CUSTOM } } @@ -627,7 +780,7 @@ sealed class StripLibsStatus { data class WillStrip( val keeping: List, val stripping: List, - val notInApk: List + val notInApk: List, ) : StripLibsStatus() } @@ -640,7 +793,7 @@ sealed class StripLibsStatus { */ internal fun computeStripLibsStatus( apkArches: List, - userKeep: Set + userKeep: Set, ): StripLibsStatus { if (apkArches.isEmpty()) return StripLibsStatus.NoNativeLibs if (apkArches.size == 1 && apkArches[0].equals("universal", ignoreCase = true)) { @@ -657,7 +810,7 @@ internal fun computeStripLibsStatus( else -> StripLibsStatus.WillStrip( keeping = apkArches.filter { it in overlap }, stripping = apkArches.filter { it !in overlap }, - notInApk = notInApk + notInApk = notInApk, ) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt index e0cb0916..480c4778 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import app.morphe.gui.data.model.Release +import app.morphe.engine.model.Release import org.koin.core.parameter.parametersOf import cafe.adriel.voyager.koin.koinScreenModel import app.morphe.gui.ui.components.ErrorDialog diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt index da8940c4..f03ec7e7 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt @@ -9,7 +9,7 @@ import app.morphe.cli.command.model.toPatchBundle import app.morphe.patcher.patch.loadPatchesFromJar import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import app.morphe.gui.data.model.Release +import app.morphe.engine.model.Release import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager @@ -22,7 +22,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import app.morphe.gui.util.Logger -import app.morphe.gui.data.model.ReleaseAsset +import app.morphe.engine.model.ReleaseAsset import java.io.File class PatchesViewModel( @@ -81,9 +81,11 @@ class PatchesViewModel( val stableReleases = releases.filter { !it.isDevRelease() } val devReleases = releases.filter { it.isDevRelease() } - // Check config for previously selected version - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion + // Check config for previously selected version FOR THIS SOURCE + val activeSourceId = patchSourceManager?.getActiveSource()?.id + val savedVersion = activeSourceId?.let { + configRepository.getLastPatchesVersionsBySource()[it] + } // Find the saved release, or fall back to latest stable val initialRelease = if (savedVersion != null) { @@ -134,8 +136,10 @@ class PatchesViewModel( parseVersionParts(version) .fold(0L) { acc, part -> acc * 10000 + part } } - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion + val activeSourceId = patchSourceManager?.getActiveSource()?.id + val savedVersion = activeSourceId?.let { + configRepository.getLastPatchesVersionsBySource()[it] + } // Pre-select the saved version, or fall back to the first (most recent) val initialRelease = if (savedVersion != null) { @@ -246,7 +250,12 @@ class PatchesViewModel( private fun checkCachedPatches(release: Release): File? { val asset = patchRepository.findPatchAsset(release) ?: return null val patchesDir = patchRepository.getCacheDir() - val cachedFile = File(patchesDir, asset.name) + // Match the version-prefixed filename PatchRepository.downloadPatches writes. + // Looking up by bare asset.name would falsely "find" the latest version's + // file for every other version's check (since maintainers commonly reuse + // the asset filename across releases) — that was the cause of the + // "latest stable shows SELECT after Clear Cache" bug. + val cachedFile = File(patchesDir, PatchRepository.cachedFileName(release, asset)) // Verify file exists and size matches (size check acts as basic integrity verification) return if (cachedFile.exists() && cachedFile.length() == asset.size) { @@ -297,9 +306,13 @@ class PatchesViewModel( ) Logger.info("Patches downloaded: ${patchFile.absolutePath}") - // Save the selected version to config so HomeScreen can pick it up - configRepository.setLastPatchesVersion(release.tagName) - Logger.info("Saved selected patches version to config: ${release.tagName}") + // Save the selected version PER SOURCE so HomeScreen can pick it up + // without contaminating other enabled sources. + val activeSourceId = patchSourceManager?.getActiveSource()?.id + if (activeSourceId != null) { + configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) + Logger.info("Saved selected patches version for source '$activeSourceId': ${release.tagName}") + } }, onFailure = { e -> _uiState.value = _uiState.value.copy( @@ -323,8 +336,11 @@ class PatchesViewModel( fun confirmSelection() { val release = _uiState.value.selectedRelease ?: return screenModelScope.launch { - configRepository.setLastPatchesVersion(release.tagName) - Logger.info("Confirmed patches selection: ${release.tagName}") + val activeSourceId = patchSourceManager?.getActiveSource()?.id + if (activeSourceId != null) { + configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) + Logger.info("Confirmed patches selection for source '$activeSourceId': ${release.tagName}") + } } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt index fefa6602..deeb747f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt @@ -18,8 +18,11 @@ import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close @@ -31,9 +34,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import java.io.File import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator @@ -360,6 +368,7 @@ private fun FailureBottomBar( val hasTempFiles = remember { FileUtils.hasTempFiles() } val tempFilesSize = remember { FileUtils.getTempDirSize() } val logFile = remember { Logger.getLogFile() } + var showLogViewer by remember { mutableStateOf(false) } Column( modifier = Modifier @@ -427,6 +436,32 @@ private fun FailureBottomBar( ) } + val viewHover = remember { MutableInteractionSource() } + val isViewHovered by viewHover.collectIsHoveredAsState() + val viewBg by animateColorAsState( + if (isViewHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .hoverable(viewHover) + .clip(RoundedCornerShape(corners.small)) + .background(viewBg) + .clickable { showLogViewer = true } + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = "VIEW", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + val openHover = remember { MutableInteractionSource() } val isOpenHovered by openHover.collectIsHoveredAsState() val openBg by animateColorAsState( @@ -450,16 +485,26 @@ private fun FailureBottomBar( .padding(horizontal = 10.dp, vertical = 4.dp) ) { Text( - text = "OPEN", + text = "REVEAL", fontSize = 10.sp, fontWeight = FontWeight.Bold, fontFamily = mono, - color = accents.primary, + color = accents.primary.copy(alpha = 0.7f), letterSpacing = 0.5.sp ) } } + if (showLogViewer) { + LogFileViewerDialog( + file = logFile, + corners = corners, + mono = mono, + borderColor = borderColor, + onDismiss = { showLogViewer = false } + ) + } + Spacer(modifier = Modifier.height(8.dp)) } @@ -672,3 +717,154 @@ private fun getStatusColor(status: PatchingStatus): Color { else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) } } + +@Composable +private fun LogFileViewerDialog( + file: File, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + onDismiss: () -> Unit, +) { + val accents = LocalMorpheAccents.current + val clipboard = LocalClipboardManager.current + + // Read file once on open. Logs are line-oriented text, typically well + // under a few MB; if a single patching session ever produces something + // pathologically large we'd notice and tail it then. + val content = remember(file) { + runCatching { file.readText() }.getOrElse { e -> + "Failed to read log file: ${e.message}" + } + } + var copied by remember { mutableStateOf(false) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(32.dp) + .clip(RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "LOG FILE", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 2.sp, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(2.dp)) + Text( + text = file.absolutePath, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1 + ) + } + + val copyHover = remember { MutableInteractionSource() } + val isCopyHovered by copyHover.collectIsHoveredAsState() + val copyBg by animateColorAsState( + if (isCopyHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .hoverable(copyHover) + .clip(RoundedCornerShape(corners.small)) + .background(copyBg) + .clickable { + clipboard.setText(AnnotatedString(content)) + copied = true + } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text( + text = if (copied) "COPIED" else "COPY ALL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (copied) accents.secondary else accents.primary, + letterSpacing = 0.5.sp + ) + } + + Spacer(Modifier.width(4.dp)) + + val closeHover = remember { MutableInteractionSource() } + val isCloseHovered by closeHover.collectIsHoveredAsState() + val closeBg by animateColorAsState( + if (isCloseHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .hoverable(closeHover) + .clip(RoundedCornerShape(corners.small)) + .background(closeBg) + .clickable { onDismiss() } + .padding(6.dp) + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + } + + // Log content — read-only, selectable, monospace. + val scrollState = rememberScrollState() + Box(modifier = Modifier.fillMaxSize()) { + SelectionContainer( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp) + ) { + Text( + text = content, + fontSize = 11.sp, + fontFamily = mono, + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f) + ) + } + + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle() + ) + } + } + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt index 386c17c5..d0443513 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import app.morphe.engine.MorpheData import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import java.io.File @@ -52,18 +53,30 @@ class PatchingViewModel( addLog("Output: ${File(config.outputApkPath).name}", LogLevel.INFO) addLog("Patches: ${config.enabledPatches.size} enabled", LogLevel.INFO) - // Resolve keystore: use saved path, or derive from output APK location + // Resolve keystore. Two modes: + // - User configured one in Settings → use it; fail loudly if the + // file is missing (don't silently swap in our default — that + // would produce APKs signed by a different identity than the + // user picked, breaking on-device updates without explanation). + // - Otherwise → use the shared MorpheData default keystore. The + // patcher library creates it on first sign if missing; reused + // every patch session so all Morphe-patched apps share one + // signing identity. val appConfig = configRepository.loadConfig() - val resolvedKeystorePath = appConfig.keystorePath - ?: File(config.outputApkPath).let { out -> - out.resolveSibling(out.nameWithoutExtension + ".keystore").absolutePath - }.also { path -> - configRepository.setKeystorePath(path) - } + val userKeystore = appConfig.resolvedKeystorePath() + if (userKeystore != null && !userKeystore.exists()) { + val msg = "Configured keystore not found: ${userKeystore.absolutePath}. " + + "Restore the file, pick another in Settings, or clear the setting to use Morphe's default." + addLog(msg, LogLevel.ERROR) + _uiState.value = _uiState.value.copy(status = PatchingStatus.FAILED, error = msg) + Logger.error("Patching aborted: $msg") + return@launch + } + val resolvedKeystorePath = (userKeystore ?: MorpheData.defaultKeystoreFile).absolutePath // Use PatchService for direct library patching val result = patchService.patch( - patchesFilePath = config.patchesFilePath, + patchesFilePaths = config.patchesFilePaths, inputApkPath = config.inputApkPath, outputApkPath = config.outputApkPath, enabledPatches = config.enabledPatches, @@ -107,17 +120,16 @@ class PatchingViewModel( ) Logger.info("Patching completed: ${config.outputApkPath}") } else { - val failedMsg = if (patchResult.failedPatches.isNotEmpty()) { - "Failed patches: ${patchResult.failedPatches.joinToString(", ")}" - } else { - "Patching failed" - } - addLog(failedMsg, LogLevel.ERROR) + val reason = patchResult.failureReason + ?: if (patchResult.failedPatches.isNotEmpty()) + "Failed patches: ${patchResult.failedPatches.joinToString(", ")}" + else "Patching failed for an unknown reason" + addLog(reason, LogLevel.ERROR) _uiState.value = _uiState.value.copy( status = PatchingStatus.FAILED, - error = "Patching failed. Check logs for details." + error = reason, ) - Logger.error("Patching failed: ${patchResult.failedPatches}") + Logger.error("Patching failed: $reason") } }, onFailure = { e -> diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index eeb1bafb..08684763 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -39,11 +39,18 @@ import cafe.adriel.voyager.core.screen.Screen import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchSourceManager +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import app.morphe.gui.ui.components.MorpheErrorBar import app.morphe.gui.ui.components.OfflineBanner +import app.morphe.gui.ui.components.SourceManagementSheet +import app.morphe.gui.ui.components.SourceSheetMode import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.screens.home.components.FullScreenDropZone @@ -84,6 +91,21 @@ class QuickPatchScreen : Screen { fun QuickPatchContent(viewModel: QuickPatchViewModel) { val uiState by viewModel.uiState.collectAsState() + // Source picker state — Quick Patch is single-source by design. The picker + // uses the same SourceManagementSheet as Expert mode but in SINGLE_SELECT + // mode (radio behavior). Users can also add/edit/remove sources from here, + // matching morphe-manager which doesn't gate source management on expert mode. + val patchSourceManager: PatchSourceManager = koinInject() + val allSources by patchSourceManager.allSources.collectAsState() + val pickerScope = rememberCoroutineScope() + var showSourcePicker by remember { mutableStateOf(false) } + var activeSourceId by remember { mutableStateOf(null) } + LaunchedEffect(uiState.patchSourceName, allSources) { + // Resolve the current active source's id by name for radio selection. + activeSourceId = allSources.firstOrNull { it.name == uiState.patchSourceName }?.id + ?: patchSourceManager.getActiveSource().id + } + val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current @@ -93,6 +115,26 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { var trailingWidthPx by remember { mutableIntStateOf(0) } val centerSidePadding = with(density) { maxOf(leadingWidthPx, trailingWidthPx).toDp() } + 16.dp + if (showSourcePicker) { + SourceManagementSheet( + sources = allSources, + mode = SourceSheetMode.SINGLE_SELECT, + activeSourceId = activeSourceId, + onSelectSingle = { id -> + showSourcePicker = false + pickerScope.launch { patchSourceManager.switchSource(id) } + }, + onToggleEnabled = { _, _ -> /* no-op in SINGLE_SELECT mode */ }, + onAdd = { src -> pickerScope.launch { patchSourceManager.addSource(src) } }, + onEdit = { src -> pickerScope.launch { patchSourceManager.updateSource(src) } }, + onRemove = { id -> pickerScope.launch { patchSourceManager.removeSource(id) } }, + onOpenPatches = { /* unused in SINGLE_SELECT mode */ }, + onDismiss = { showSourcePicker = false }, + enabled = uiState.phase != QuickPatchPhase.DOWNLOADING && + uiState.phase != QuickPatchPhase.PATCHING, + ) + } + FullScreenDropZone( isDragHovering = uiState.isDragHovering, onDragHoverChange = { viewModel.setDragHover(it) }, @@ -134,7 +176,9 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { BrandingLogo() } - // Patches version badge — centered + // Patches version badge — centered. Click opens the source-management + // sheet in SINGLE_SELECT mode so the user can pick which source Quick + // Patch uses (and add/edit/remove sources too). Box( modifier = Modifier .align(Alignment.Center) @@ -147,7 +191,8 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { latestLabel = if (uiState.patchesVersion != null && uiState.patchesVersion == uiState.latestPatchesVersion) { "LATEST STABLE" - } else null + } else null, + onClick = { showSourcePicker = true }, ) } @@ -262,75 +307,15 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { // Error/warning bar uiState.error?.let { error -> - val mono = LocalMorpheFont.current val isUnsupportedWarning = error.contains("not supported in Quick Patch") - val accentColor = if (isUnsupportedWarning) accents.warning else MaterialTheme.colorScheme.error - val barBg = MaterialTheme.colorScheme.surface - val borderCol = accentColor.copy(alpha = 0.4f) - - Row( + MorpheErrorBar( + message = error, + onDismiss = { viewModel.clearError() }, + isWarning = isUnsupportedWarning, modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 24.dp, vertical = 20.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(corners.small)) - .border(1.dp, borderCol, RoundedCornerShape(corners.small)) - .background(barBg) - .drawBehind { - drawRect( - color = accentColor, - size = androidx.compose.ui.geometry.Size(3.dp.toPx(), size.height) - ) - } - .padding(start = 3.dp) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(8.dp) - .background(accentColor, RoundedCornerShape(1.dp)) - ) - Spacer(Modifier.width(12.dp)) - Text( - text = error, - fontFamily = mono, - fontSize = 12.sp, - lineHeight = 16.sp, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - Spacer(Modifier.width(12.dp)) - - // Dismiss — same hover pattern as the close button - val dismissHover = remember { MutableInteractionSource() } - val isDismissHovered by dismissHover.collectIsHoveredAsState() - val dismissBg by animateColorAsState( - if (isDismissHovered) accentColor.copy(alpha = 0.12f) - else Color.Transparent, - animationSpec = tween(150) - ) - Box( - modifier = Modifier - .height(28.dp) - .hoverable(dismissHover) - .clip(RoundedCornerShape(corners.small)) - .background(dismissBg) - .clickable { viewModel.clearError() } - .padding(horizontal = 12.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "DISMISS", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = if (isDismissHovered) accentColor - else accentColor.copy(alpha = 0.7f), - letterSpacing = 1.sp - ) - } - } + ) } } } @@ -361,10 +346,12 @@ private fun PatchesVersionBadge( isLoading: Boolean, patchSourceName: String? = null, latestLabel: String? = null, + onClick: (() -> Unit)? = null, ) { val mono = LocalMorpheFont.current val corners = LocalMorpheCorners.current val accents = LocalMorpheAccents.current + val interactive = onClick != null if (isLoading) { Row( @@ -398,7 +385,13 @@ private fun PatchesVersionBadge( .clip(RoundedCornerShape(corners.small)) .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) .background(MaterialTheme.colorScheme.surface) - .padding(start = 12.dp, end = 4.dp), + .then( + if (interactive) Modifier + .pointerHoverIcon(androidx.compose.ui.input.pointer.PointerIcon.Hand) + .clickable(onClick = onClick!!) + else Modifier + ) + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -1199,6 +1192,8 @@ private fun CompletedContent( val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } val monitorState by DeviceMonitor.state.collectAsState() + val adbPreference = LocalAdbPreference.current + val isAdbDisabledByUser = !adbPreference.enabled var isInstalling by remember { mutableStateOf(false) } var installError by remember { mutableStateOf(null) } var installSuccess by remember { mutableStateOf(false) } @@ -1336,8 +1331,44 @@ private fun CompletedContent( } } - // ADB install - if (monitorState.isAdbAvailable == true) { + // ADB install — when the user has the toggle off, render a compact + // "ADB OFF" hint with an inline enable button rather than hiding the + // affordance entirely (otherwise users wonder where install went). + if (isAdbDisabledByUser) { + Spacer(modifier = Modifier.height(12.dp)) + val enableHover = remember { MutableInteractionSource() } + val enableHovered by enableHover.collectIsHoveredAsState() + Box( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth() + .height(38.dp) + .hoverable(enableHover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (enableHovered) accents.primary.copy(alpha = 0.5f) + else accents.primary.copy(alpha = 0.25f), + RoundedCornerShape(corners.small) + ) + .background( + if (enableHovered) accents.primary.copy(alpha = 0.08f) + else Color.Transparent, + RoundedCornerShape(corners.small) + ) + .clickable { adbPreference.onChange(true) }, + contentAlignment = Alignment.Center + ) { + Text( + text = "ADB OFF · ENABLE TO INSTALL", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + } else if (monitorState.isAdbAvailable == true) { Spacer(modifier = Modifier.height(12.dp)) val readyDevices = monitorState.devices.filter { it.isReady } @@ -1702,10 +1733,16 @@ private fun SupportedAppsRow( .fillMaxWidth() .then(if (useScrolling) Modifier.horizontalScroll(cardsScrollState) else Modifier) .height(IntrinsicSize.Max) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { focusManager.clearFocus() }, + // detectTapGestures (not .clickable) so scroll-wheel / + // two-finger gestures over this Row aren't swallowed. + // .clickable wraps the modifier chain in a pointer-input + // node that consumes scroll events on Linux/Skiko, + // breaking both the inner horizontalScroll and the + // outer page-level verticalScroll. Taps still clear + // the search-bar focus. + .pointerInput(Unit) { + detectTapGestures(onTap = { focusManager.clearFocus() }) + }, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { filteredApps.forEach { app -> diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index 20bf8b13..cb076971 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -11,24 +11,29 @@ import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.PatchConfig import app.morphe.gui.data.model.SupportedApp +import app.morphe.engine.MorpheData import app.morphe.engine.UpdateInfo import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch -import net.dongliu.apk.parser.ApkFile +import app.morphe.engine.util.ApkManifestReader import app.morphe.gui.util.ChecksumStatus +import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import app.morphe.gui.util.SupportedAppExtractor import app.morphe.gui.util.VersionStatus +import app.morphe.gui.util.humanizePatchLoadError +import app.morphe.gui.data.repository.ActiveMode import java.io.File /** @@ -55,11 +60,22 @@ class QuickPatchViewModel( private var cachedPatches: List = emptyList() private var cachedSupportedApps: List = emptyList() private var cachedPatchesFile: File? = null + /** All successfully-resolved patch files across enabled sources. Single-element + * in single-source mode. Used by the patching call to feed the engine the + * union of patches when multiple sources are enabled. */ + private var cachedAllPatchFiles: List = emptyList() - init { - // Load patches on startup to get dynamic app info - loadPatchesAndSupportedApps() + private fun currentResolvedPatchFiles(): List = + cachedAllPatchFiles.takeIf { it.isNotEmpty() } + ?: listOfNotNull(cachedPatchesFile) + + /** Snapshot of the most recent multi-source load. Used by the QuickPatchScreen + * header to render the same SourcesCountPill as Expert mode (no click action + * in Quick Patch — sources are managed only from Expert mode). */ + fun getResolvedSourcesSnapshot(): EnabledSourcesLoader.Result? = cachedSourcesResult + private var cachedSourcesResult: EnabledSourcesLoader.Result? = null + init { // Background CLI update check — non-blocking, banner only. screenModelScope.launch { val info = updateCheckRepository.getUpdateInfo() @@ -70,9 +86,29 @@ class QuickPatchViewModel( ) } + // Load patches whenever QUICK becomes the active mode. StateFlow + // replays the current value on subscribe, so this covers the + // "VM was just constructed while QUICK is active" case (replacing + // the old unconditional init-block load) AND the "user switched + // back to Quick after being in Expert" case. + screenModelScope.launch { + patchSourceManager.activeMode.collect { mode -> + if (mode == ActiveMode.QUICK) { + loadPatchesAndSupportedApps() + } + } + } + // Observe source changes screenModelScope.launch { patchSourceManager.sourceVersion.drop(1).collect { + // Skip when Expert mode is active — HomeViewModel will handle + // the multi-source reload. QuickVM still lives in memory + // (it's `remember`-scoped to App.kt) but staying silent here + // halves the parallel HTTP traffic and removes the duplicate + // request for the active source that BOTH VMs would otherwise + // fire simultaneously. + if (patchSourceManager.activeMode.value != ActiveMode.QUICK) return@collect Logger.info("QuickVM: Source changed, reloading patches...") patchRepository = patchSourceManager.getActiveRepositorySync() localPatchFilePath = patchSourceManager.getLocalFilePath() @@ -132,170 +168,86 @@ class QuickPatchViewModel( } /** - * Load patches from GitHub and extract supported apps dynamically. + * Load patches from all enabled sources via [EnabledSourcesLoader] and build + * the union supported-apps list. Single-source case (default) produces output + * equivalent to the pre-multi-source flow. */ private fun loadPatchesAndSupportedApps() { loadJob?.cancel() loadJob = screenModelScope.launch { _uiState.value = _uiState.value.copy(isLoadingPatches = true, patchLoadError = null) - // LOCAL source: skip GitHub entirely, load directly from the .mpp file - if (localPatchFilePath != null) { - val localFile = File(localPatchFilePath) - if (localFile.exists()) { - loadPatchesFromFile(localFile, localFile.nameWithoutExtension, isOffline = false) - } else { + try { + // Quick Patch is intentionally single-source — multi-source belongs in + // Expert mode. The user picks WHICH single source via the source-picker + // sheet, which calls patchSourceManager.switchSource and updates + // activePatchSourceId. Quick Patch loads only that source regardless of + // Expert's enabled flags — the two modes operate independently. + val activeSource = patchSourceManager.getActiveSource() + val activeRepo = patchSourceManager.getRepositoryForSource(activeSource) + val pair: Pair = + activeSource to activeRepo + + val result = EnabledSourcesLoader.loadAll(listOf(pair), patchService) + + if (!result.anyLoaded) { + val firstError = result.resolved.firstNotNullOfOrNull { it.error } + ?: result.loaded.perSource.firstNotNullOfOrNull { it.error?.message } + ?: "Could not load any patches" _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = "Local patch file not found: ${localFile.name}" + patchLoadError = firstError ) - } - return@launch - } - - try { - // Fetch releases - val releasesResult = patchRepository.fetchReleases() - val releases = releasesResult.getOrNull() - - if (releases.isNullOrEmpty()) { - // Try to fall back to cached .mpp file when offline - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile)) - return@launch - } - Logger.warn("Quick mode: Could not fetch releases") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not fetch releases. Check your internet connection.") return@launch } - // Quick mode always uses the latest stable release - val release = releases.firstOrNull { !it.isDevRelease() } - - if (release == null) { - Logger.warn("Quick mode: No suitable release found") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "No suitable release found") - return@launch - } - - // Download patches - val patchFileResult = patchRepository.downloadPatches(release) - val patchFile = patchFileResult.getOrNull() - - if (patchFile == null) { - Logger.warn("Quick mode: Could not download patches") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not download patches") - return@launch - } - - cachedPatchesFile = patchFile - - // Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches.isNullOrEmpty()) { - Logger.warn("Quick mode: Could not load patches: ${patchesResult.exceptionOrNull()?.message}") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not load patches") - return@launch - } - - cachedPatches = patches - - // Extract supported apps dynamically - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) + val supportedApps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + cachedPatches = result.unionGuiPatches cachedSupportedApps = supportedApps + val firstResolved = result.resolved.firstOrNull { it.patchFile != null } + cachedPatchesFile = firstResolved?.patchFile + cachedAllPatchFiles = result.resolved.mapNotNull { it.patchFile } + cachedSourcesResult = result + + Logger.info( + "Quick mode: Loaded ${supportedApps.size} supported apps from " + + "${result.resolved.count { it.patchFile != null }} source(s)" + ) - Logger.info("Quick mode: Loaded ${supportedApps.size} supported apps: ${supportedApps.map { "${it.displayName} (${it.recommendedVersion})" }}") + // Multi-source: only flag offline when EVERY resolved source is offline. + val resolvedSources = result.resolved.filter { it.patchFile != null } + val isOffline = resolvedSources.isNotEmpty() && resolvedSources.all { it.isOffline } + val displayVersion = firstResolved?.resolvedVersion + val sourceName = if (result.resolved.size == 1) { + firstResolved?.source?.name ?: patchSourceManager.getActiveSourceName() + } else { + "${result.resolved.count { it.patchFile != null }} sources" + } _uiState.value = _uiState.value.copy( isLoadingPatches = false, supportedApps = supportedApps, - patchesVersion = release.tagName, - latestPatchesVersion = release.tagName, - patchSourceName = patchSourceManager.getActiveSourceName(), + patchesVersion = displayVersion, + latestPatchesVersion = displayVersion, + patchSourceName = sourceName, patchLoadError = null, - isOffline = false + isOffline = isOffline ) + } catch (e: CancellationException) { + // See HomeViewModel for the rationale: never overwrite UI + // state from a cancelled load — the cancellation race would + // clobber a successor's progress with a stale error. + throw e } catch (e: Exception) { Logger.error("Quick mode: Failed to load patches", e) - // Try to fall back to cached .mpp file - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - try { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile)) - return@launch - } catch (inner: Exception) { - Logger.error("Quick mode: Failed to load cached patches fallback", inner) - } - } - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Failed to load patches: ${e.message}") + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = humanizePatchLoadError(e), + ) } } } - /** - * Find any cached .mpp file when offline. - * Searches the per-source cache directory. - */ - private fun findCachedPatchFile(savedVersion: String?): File? { - val patchesDir = patchRepository.getCacheDir() - val patchFiles = patchesDir.listFiles { file -> - val ext = file.extension.lowercase() - ext == "mpp" || ext == "jar" - }?.filter { it.length() > 0 } ?: return null - - if (patchFiles.isEmpty()) return null - - return if (savedVersion != null) { - patchFiles.firstOrNull { it.name.contains(savedVersion, ignoreCase = true) } - ?: patchFiles.maxByOrNull { it.lastModified() } - } else { - patchFiles.maxByOrNull { it.lastModified() } - } - } - - private fun versionFromFilename(file: File): String { - val name = file.nameWithoutExtension - val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(name) - return match?.value ?: name - } - - /** - * Load patches from a local .mpp file (offline fallback). - */ - private suspend fun loadPatchesFromFile(patchFile: File, version: String, isOffline: Boolean = true) { - cachedPatchesFile = patchFile - - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches.isNullOrEmpty()) { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Could not load patches: ${patchesResult.exceptionOrNull()?.message}" - ) - return - } - - cachedPatches = patches - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - cachedSupportedApps = supportedApps - Logger.info("Quick mode: Loaded ${supportedApps.size} supported apps from ${if (isOffline) "cached" else "local"} patches: ${patchFile.name}") - - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - supportedApps = supportedApps, - patchesVersion = version, - patchSourceName = patchSourceManager.getActiveSourceName(), - patchLoadError = null, - isOffline = isOffline - ) - } - /** * Retry loading patches after a failure. */ @@ -355,10 +307,14 @@ class QuickPatchViewModel( } return try { - ApkFile(apkToParse).use { apk -> - val meta = apk.apkMeta - val packageName = meta.packageName - val versionName = meta.versionName ?: "Unknown" + // ARSCLib manifest reader (engine) — replaces apk-parser. Same + // library morphe-patcher uses; handles split APKs cleanly. + val manifest = ApkManifestReader.read(apkToParse) + ?: throw IllegalStateException("ARSCLib couldn't read manifest") + + run { + val packageName = manifest.packageName + val versionName = manifest.versionName ?: "Unknown" // Check if supported using dynamic data val dynamicAppInfo = cachedSupportedApps.find { it.packageName == packageName } @@ -376,7 +332,7 @@ class QuickPatchViewModel( } if (packageName !in supportedPackages) { - val appName = SupportedApp.resolveDisplayName(packageName, meta.label) + val appName = SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) val supportedNames = cachedSupportedApps.map { it.displayName } .ifEmpty { listOf("YouTube", "YouTube Music", "Reddit") } .joinToString(", ") @@ -390,7 +346,7 @@ class QuickPatchViewModel( // Get display name and recommended version from dynamic data, fallback to constants val displayName = dynamicAppInfo?.displayName - ?: SupportedApp.resolveDisplayName(packageName, meta.label) + ?: SupportedApp.resolveDisplayName(packageName, manifest.applicationLabel) val recommendedVersion = dynamicAppInfo?.recommendedVersion @@ -425,7 +381,7 @@ class QuickPatchViewModel( // Extract architectures — scan the original file (bundles have splits with native libs) val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) - val minSdk = meta.minSdkVersion?.toIntOrNull() + val minSdk = manifest.minSdkVersion Logger.info("Quick mode: Analyzed $displayName v$versionName (recommended: $recommendedVersion, status: $versionStatus, archs: $architectures)") @@ -516,31 +472,34 @@ class QuickPatchViewModel( progress = 0.4f ) - // Generate output path + // Generate output path via the shared engine helper — same path + // the CLI and Expert mode compute. Passing apkInfo.displayName + // as the display name preserves the friendly label. val appConfig = configRepository.loadConfig() - val baseName = apkInfo.displayName.replace(" ", "-") - val baseOutputDir = appConfig.defaultOutputDirectory?.let { File(it) } - ?: apkFile.parentFile - ?: File(System.getProperty("user.home")) - val outputDir = File(baseOutputDir, baseName).also { it.mkdirs() } - val patchesVersion = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") - .find(patchFile.name)?.groupValues?.get(1) - val patchesSuffix = if (patchesVersion != null) "-patches-$patchesVersion" else "" - val outputFileName = "$baseName-Morphe-${apkInfo.versionName}${patchesSuffix}.apk" - val outputPath = File(outputDir, outputFileName).absolutePath - - // Resolve keystore: use saved path, or derive from output APK location - val resolvedKeystorePath = appConfig.keystorePath - ?: File(outputPath).let { out -> - out.resolveSibling(out.nameWithoutExtension + ".keystore").absolutePath - }.also { path -> - configRepository.setKeystorePath(path) - } + val outputPath = app.morphe.engine.util.ApkOutputNaming.outputApkPath( + inputApk = apkFile, + patchesFile = patchFile, + baseOutputDir = appConfig.resolvedDefaultOutputDirectory(), + appDisplayName = apkInfo.displayName, + ).absolutePath + + // Resolve keystore — see PatchingViewModel for the full rationale. + // User-configured: use it; fail loudly if missing. + // Default: shared MorpheData keystore, auto-created on first sign. + val userKeystore = appConfig.resolvedKeystorePath() + if (userKeystore != null && !userKeystore.exists()) { + val msg = "Configured keystore not found: ${userKeystore.absolutePath}. " + + "Restore the file, pick another in Settings, or clear the setting to use Morphe's default." + _uiState.value = _uiState.value.copy(phase = QuickPatchPhase.READY, error = msg) + Logger.error("Quick patching aborted: $msg") + return@launch + } + val resolvedKeystorePath = (userKeystore ?: MorpheData.defaultKeystoreFile).absolutePath // Use PatchService for direct library patching (no CLI subprocess) // exclusiveMode = false means the library's patch.use field determines defaults val patchResult = patchService.patch( - patchesFilePath = patchFile.absolutePath, + patchesFilePaths = currentResolvedPatchFiles().map { it.absolutePath }, inputApkPath = apkFile.absolutePath, outputApkPath = outputPath, enabledPatches = emptyList(), diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index fbce3149..64b0d6c8 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.repository.ConfigRepository import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -85,6 +86,8 @@ fun ResultScreenContent(outputPath: String) { // ADB state from DeviceMonitor val monitorState by DeviceMonitor.state.collectAsState() + val adbPreference = LocalAdbPreference.current + val isAdbDisabledByUser = !adbPreference.enabled var isInstalling by remember { mutableStateOf(false) } var installProgress by remember { mutableStateOf("") } var installError by remember { mutableStateOf(null) } @@ -352,7 +355,14 @@ fun ResultScreenContent(outputPath: String) { } // ADB Install section - if (monitorState.isAdbAvailable == true) { + if (isAdbDisabledByUser) { + AdbDisabledHint( + corners = corners, + mono = mono, + borderColor = borderColor, + onEnableClick = { adbPreference.onChange(true) } + ) + } else if (monitorState.isAdbAvailable == true) { AdbInstallSection( devices = monitorState.devices, selectedDevice = monitorState.selectedDevice, @@ -393,8 +403,10 @@ fun ResultScreenContent(outputPath: String) { ) } - // ADB help text - if (monitorState.isAdbAvailable == false) { + // ADB help text — only when the toggle is ON but the binary is + // missing. When the toggle is OFF, AdbDisabledHint above carries + // the explanation; suppress the duplicate "ADB not found" text. + if (!isAdbDisabledByUser && monitorState.isAdbAvailable == false) { Text( text = "ADB not found. Install Android SDK Platform Tools to enable direct installation.", fontSize = 10.sp, @@ -868,6 +880,87 @@ private fun CleanupSection( } } +/** + * Replaces [AdbInstallSection] when the user has the auto-start ADB toggle off. + * Mirrors the bordered card layout so the result screen doesn't collapse — + * but the install button is replaced with a clearly-disabled "ENABLE ADB" + * hint that flips the toggle in one click. + */ +@Composable +private fun AdbDisabledHint( + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + onEnableClick: () -> Unit, +) { + val accents = LocalMorpheAccents.current + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) { + Text( + text = "ADB INSTALL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "ADB is off. Install-on-device is disabled.", + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Enable ADB in Settings to push patched APKs directly.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + ) + Spacer(Modifier.height(14.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (isHovered) accents.primary.copy(alpha = 0.5f) + else accents.primary.copy(alpha = 0.25f), + RoundedCornerShape(corners.small) + ) + .background( + if (isHovered) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + ) + .clickable(onClick = onEnableClick), + contentAlignment = Alignment.Center + ) { + Text( + text = "ENABLE ADB", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 1.sp + ) + } + } + } +} + private fun formatFileSize(bytes: Long): String { return when { bytes < 1024 -> "$bytes B" diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt index da3715bc..df2f8dc9 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt @@ -95,6 +95,14 @@ private val MatchaAccents = MorpheAccentColors( warning = Color(0xFFB77833), // Toasted ochre ) +/** Deepspace — high-saturation cyan on near-black. Cyberdeck/dev-tool aesthetic. */ +private val DeepspaceAccents = MorpheAccentColors( + primary = Color(0xFF00D9FF), // Electric cyan — primary + secondary = Color(0xFF79E3A5), // Mint green — stable / success + tertiary = Color(0xFF7AB7FF), // Cool blue — structural + warning = Color(0xFFFFB347), // Warm amber — older / warning +) + // ════════════════════════════════════════════════════════════════════ // CORNER / SHAPE STYLE SYSTEM // ════════════════════════════════════════════════════════════════════ @@ -111,6 +119,24 @@ data class MorpheCornerStyle( val LocalMorpheCorners = compositionLocalOf { MorpheCornerStyle() } +/** + * Canonical control sizing across the app. Use these instead of hardcoded `.dp` + * values for buttons, text fields, search bars, and dialog action rows so the + * same dimensions apply everywhere — no per-screen drift. + * + * - [controlHeight]: standard interactive height (buttons, text fields, pills, + * search bars). Matches the height of OPEN LOGS / OPEN APP DATA action buttons. + * - [iconInControl]: icon size used inside controlHeight-sized affordances. + * - [controlHorizontalPadding]: standard horizontal padding inside a control. + */ +data class MorpheDimens( + val controlHeight: Dp = 36.dp, + val iconInControl: Dp = 14.dp, + val controlHorizontalPadding: Dp = 12.dp, +) + +val LocalMorpheDimens = compositionLocalOf { MorpheDimens() } + /** Sharp corners for cyberdeck/dev themes. */ private val SharpCorners = MorpheCornerStyle(small = 2.dp, medium = 2.dp, large = 2.dp) @@ -248,6 +274,25 @@ private val MatchaColorScheme = lightColorScheme( onError = Color.White ) +// ── Deepspace ── +// Cyberdeck dev-tool aesthetic: electric cyan + mint on near-black blue. +private val DeepspaceColorScheme = darkColorScheme( + primary = Color(0xFF00D9FF), // Electric cyan + secondary = Color(0xFF79E3A5), // Mint green + tertiary = Color(0xFF7AB7FF), // Cool blue + background = Color(0xFF0D1117), // Near-black blue + surface = Color(0xFF14191F), // Slightly raised + surfaceVariant = Color(0xFF1B2128), // Card surfaces + onPrimary = Color(0xFF001A22), // Deep cyan-black for high contrast on cyan + onSecondary = Color(0xFF0A2317), // Deep green-black on mint + onTertiary = Color(0xFF051628), // Deep blue-black + onBackground = Color(0xFFD6DEEB), // Warm light text + onSurface = Color(0xFFD6DEEB), + onSurfaceVariant = Color(0xFF8E97A6), // Muted text + error = Color(0xFFFF6B6B), + onError = Color(0xFF1E0707), +) + // ════════════════════════════════════════════════════════════════════ // THEME PREFERENCE // ════════════════════════════════════════════════════════════════════ @@ -260,11 +305,12 @@ enum class ThemePreference { CATPPUCCIN, SAKURA, MATCHA, + DEEPSPACE, SYSTEM; /** Whether this theme uses dark color scheme (for resource qualifiers). */ fun isDark(): Boolean = when (this) { - DARK, AMOLED, NORD, CATPPUCCIN -> true + DARK, AMOLED, NORD, CATPPUCCIN, DEEPSPACE -> true LIGHT, SAKURA, MATCHA -> false SYSTEM -> false // caller should check isSystemInDarkTheme() } @@ -293,6 +339,7 @@ fun MorpheTheme( ThemePreference.CATPPUCCIN -> CatppuccinMochaColorScheme ThemePreference.SAKURA -> SakuraColorScheme ThemePreference.MATCHA -> MatchaColorScheme + ThemePreference.DEEPSPACE -> DeepspaceColorScheme ThemePreference.SYSTEM -> { if (isSystemInDarkTheme()) MorpheDarkColorScheme else MorpheLightColorScheme } @@ -308,13 +355,15 @@ fun MorpheTheme( ThemePreference.CATPPUCCIN -> CatppuccinAccents ThemePreference.SAKURA -> SakuraAccents ThemePreference.MATCHA -> MatchaAccents + ThemePreference.DEEPSPACE -> DeepspaceAccents ThemePreference.SYSTEM -> if (isSystemInDarkTheme()) DarkAccents else LightAccents } CompositionLocalProvider( LocalMorpheCorners provides corners, LocalMorpheFont provides font, - LocalMorpheAccents provides accents + LocalMorpheAccents provides accents, + LocalMorpheDimens provides MorpheDimens(), ) { MaterialTheme( colorScheme = colorScheme, diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt index d345989c..b8fa6e1c 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -8,6 +8,8 @@ package app.morphe.gui.util import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File +import java.net.InetSocketAddress +import java.net.Socket /** * Manages ADB (Android Debug Bridge) operations for installing APKs. @@ -17,6 +19,49 @@ class AdbManager { private var adbPath: String? = null + /** + * Set to true once [startServer] confirms Morphe was the process that + * spawned the ADB daemon (vs. attaching to one that was already running — + * Android Studio, scrcpy, a prior shell session). Gates [killServerIfOwned] + * so we never nuke a daemon someone else is depending on. + * + * Updated on every [startServer] call: if we attach to a pre-existing + * daemon and that daemon later dies + our next [startServer] tick + * respawns it, ownership flips from false → true. Without that, the + * polling loop's implicit respawns would leak a daemon Morphe is + * actively maintaining. + */ + @Volatile + var weStartedDaemon: Boolean = false + private set + + /** + * One-shot log dedup for the "we attached to a pre-existing daemon" + * message — without this, the polling loop would log it every 5s. + * Reset by [killServerIfOwned] so a re-attach after a kill cycle + * re-logs once. + */ + @Volatile + private var loggedAttachOnce: Boolean = false + + /** + * Cheap probe to check if the ADB daemon is listening on its conventional + * loopback port (5037). Used by [startServer] to detect ownership without + * relying on adb's stderr output (which varies across versions and can be + * suppressed when invoked programmatically). + * + * Short timeout keeps the polling loop snappy; localhost connects in <1ms + * when alive, refuses immediately when down. + */ + private fun isDaemonAlive(): Boolean = try { + Socket().use { socket -> + socket.connect(InetSocketAddress("127.0.0.1", 5037), 250) + } + true + } catch (_: Exception) { + false + } + /** * Find ADB binary in common locations or PATH. * Returns the path to ADB if found, null otherwise. @@ -106,6 +151,102 @@ class AdbManager { */ suspend fun isAdbAvailable(): Boolean = findAdb() != null + /** + * Ensure the ADB daemon is running, and record whether Morphe was the + * process that spawned the *current* daemon. Idempotent and cheap — safe + * to call on every poll tick. + * + * Detection is a TCP probe of 127.0.0.1:5037 (adb's conventional listen + * port). Before: alive? After invoking `adb start-server`: alive? + * - was-down + now-up → we own it (set [weStartedDaemon] = true). + * - was-up → no-op; ownership flag unchanged. + * + * Re-detection on every call matters: if Morphe initially attached to a + * pre-existing daemon (flag = false) and that daemon dies mid-session, + * the *next* tick's call will spawn a fresh one and flip the flag to + * true — so a subsequent [killServerIfOwned] correctly tears it down. + */ + suspend fun startServer(): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + try { + if (isDaemonAlive()) { + // Daemon already up — Morphe is attaching, not spawning. + // Don't touch the ownership flag (a prior tick may have set + // it to true and the daemon is still ours). + if (!weStartedDaemon && !loggedAttachOnce) { + Logger.info("ADB daemon was already running — leaving it alone on shutdown") + loggedAttachOnce = true + } + return@withContext Result.success(Unit) + } + + // Daemon is down. Spawn it. + val process = ProcessBuilder(adb, "start-server") + .redirectErrorStream(true) + .start() + process.inputStream.bufferedReader().readText() // drain so the child exits cleanly + val exitCode = process.waitFor() + if (exitCode != 0) { + return@withContext Result.failure( + AdbException("adb start-server exited with code $exitCode") + ) + } + + if (isDaemonAlive()) { + if (!weStartedDaemon) { + Logger.info("ADB daemon spawned by Morphe — will kill on shutdown") + } + weStartedDaemon = true + loggedAttachOnce = false + } else { + Logger.warn("adb start-server returned success but port 5037 is still closed") + } + Result.success(Unit) + } catch (e: Exception) { + Logger.error("Failed to start ADB server", e) + Result.failure(AdbException("Failed to start ADB server: ${e.message}")) + } + } + + /** + * Kill the ADB server, but only if [weStartedDaemon] — i.e. Morphe was + * the one that spawned it. Refusing to kill a daemon we attached to + * keeps Android Studio / scrcpy / other concurrent users alive. + * + * Clears [weStartedDaemon] on success so repeated calls are idempotent. + */ + suspend fun killServerIfOwned(): Result = withContext(Dispatchers.IO) { + if (!weStartedDaemon) { + Logger.debug("Skipping adb kill-server — daemon wasn't started by Morphe") + return@withContext Result.success(false) + } + val adb = findAdb() ?: return@withContext Result.success(false) + + try { + val process = ProcessBuilder(adb, "kill-server") + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + if (exitCode != 0) { + Logger.warn("adb kill-server exited with code $exitCode: $output") + return@withContext Result.failure( + AdbException("adb kill-server exited with code $exitCode") + ) + } + weStartedDaemon = false + loggedAttachOnce = false // next attach (if any) re-logs once + Logger.info("ADB daemon killed by Morphe") + Result.success(true) + } catch (e: Exception) { + Logger.error("Failed to kill ADB server", e) + Result.failure(AdbException("Failed to kill ADB server: ${e.message}")) + } + } + /** * Get list of connected devices. * Returns list of device IDs and their status. diff --git a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt index 1ce204e0..cc8a3fa0 100644 --- a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt +++ b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt @@ -34,8 +34,14 @@ object DeviceMonitor { if (!adbAvailable) return@launch - // Poll every 5 seconds + // Re-detect ownership on every poll. startServer() is cheap when + // the daemon's already alive (TCP port probe + early-return), + // and when the daemon has died externally + Morphe respawns it, + // ownership correctly flips to true — so a later kill on toggle + // OFF / window close tears down the daemon Morphe is actively + // keeping alive instead of leaking it. while (isActive) { + adbManager.startServer() refreshDevices() delay(5000) } @@ -47,6 +53,20 @@ object DeviceMonitor { pollingJob = null } + /** + * Stop polling AND kill the ADB daemon if Morphe owns it. Use this when + * the user toggles auto-start ADB OFF or closes the window. The owned-check + * lives in [AdbManager.killServerIfOwned] — if the daemon was already + * running when Morphe attached, this is a no-op. + * + * Clears device state immediately so UI doesn't flash stale entries. + */ + suspend fun stopMonitoringAndKillIfOwned() { + stopMonitoring() + _state.value = DeviceMonitorState(isAdbAvailable = _state.value.isAdbAvailable) + adbManager.killServerIfOwned() + } + fun selectDevice(device: AdbDevice) { _state.value = _state.value.copy(selectedDevice = device) } diff --git a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt new file mode 100644 index 00000000..8364a712 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.util + +import app.morphe.engine.MultiSourceLoader +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.data.repository.PatchRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import java.io.File + +/** + * GUI-side orchestrator that resolves each enabled patch source to a downloaded + * `.mpp` file (LOCAL = read filePath, GITHUB = fetch latest release + download) + * in parallel, then hands the resulting files to [MultiSourceLoader] for the + * actual patch loading + union. + * + * The single-source case (one enabled source) produces output equivalent to the + * pre-multi-source per-ViewModel flow. Per-source version pinning via + * [preferredVersionsBySource] keeps each source independent — picking a tag in + * one source's PatchesScreen does NOT contaminate other sources. + */ +object EnabledSourcesLoader { + + /** + * Per-source resolution result before patch-loading. Successful sources have + * a [patchFile]; failed ones have an [error] message and the UI can render + * the failure inline. + */ + /** What channel the resolved release is on. Used by the home pill LEDs and + * the sheet's channel badge so we don't keep re-deriving from tag strings. */ + enum class Channel { STABLE_LATEST, STABLE_OLDER, DEV_LATEST, DEV_OLDER, UNKNOWN } + + data class ResolvedSource( + val source: PatchSource, + val patchFile: File? = null, + val resolvedVersion: String? = null, + val isOffline: Boolean = false, + val error: String? = null, + val channel: Channel = Channel.UNKNOWN, + ) + + data class Result( + /** Resolution outcome per source (success or failure). */ + val resolved: List, + /** MultiSourceLoader output across the successfully-resolved sources. */ + val loaded: MultiSourceLoader.Result, + /** Union of GUI patches across all sources, for SupportedAppExtractor / UI. */ + val unionGuiPatches: List, + /** GUI patches grouped by sourceId, for badging UI in PatchSelectionScreen. */ + val guiPatchesBySource: Map>, + ) { + val anyLoaded: Boolean get() = loaded.allPatches.isNotEmpty() + val anyFailed: Boolean get() = resolved.any { it.error != null } || loaded.hasErrors + } + + /** + * Resolve and load every enabled source in parallel. + * + * @param enabled list of (source, repository) pairs from + * [app.morphe.gui.data.repository.PatchSourceManager.getEnabledRepositories]. + * Repository is null for LOCAL sources. + */ + suspend fun loadAll( + enabled: List>, + patchService: PatchService, + preferredVersionsBySource: Map = emptyMap(), + ): Result = supervisorScope { + // supervisorScope (not coroutineScope) so a single source's failure + // doesn't cancel the other in-flight resolves. Each async catches its + // own exceptions and returns a failed ResolvedSource — failures + // become data, not control flow. Cancellation still propagates from + // the caller (e.g. ViewModel cancelling its loadJob). + val resolved = enabled.map { (source, repo) -> + async(Dispatchers.IO) { + try { + resolve(source, repo, preferredVersionsBySource[source.id]) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ResolvedSource(source = source, error = e.message ?: e.javaClass.simpleName) + } + } + }.awaitAll() + + val inputs = resolved.mapNotNull { res -> + val file = res.patchFile ?: return@mapNotNull null + MultiSourceLoader.SourceInput( + sourceId = res.source.id, + sourceName = res.source.name, + patchFile = file, + ) + } + + val loaded = if (inputs.isEmpty()) { + MultiSourceLoader.Result( + perSource = emptyList(), + allPatches = emptySet(), + patchToSourceIds = emptyMap(), + ) + } else { + MultiSourceLoader.load(inputs) + } + + // Convert library patches → GUI patches once. Both the union and per-source + // groupings are derived from this single conversion. + val unionGui = patchService.convertToGuiPatches(loaded.allPatches) + val guiBySource: Map> = + loaded.perSource.associate { src -> + src.sourceId to patchService.convertToGuiPatches(src.patches) + } + + Result( + resolved = resolved, + loaded = loaded, + unionGuiPatches = unionGui, + guiPatchesBySource = guiBySource, + ) + } + + private suspend fun resolve( + source: PatchSource, + repo: PatchRepository?, + preferredVersion: String?, + ): ResolvedSource = withContext(Dispatchers.IO) { + when (source.type) { + PatchSourceType.LOCAL -> resolveLocal(source) + // GitHub / GitLab / built-in default all flow through the same + // remote-fetch path. The PatchRepository instance itself knows + // which API to talk to based on the source's provider type. + PatchSourceType.DEFAULT, + PatchSourceType.GITHUB, + PatchSourceType.GITLAB -> resolveRemote(source, repo, preferredVersion) + } + } + + private fun resolveLocal(source: PatchSource): ResolvedSource { + val path = source.filePath + if (path.isNullOrBlank()) { + return ResolvedSource(source = source, error = "Local source has no file path configured") + } + val file = File(path) + if (!file.exists()) { + return ResolvedSource(source = source, error = "Local patch file not found: ${file.name}") + } + return ResolvedSource( + source = source, + patchFile = file, + resolvedVersion = file.nameWithoutExtension, + isOffline = false, + ) + } + + private suspend fun resolveRemote( + source: PatchSource, + repo: PatchRepository?, + preferredVersion: String?, + ): ResolvedSource { + if (repo == null) { + return ResolvedSource(source = source, error = "No repository configured for source") + } + + val releasesResult = repo.fetchReleases() + val releases = releasesResult.getOrNull() + + if (releases.isNullOrEmpty()) { + // Offline fallback: scan source's cache dir for any .mpp/.jar file + val cached = findCachedPatchFile(repo) + if (cached != null) { + return ResolvedSource( + source = source, + patchFile = cached, + resolvedVersion = versionFromFilename(cached), + isOffline = true, + ) + } + val errMsg = releasesResult.exceptionOrNull()?.message ?: "Could not fetch releases" + return ResolvedSource(source = source, error = errMsg) + } + + // Honor a user-pinned version if it exists in this source's releases. + // Otherwise pick latest stable, falling back to latest dev. + val release = preferredVersion + ?.let { pinned -> releases.find { it.tagName == pinned } } + ?: releases.firstOrNull { !it.isDevRelease() } + ?: releases.firstOrNull() + ?: return ResolvedSource(source = source, error = "No releases found") + + // Classify against this source's release list so the LED + badge can + // distinguish "latest stable" from "older stable" from "dev". + val latestStableTag = releases.firstOrNull { !it.isDevRelease() }?.tagName + val latestDevTag = releases.firstOrNull { it.isDevRelease() }?.tagName + val channel = when { + release.isDevRelease() && release.tagName == latestDevTag -> Channel.DEV_LATEST + release.isDevRelease() -> Channel.DEV_OLDER + release.tagName == latestStableTag -> Channel.STABLE_LATEST + else -> Channel.STABLE_OLDER + } + + val downloadResult = repo.downloadPatches(release) + val patchFile = downloadResult.getOrNull() + ?: return ResolvedSource( + source = source, + error = "Could not download patches: ${downloadResult.exceptionOrNull()?.message}", + ) + + return ResolvedSource( + source = source, + patchFile = patchFile, + resolvedVersion = release.tagName, + isOffline = false, + channel = channel, + ) + } + + private fun findCachedPatchFile(repo: PatchRepository): File? { + val cacheDir = repo.getCacheDir() + return cacheDir.listFiles { file -> + val ext = file.extension.lowercase() + (ext == "mpp" || ext == "jar") && file.length() > 0 + }?.maxByOrNull { it.lastModified() } + } + + private fun versionFromFilename(file: File): String { + val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(file.nameWithoutExtension) + return match?.value ?: file.nameWithoutExtension + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt index c229a158..45532cab 100644 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -5,18 +5,22 @@ package app.morphe.gui.util +import app.morphe.engine.MorpheData import java.io.File -import java.nio.file.Paths import java.util.zip.ZipFile /** * Platform-agnostic file utilities. * Handles app directories, temp files, and cross-platform path operations. + * + * Directory paths delegate to [MorpheData] (the engine-level single source of + * truth) so the GUI, CLI, and any future surface all agree on where data + * lives. The previous per-OS app-data folders (`%APPDATA%/morphe-gui`, + * `~/Library/Application Support/morphe-gui`, `~/.config/morphe-gui`) are + * superseded by `MorpheData.root` — see `unified-data-location-plan.md`. */ object FileUtils { - private const val APP_NAME = "morphe-gui" - /** * All modern Android architectures. Obsolete architectures such as Mips are not included. */ @@ -25,64 +29,25 @@ object FileUtils { private val EXTENSION_APK_BUNDLES = setOf("apkm", "xapk", "apks") private val EXTENSION_APK_ANY = EXTENSION_APK_BUNDLES + "apk" - /** - * Get the app data directory based on OS. - * - Windows: %APPDATA%/morphe-gui - * - macOS: ~/Library/Application Support/morphe-gui - * - Linux: ~/.config/morphe-gui - */ - fun getAppDataDir(): File { - val osName = System.getProperty("os.name").lowercase() - val userHome = System.getProperty("user.home") + /** Returns the unified Morphe data root. Was: per-OS app-data folder. */ + fun getAppDataDir(): File = MorpheData.root - val appDataPath = when { - osName.contains("win") -> { - val appData = System.getenv("APPDATA") ?: Paths.get(userHome, "AppData", "Roaming").toString() - Paths.get(appData, APP_NAME) - } - osName.contains("mac") -> { - Paths.get(userHome, "Library", "Application Support", APP_NAME) - } - else -> { - // Linux and others - Paths.get(userHome, ".config", APP_NAME) - } - } - - return appDataPath.toFile().also { it.mkdirs() } - } - - /** - * Get the patches cache directory. - */ - fun getPatchesDir(): File { - return File(getAppDataDir(), "patches").also { it.mkdirs() } - } + /** Returns the patches cache directory. */ + fun getPatchesDir(): File = MorpheData.patchesDir - /** - * Get the logs directory. - */ - fun getLogsDir(): File { - return File(getAppDataDir(), "logs").also { it.mkdirs() } - } + /** Returns the logs directory. */ + fun getLogsDir(): File = MorpheData.logsDir - /** - * Get the config file path. - */ - fun getConfigFile(): File { - return File(getAppDataDir(), "config.json") - } + /** Returns the GUI config file path. */ + fun getConfigFile(): File = MorpheData.configFile - /** - * Get the app temp directory for patching operations. - */ - fun getTempDir(): File { - val systemTemp = System.getProperty("java.io.tmpdir") - return File(systemTemp, APP_NAME).also { it.mkdirs() } - } + /** Returns the patcher-scratch directory shared with the CLI. */ + fun getTempDir(): File = MorpheData.tmpDir /** - * Create a unique temp directory for a patching session. + * Create a unique temp directory for a patching session. Session-scoped + * timestamp keeps concurrent CLI/GUI patches from stepping on each other + * (see Phase 6 of the unified-data-location plan). */ fun createPatchingTempDir(): File { val timestamp = System.currentTimeMillis() diff --git a/src/main/kotlin/app/morphe/gui/util/Logger.kt b/src/main/kotlin/app/morphe/gui/util/Logger.kt index 31cef73d..b05e95a0 100644 --- a/src/main/kotlin/app/morphe/gui/util/Logger.kt +++ b/src/main/kotlin/app/morphe/gui/util/Logger.kt @@ -14,7 +14,10 @@ import java.util.* /** * Simple file logger with rotation support. - * Logs to ~/.morphe-gui/logs/morphe-gui.log + * + * Log file location: `/logs/morphe-gui.log` — JAR-adjacent + * `morphe-data/logs/` for shipped jars, `~/morphe/logs/` for IDE/dev runs. + * See [app.morphe.engine.MorpheData] for the full resolution + fallback rules. */ object Logger { diff --git a/src/main/kotlin/app/morphe/gui/util/PatchLoadErrorMessage.kt b/src/main/kotlin/app/morphe/gui/util/PatchLoadErrorMessage.kt new file mode 100644 index 00000000..551b5a8e --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/PatchLoadErrorMessage.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.util + +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.HttpRequestTimeoutException +import java.io.IOException +import java.net.UnknownHostException + +/** + * Map a load failure exception to a short, user-readable line. + * + * The raw `Exception.message` is hostile when the underlying cause is a + * coroutine/Ktor internal — users see "StandaloneCoroutine was cancelled" + * and assume the app crashed. This translates the common network/IO failures + * into plain English and falls back to the original message for anything we + * don't recognize. + * + * Intentionally does NOT handle CancellationException — that should never + * reach the UI; callers must re-throw it from their catch blocks instead of + * surfacing it as an error. + */ +fun humanizePatchLoadError(e: Throwable): String = when (e) { + is HttpRequestTimeoutException, + is SocketTimeoutException, + is ConnectTimeoutException -> "Network timeout — check your connection and try again" + + is UnknownHostException -> "Couldn't reach the patch server — check your connection" + + is IOException -> { + val msg = e.message.orEmpty() + when { + msg.contains("rate limit", ignoreCase = true) -> + "GitHub rate limit hit — wait a few minutes and try again" + msg.contains("connection reset", ignoreCase = true) || + msg.contains("connection closed", ignoreCase = true) -> + "Connection dropped while downloading — try again" + else -> msg.ifBlank { "Network error while loading patches" } + } + } + + else -> e.message?.takeIf { it.isNotBlank() } ?: "Could not load patches" +} diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 53513874..5f96e184 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -77,7 +77,7 @@ class PatchService { * Delegates to PatchEngine for the actual pipeline. */ suspend fun patch( - patchesFilePath: String, + patchesFilePaths: List, inputApkPath: String, outputApkPath: String, enabledPatches: List = emptyList(), @@ -93,23 +93,29 @@ class PatchService { onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { try { - val patchFile = File(patchesFilePath) + if (patchesFilePaths.isEmpty()) { + return@withContext Result.failure(Exception("No patches files supplied")) + } + val patchFiles = patchesFilePaths.map { File(it) } val inputApk = File(inputApkPath) val outputFile = File(outputApkPath) - if (!patchFile.exists()) { - return@withContext Result.failure(Exception("Patches file not found")) + patchFiles.firstOrNull { !it.exists() }?.let { + return@withContext Result.failure(Exception("Patches file not found: ${it.name}")) } if (!inputApk.exists()) { return@withContext Result.failure(Exception("Input APK not found")) } - // Load patches (copy to temp to avoid Windows file lock) + // Load patches (copy each to temp to avoid Windows file lock) onProgress("Loading patches...") - val patchTempCopy = File.createTempFile("morphe-patches-", ".mpp") + val tempCopies = patchFiles.map { src -> + val tmp = File.createTempFile("morphe-patches-", ".mpp") + src.copyTo(tmp, overwrite = true) + tmp + } try { - patchFile.copyTo(patchTempCopy, overwrite = true) - val loadedPatches = loadPatchesFromJar(setOf(patchTempCopy)) + val loadedPatches = loadPatchesFromJar(tempCopies.toSet()) // Convert GUI's flat "patchName.optionKey" -> value map // to engine's Map> format @@ -144,14 +150,25 @@ class PatchService { val engineResult = PatchEngine.patch(config, onProgress) + val failureReason = if (engineResult.success) null else { + // Prefer a specific failed-patch error, else the last failed + // step's error (rebuild/sign), else a generic fallback. + engineResult.failedPatches.firstOrNull()?.let { fp -> + "${fp.name}: ${fp.error.lineSequence().first()}" + } + ?: engineResult.stepResults.lastOrNull { !it.success && it.error != null } + ?.let { "${it.step.name.lowercase().replaceFirstChar { c -> c.uppercase() }} failed: ${it.error}" } + ?: "Patching failed for an unknown reason" + } Result.success(PatchResult( success = engineResult.success, outputPath = engineResult.outputPath, appliedPatches = engineResult.appliedPatches, failedPatches = engineResult.failedPatches.map { it.name }, + failureReason = failureReason, )) } finally { - patchTempCopy.delete() + tempCopies.forEach { runCatching { it.delete() } } } } catch (e: Exception) { Logger.error("Patching failed", e) @@ -159,25 +176,59 @@ class PatchService { } } + /** + * Convert a set of already-loaded library patches into GUI patches. + * Used by EnabledSourcesLoader / MultiSourceLoader paths so we don't have to + * re-open the .mpp file just to convert. + */ + fun convertToGuiPatches(loaded: Set>): List = + loaded.map { it.toGuiPatch() } + /** * Convert library Patch to GUI Patch model. + * + * Reads BOTH the new [compatibility] API and the deprecated [compatiblePackages] + * field — some forks (e.g. hoo-dles) compiled their patches against the older + * patcher API and only declare compatibility via the legacy field. Without the + * fallback, those patches would convert to a GUI Patch with empty + * compatiblePackages, which means SupportedAppExtractor under-counts apps and + * the per-source attribution map misses entire sources. */ + @Suppress("DEPRECATION") private fun LibraryPatch<*>.toGuiPatch(): Patch { - return Patch( - name = this.name ?: "Unknown", - description = this.description ?: "", - compatiblePackages = this.compatibility - ?.mapNotNull { compatibility -> - val packageName = compatibility.packageName ?: return@mapNotNull null - val (experimental, stable) = compatibility.targets.partition { it.isExperimental } + // Primary: new compatibility API (typed, with experimental flag, display name). + val fromNewApi: List = this.compatibility + ?.mapNotNull { compatibility -> + val packageName = compatibility.packageName ?: return@mapNotNull null + val (experimental, stable) = compatibility.targets.partition { it.isExperimental } + CompatiblePackage( + name = packageName, + displayName = compatibility.name, + versions = stable.mapNotNull { it.version }, + experimentalVersions = experimental.mapNotNull { it.version } + ) + } + ?: emptyList() + + // Fallback: legacy compatiblePackages field (Set>). + // No display name or experimental flag in the legacy schema — those stay null/empty. + val fromLegacyApi: List = if (fromNewApi.isEmpty()) { + this.compatiblePackages + ?.map { (pkgName, versions) -> CompatiblePackage( - name = packageName, - displayName = compatibility.name, - versions = stable.mapNotNull { it.version }, - experimentalVersions = experimental.mapNotNull { it.version } + name = pkgName, + displayName = null, + versions = versions?.toList() ?: emptyList(), + experimentalVersions = emptyList(), ) } - ?: emptyList(), + ?: emptyList() + } else emptyList() + + return Patch( + name = this.name ?: "Unknown", + description = this.description ?: "", + compatiblePackages = fromNewApi.ifEmpty { fromLegacyApi }, options = this.options.values.map { opt -> PatchOption( key = opt.key, @@ -220,5 +271,9 @@ data class PatchResult( val success: Boolean, val outputPath: String, val appliedPatches: List, - val failedPatches: List + val failedPatches: List, + // Human-readable reason for [success == false]. Populated from the first + // failed patch's error or — when patching succeeded but a later step + // (rebuild, sign) blew up — that step's error. Null on success. + val failureReason: String? = null, )