diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml
index e030f62a5..bcb961770 100644
--- a/.github/workflows/build_pull_request.yml
+++ b/.github/workflows/build_pull_request.yml
@@ -2,10 +2,14 @@ name: Build pull request
on:
workflow_dispatch:
- push:
+ pull_request:
branches:
- dev
+concurrency:
+ group: compile-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
build:
runs-on: ubuntu-latest
diff --git a/.github/workflows/compile_pull_request.yml b/.github/workflows/compile_pull_request.yml
deleted file mode 100644
index 70750c8d7..000000000
--- a/.github/workflows/compile_pull_request.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-name: Compile pull request
-
-on:
- pull_request:
- branches:
- - dev
-
-concurrency:
- group: compile-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Java
- uses: actions/setup-java@v5
- with:
- distribution: 'temurin'
- java-version: '17'
-
- - name: Cache Gradle
- uses: burrunan/gradle-cache-action@v3
-
- - name: Build Debug APK
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- _JAVA_OPTIONS: "-Djava.awt.headless=true"
- run: |
- chmod +x ./gradlew
- ./gradlew assembleDebug -PnoProguard -PsignAsDebug --stacktrace
diff --git a/.github/workflows/crowdin_pull.yml b/.github/workflows/crowdin_pull.yml
index 07a4391b7..19494a982 100644
--- a/.github/workflows/crowdin_pull.yml
+++ b/.github/workflows/crowdin_pull.yml
@@ -33,10 +33,6 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- - name: Push crowdin branch
- run: |
- git checkout -B crowdin
- git push --force origin crowdin
merge:
name: Squash merge Crowdin into Dev
if: github.event_name == 'workflow_dispatch'
@@ -53,6 +49,9 @@ jobs:
fetch-depth: 0
clean: true
+ - name: Cache Gradle
+ uses: burrunan/gradle-cache-action@v3
+
# Step 2: Compile Android app to check string resources
- name: Compile Android app to check resources
env:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6916aae3a..68fb888db 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,6 +2,11 @@ name: Release
on:
workflow_dispatch:
+ inputs:
+ send_fcm:
+ description: 'Send FCM push notification'
+ type: boolean
+ default: true
push:
branches:
- main
@@ -25,7 +30,7 @@ jobs:
uses: actions/setup-java@v5
with:
distribution: 'temurin'
- java-version: '17'
+ java-version: '21'
- name: Cache Gradle
uses: burrunan/gradle-cache-action@v3
@@ -60,31 +65,6 @@ jobs:
KEYSTORE_ENTRY_ALIAS: ${{ secrets.KEYSTORE_ENTRY_ALIAS }}
KEYSTORE_ENTRY_PASSWORD: ${{ secrets.KEYSTORE_ENTRY_PASSWORD }}
- - name: Copy release files to legacy path
- # TODO: Eventually delete this logic
- if: steps.release.outputs.new_release_published == 'true'
- run: |
- git config user.name "semantic-release-bot"
- git config user.email "semantic-release-bot@martynus.net"
-
- # Make sure we're rebasing onto the latest remote branch
- git fetch origin
-
- # Hard reset to latest remote branch
- git checkout ${GITHUB_REF_NAME}
- git reset --hard origin/${GITHUB_REF_NAME}
-
- cp app-release.json app/app-release.json
- cp CHANGELOG.md app/CHANGELOG.md
-
- git add app/app-release.json
- git add app/CHANGELOG.md
-
- git commit -m "chore: Update legacy release files"
-
- # Push explicitly to the branch that triggered the workflow
- git push origin HEAD:${GITHUB_REF_NAME}
-
- name: Attest
if: steps.release.outputs.new_release_published == 'true'
uses: actions/attest-build-provenance@v2
@@ -114,17 +94,17 @@ jobs:
})
- name: Wait before sending FCM
- if: steps.release.outputs.new_release_published == 'true'
+ if: steps.release.outputs.new_release_published == 'true' && inputs.send_fcm != false
run: sleep 480
- name: Setup Python for FCM
- if: steps.release.outputs.new_release_published == 'true'
+ if: steps.release.outputs.new_release_published == 'true' && inputs.send_fcm != false
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Send FCM push notification
- if: steps.release.outputs.new_release_published == 'true'
+ if: steps.release.outputs.new_release_published == 'true' && inputs.send_fcm != false
env:
FCM_PROJECT_ID: ${{ secrets.FCM_PROJECT_ID }}
FCM_SERVICE_ACCOUNT_JSON: ${{ secrets.FCM_SERVICE_ACCOUNT_JSON }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfe58de96..7db5c4c04 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,177 @@
+# [1.16.0-dev.16](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.15...v1.16.0-dev.16) (2026-04-30)
+
+
+### Bug Fixes
+
+* "SessionBasedInstallConfirmationActivity was finished by user" install error on some devices ([3e74857](https://github.com/MorpheApp/morphe-manager/commit/3e74857bdb6fab5815f04c52b137024188a60dc3))
+* Scope `InstalledAppInfoViewModel` to dialog instance via dialog token ([0cf2f6a](https://github.com/MorpheApp/morphe-manager/commit/0cf2f6a7c451fe1bd79538684babc3e1376b7f41))
+
+# [1.16.0-dev.15](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.14...v1.16.0-dev.15) (2026-04-29)
+
+
+### Bug Fixes
+
+* Show reinstall button and installer dialog for deleted apps ([472d046](https://github.com/MorpheApp/morphe-manager/commit/472d0462959235a90c0033129f87dd22a7af621d))
+* Update home screen cards immediately after install/uninstall ([8f671bd](https://github.com/MorpheApp/morphe-manager/commit/8f671bdc350e1daa576640e13bbd778d78382f73))
+
+# [1.16.0-dev.14](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.13...v1.16.0-dev.14) (2026-04-28)
+
+
+### Bug Fixes
+
+* File picker and export for Android TV ([#491](https://github.com/MorpheApp/morphe-manager/issues/491)) ([7c1cfba](https://github.com/MorpheApp/morphe-manager/commit/7c1cfba98ff4b2eb39aa0e8a6f14e028978f1f60))
+
+
+### Features
+
+* Add manual `JKS` parser for keystore import without BC provider dependency ([#494](https://github.com/MorpheApp/morphe-manager/issues/494)) ([ccc99a2](https://github.com/MorpheApp/morphe-manager/commit/ccc99a24b410dff2f04d8c74b8d0916b64d7d762))
+
+# [1.16.0-dev.13](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.12...v1.16.0-dev.13) (2026-04-27)
+
+
+### Bug Fixes
+
+* Replace `isLoaded` flag with `BundleState` sealed class and simplify `homeAppState` ([72976d3](https://github.com/MorpheApp/morphe-manager/commit/72976d33aea02e3aad6639fe7ff1ec1f4d330a0c))
+
+
+### Features
+
+* Add random background mode with rotation interval ([2d12fbb](https://github.com/MorpheApp/morphe-manager/commit/2d12fbbb052c3fe1fae979db7ad1d13b83918da9))
+* Import keystore from `PKCS12`, `BKS` and `JKS` formats ([3f38387](https://github.com/MorpheApp/morphe-manager/commit/3f38387f515c351b5c4c248a9f682b46af03de85))
+
+# [1.16.0-dev.12](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.11...v1.16.0-dev.12) (2026-04-26)
+
+
+### Bug Fixes
+
+* Show SDK-incompatible versions as disabled, block patching when no versions are compatible with device SDK ([f90d5ba](https://github.com/MorpheApp/morphe-manager/commit/f90d5bacc7198f2c0a3c7cf48c7745415d89e141))
+
+
+### Features
+
+* Open `.mpp` patch sources directly from file manager ([#483](https://github.com/MorpheApp/morphe-manager/issues/483)) ([f46a11f](https://github.com/MorpheApp/morphe-manager/commit/f46a11f91e88d948ecbab8e71200cec543ce48ec))
+
+# [1.16.0-dev.11](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.10...v1.16.0-dev.11) (2026-04-24)
+
+
+### Bug Fixes
+
+* Patch bundles do not load on Android 8.0 devices ([3116619](https://github.com/MorpheApp/morphe-manager/commit/3116619cee63697dd71d1c22b95be11cec78384e))
+* Resolve display name from bundle metadata over patched APK label ([6c6e065](https://github.com/MorpheApp/morphe-manager/commit/6c6e0658d4d4e2a415db1342764de45bd5595c04))
+
+
+### Features
+
+* Live patching progress in foreground notification ([c25af8f](https://github.com/MorpheApp/morphe-manager/commit/c25af8f28e7f33d90f8db62b74f5eb1128680e95))
+
+# [1.16.0-dev.10](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.9...v1.16.0-dev.10) (2026-04-24)
+
+
+### Bug Fixes
+
+* Resolve app icon from saved APK when app is not installed ([fe3ef6c](https://github.com/MorpheApp/morphe-manager/commit/fe3ef6cfaea6bb68c11bd89a127a1122d3cdb943))
+
+
+### Features
+
+* Add `BundleAppMetadata` as a data source for `AppDataResolver` ([3bdc1f5](https://github.com/MorpheApp/morphe-manager/commit/3bdc1f5299b7429444c7266559806aa1ad6aa8d1))
+* Open patches dialog on hidden app tap in search ([1898e74](https://github.com/MorpheApp/morphe-manager/commit/1898e74fe8240961ddda118c2f7fac7f48bacb76))
+
+# [1.16.0-dev.9](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.8...v1.16.0-dev.9) (2026-04-24)
+
+
+### Bug Fixes
+
+* Check primary ABI only in `isArmV7` to avoid false positives on `ArmV8` devices ([14729c2](https://github.com/MorpheApp/morphe-manager/commit/14729c28a73c0294152c3df1aabffdfb79974215))
+* Fall back to `Downloads` export on devices without `DocumentsUI` (Android TV) ([1e21c39](https://github.com/MorpheApp/morphe-manager/commit/1e21c3957e37351d1498b9894d2e4c3ee8154608))
+
+# [1.16.0-dev.8](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.7...v1.16.0-dev.8) (2026-04-24)
+
+
+### Bug Fixes
+
+* Adapt accent color contrast for extreme black/white values in app info dialog ([c4f883f](https://github.com/MorpheApp/morphe-manager/commit/c4f883ffbdbb1bd3e7b2085939cb050fa747a11b))
+* Always respect manager prerelease preference for update channel ([090ee0c](https://github.com/MorpheApp/morphe-manager/commit/090ee0ca1ed8d076b58dd3abe4ffa8f8f2062203))
+* Show swipe gesture hint on every custom bundle addition ([0c66503](https://github.com/MorpheApp/morphe-manager/commit/0c665038ed00234ed13b5df32b180382ecfa2f12))
+
+
+### Features
+
+* Adaptive two-column layout for `InstalledAppInfoDialog` on tablets ([40a29a9](https://github.com/MorpheApp/morphe-manager/commit/40a29a96967ef06eca27cb5f62db8d821f93c4aa))
+* Add swipe gestures to hidden apps dialog and search results ([8cf1f13](https://github.com/MorpheApp/morphe-manager/commit/8cf1f137e8cf346a5628c80098e04aecf86f2441))
+
+# [1.16.0-dev.7](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.6...v1.16.0-dev.7) (2026-04-24)
+
+
+### Bug Fixes
+
+* `Session is dead` error on Pixels devices when installing apps ([#458](https://github.com/MorpheApp/morphe-manager/issues/458)) ([ce1ce6e](https://github.com/MorpheApp/morphe-manager/commit/ce1ce6e195a4138c7ca18f5a9dd018db8591fddc))
+
+
+### Features
+
+* Improve patch visibility in bundle and app patch dialogs ([#457](https://github.com/MorpheApp/morphe-manager/issues/457)) ([1881991](https://github.com/MorpheApp/morphe-manager/commit/188199176dba9fe41115e2376fe7ade3138c8068))
+
+# [1.16.0-dev.6](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.5...v1.16.0-dev.6) (2026-04-20)
+
+
+### Bug Fixes
+
+* System installer couldn't update an already installed app ([#455](https://github.com/MorpheApp/morphe-manager/issues/455)) ([adc93e4](https://github.com/MorpheApp/morphe-manager/commit/adc93e4bab2d9b153b2aecd9f0671b393e42d309))
+* When greeting message is disabled, show a small top spacer so the app cards don't sit flush against the top of the screen ([6900cc2](https://github.com/MorpheApp/morphe-manager/commit/6900cc226e189b97c747d6d53f5fba98ade6ccf0))
+
+
+### Features
+
+* Sort universal patches to bottom of each bundle in patches dialog ([eac672e](https://github.com/MorpheApp/morphe-manager/commit/eac672e51f1fe3007bba46af6321d833ac20fb4b))
+
+# [1.16.0-dev.5](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.4...v1.16.0-dev.5) (2026-04-19)
+
+
+### Bug Fixes
+
+* Shizuku installer couldn't update an already installed app ([#454](https://github.com/MorpheApp/morphe-manager/issues/454)) ([d4e74e3](https://github.com/MorpheApp/morphe-manager/commit/d4e74e3a84ca34529f8d20005c19b2b0e7c9136f))
+
+# [1.16.0-dev.4](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.3...v1.16.0-dev.4) (2026-04-19)
+
+
+### Bug Fixes
+
+* Add logging and fix stale installer cache ([78b5aee](https://github.com/MorpheApp/morphe-manager/commit/78b5aee8dca5f1f51fb6bb46c9d9cab8bd1f7c0a))
+* Merge 'Filter split APKs' and `Remove unused native libraries` into 'Optimize for device architecture' setting ([2edb15f](https://github.com/MorpheApp/morphe-manager/commit/2edb15fcfb0fd018ffcafc0f7203930a203ab4d4))
+
+
+### Features
+
+* Add swipe gestures and multi-select to app buttons on main screen ([#446](https://github.com/MorpheApp/morphe-manager/issues/446)) ([0330699](https://github.com/MorpheApp/morphe-manager/commit/033069989d24c437798bb5a7bf248afe9f30ae89))
+* Add toggle to disable home screen patching phrases ([#443](https://github.com/MorpheApp/morphe-manager/issues/443)) ([f53ad64](https://github.com/MorpheApp/morphe-manager/commit/f53ad646c90700af17b3d58b0a2651cdfb87aab9))
+
+# [1.16.0-dev.3](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.2...v1.16.0-dev.3) (2026-04-18)
+
+
+### Bug Fixes
+
+* Handle `InstallFailure` result when installing manager update ([a4d1eb8](https://github.com/MorpheApp/morphe-manager/commit/a4d1eb8a99dc53d59f23df3d7bb8ad3725edd1c4))
+
+
+### Features
+
+* Migrate to `Ackpine` for package installation/uninstallation ([#444](https://github.com/MorpheApp/morphe-manager/issues/444)) ([aa7207d](https://github.com/MorpheApp/morphe-manager/commit/aa7207d486427753eb56c18ddd29d481f1a3605e))
+
+# [1.16.0-dev.2](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.1...v1.16.0-dev.2) (2026-04-18)
+
+
+### Features
+
+* Add fast bytecode mode setting to expert mode ([#403](https://github.com/MorpheApp/morphe-manager/issues/403)) ([e73c63c](https://github.com/MorpheApp/morphe-manager/commit/e73c63c7fcce14d5d38ce8b8cde26feed4a6e5e4))
+
+# [1.16.0-dev.1](https://github.com/MorpheApp/morphe-manager/compare/v1.15.0...v1.16.0-dev.1) (2026-04-17)
+
+
+### Features
+
+* Store merged APK from split archives as original for repatching ([#438](https://github.com/MorpheApp/morphe-manager/issues/438)) ([be0b868](https://github.com/MorpheApp/morphe-manager/commit/be0b86866f5e9f5e8aad4b596158084d03189fa3))
+
# [1.15.0](https://github.com/MorpheApp/morphe-manager/compare/v1.14.0...v1.15.0) (2026-04-17)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..22a32f228
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,16 @@
+# 👋 Contribution guidelines
+
+This document describes how to contribute to the Morphe Manager.
+
+## 📝 How to contribute
+
+1. Before contributing, it is recommended to [open an issue](https://github.com/MorpheApp/morphe-manager/issues/new?labels=Feature+request&projects=&template=feature_request.yml&title=feat%3A+)
+ to discuss your change. This will help you determine whether your change is worth your time to implement it.
+2. Development happens on the `dev` branch. Fork the repository and create your branch from `dev`.
+3. Commit your changes.
+4. Submit a pull request to the `dev` branch of the repository and reference issues
+ that your pull request closes in the description of your pull request.
+5. Our team will review your pull request and provide feedback. Once your pull request is approved,
+ it will be merged into the `dev` branch and will be included in the next pre-release of Morphe.
+
+❤️ Thank you for considering contributing to the Morphe Manager.
diff --git a/README.md b/README.md
index deab66133..b85cef2a8 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ The website will guide you to the latest release for your device. No account nee
3. **Choose your mode:**
- **Simple mode** - designed for a one-tap experience. Just tap Patch and Morphe handles the rest with sensible defaults. No configuration needed.
- **Expert mode** - gives you full control. Choose exactly which of the 100+ patches to apply, configure per-patch options (colors, toggles, and more), and fine-tune everything before patching.
-4. **Provide the APK** - Morphe guides you through obtaining the original app file via step-by-step dialogs. The patching itself happens entirely on your device.
+4. **Provide the APK** - Morphe guides you through obtaining the original app file via step-by-step dialogs. The patching itself happens entirely on your device. *(If you can't select the APK file, try moving it out of the Downloads folder to the root of your internal storage.)*
5. **Install and enjoy** - once patching is complete, install the result like any normal APK.
Everything happens locally. Morphe never uploads your APKs or personal data anywhere.
@@ -59,6 +59,8 @@ Everything happens locally. Morphe never uploads your APKs or personal data anyw
- Expert mode also shows an expanded patching screen with real-time logs and live RAM usage monitoring during patching
- 100+ patches for YouTube, YouTube Music, and Reddit
- Support for split APKs
+- Skips split APK modules for unsupported CPU architectures, locales, and screen densities during merge
+- Strips native libraries for unsupported architectures from plain APKs after patching
**Patch options** *(Simple mode: available in the Advanced tab; Expert mode: available on the patch selection screen)*
- Custom app display name and header logo per app
@@ -91,6 +93,7 @@ Everything happens locally. Morphe never uploads your APKs or personal data anyw
- Manage saved patch selections per app
- GitHub Personal Access Token support for higher API rate limits
- Process runtime - run patching in a separate process for better stability, with configurable memory limit
+- Bytecode processing mode - controls how bytecode is processed during patching, affecting patching speed, memory usage, and output APK size
- Export debug logs for troubleshooting
## ❓ New to GitHub?
@@ -106,6 +109,11 @@ That's it. Once Morphe is installed, everything else happens inside the app.
For guides, FAQs, and troubleshooting, visit **[morphe.software](https://morphe.software)** or join the community on **[Reddit](https://www.reddit.com/r/MorpheApp)**.
+## 📙 Contributing
+
+Thank you for considering contributing to Morphe Manager.
+You can find the contribution guidelines [here](CONTRIBUTING.md).
+
## ❗ About
Morphe is built on the foundation of [ReVanced Manager](https://github.com/ReVanced/revanced-manager) and [URV](https://github.com/Jman-Github/Universal-ReVanced-Manager). All changes made by Morphe are documented in the Git history.
diff --git a/app-release.json b/app-release.json
index 752226b66..d1fbcc747 100644
--- a/app-release.json
+++ b/app-release.json
@@ -1,7 +1,7 @@
{
- "created_at": "2026-04-17T06:32:20.437Z",
- "description": "# [1.15.0](https://github.com/MorpheApp/morphe-manager/compare/v1.14.0...v1.15.0) (2026-04-17)\n\n\n### Bug Fixes\n\n* Adjust wording ([482c1d1](https://github.com/MorpheApp/morphe-manager/commit/482c1d18945cfdc63bb54d7d112b0ec7ce4f58ba))\n* Cancel patcher worker immediately on user cancellation ([4f0b312](https://github.com/MorpheApp/morphe-manager/commit/4f0b3124052a0975a94a38f0a519ab8e340ec318))\n* Don't count empty patch selections in package badge ([e073ecf](https://github.com/MorpheApp/morphe-manager/commit/e073ecf279d5d605198a039b6325226f1d3feec2))\n* Improve APK load error messages with distinct failure reasons ([3174f28](https://github.com/MorpheApp/morphe-manager/commit/3174f28480e1857ae689dee26806ed513ad980f9))\n* Interrupt split APK merger immediately on cancellation ([0f7feca](https://github.com/MorpheApp/morphe-manager/commit/0f7fecabfd24ea588755c2ddfc2a4661c0783b83))\n* Re-download bundle if version matches but createdAt differs ([2e77833](https://github.com/MorpheApp/morphe-manager/commit/2e77833cca08fb1c5c52bebe11dd032269099f9c))\n* Refresh patch options only once on bundle load ([bf04846](https://github.com/MorpheApp/morphe-manager/commit/bf0484648ea76dfedbd778716fd75c65f1538f4f))\n* Serialize `StringList` options based on patcher type ([8464f34](https://github.com/MorpheApp/morphe-manager/commit/8464f34f280b02beab4965dd62f3d9cfc3653979))\n* Show failing bundle name in error toast and auto-disable bundles on fetch failure ([1c3a384](https://github.com/MorpheApp/morphe-manager/commit/1c3a3843f3989621764dfb8582e926170e686fa9))\n* Show full patching log in error dialog when no specific error is captured ([f18d826](https://github.com/MorpheApp/morphe-manager/commit/f18d8267cc0b356139ce4a9299548d45097624d5))\n* Show success toast after bundle import completes ([74d05cb](https://github.com/MorpheApp/morphe-manager/commit/74d05cb46d74ea489ad2c454706a9c0a4cf4a1c5))\n* Skip disabled installed apps in AppDataResolver ([8eaa88b](https://github.com/MorpheApp/morphe-manager/commit/8eaa88bda6e4fe657924355eaed1b3fe87f045b1))\n* Use `GetContent` instead of `OpenDocument` for APK/bundle pickers ([cb3551d](https://github.com/MorpheApp/morphe-manager/commit/cb3551d13ac490b2e74eb7ec111369e278e32efe))\n\n\n### Features\n\n* Add Android TV launcher support ([38f2703](https://github.com/MorpheApp/morphe-manager/commit/38f27030d4c80b1873af37c21454206fc86ec372))\n* Add Expert badge to patch bundle viewer ([169ff75](https://github.com/MorpheApp/morphe-manager/commit/169ff751ba839b50aeebb03c07801688a8dd2cbe))\n* Add import/export selection buttons in patch selection dialog ([c5b4ef6](https://github.com/MorpheApp/morphe-manager/commit/c5b4ef658e05a34198233ffe897e05499454ca18))\n* Add saved selection button in expert mode dialog ([ee336d8](https://github.com/MorpheApp/morphe-manager/commit/ee336d865e71e4a597924a302326ef2d5c638805))\n* Export/import third-party bundles with manager settings ([e5c826f](https://github.com/MorpheApp/morphe-manager/commit/e5c826fb81c725cb6ea3b614f2a04a924350f05a))\n* Group compatible versions by bundle in APK availability dialog ([#432](https://github.com/MorpheApp/morphe-manager/issues/432)) ([362d097](https://github.com/MorpheApp/morphe-manager/commit/362d09744c51844774c1e9555580e0ed7fcdbfa1))\n* Show bottom bar labels in main screen ([2d4fd8d](https://github.com/MorpheApp/morphe-manager/commit/2d4fd8d3c2180c9443e65c8d0a9c23bcb2586e13))\n* Show update date for single default bundle in management sheet ([16e81bb](https://github.com/MorpheApp/morphe-manager/commit/16e81bbde5eff647742eee5414033a4bcce4c98d))",
- "download_url": "https://github.com/MorpheApp/morphe-manager/releases/download/v1.15.0/morphe-manager-1.15.0.apk",
- "signature_download_url": "https://github.com/MorpheApp/morphe-manager/releases/download/v1.15.0/morphe-manager-1.15.0.apk.asc",
- "version": "1.15.0"
+ "created_at": "2026-04-30T21:41:49.760Z",
+ "description": "# [1.16.0-dev.16](https://github.com/MorpheApp/morphe-manager/compare/v1.16.0-dev.15...v1.16.0-dev.16) (2026-04-30)\n\n\n### Bug Fixes\n\n* \"SessionBasedInstallConfirmationActivity was finished by user\" install error on some devices ([3e74857](https://github.com/MorpheApp/morphe-manager/commit/3e74857bdb6fab5815f04c52b137024188a60dc3))\n* Scope `InstalledAppInfoViewModel` to dialog instance via dialog token ([0cf2f6a](https://github.com/MorpheApp/morphe-manager/commit/0cf2f6a7c451fe1bd79538684babc3e1376b7f41))",
+ "download_url": "https://github.com/MorpheApp/morphe-manager/releases/download/v1.16.0-dev.16/morphe-manager-1.16.0-dev.16.apk",
+ "signature_download_url": "https://github.com/MorpheApp/morphe-manager/releases/download/v1.16.0-dev.16/morphe-manager-1.16.0-dev.16.apk.asc",
+ "version": "1.16.0-dev.16"
}
diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md
index bfe58de96..f444f8809 100644
--- a/app/CHANGELOG.md
+++ b/app/CHANGELOG.md
@@ -1287,4 +1287,4 @@
### Features
-* Custom Morphe home screen ([515d08c](https://github.com/MorpheApp/morphe-manager/commit/515d08ce741752d06cbabb7be57bac9fe692d8a6))
+* Custom Morphe home screen ([515d08c](https://github.com/MorpheApp/morphe-manager/commit/515d08ce741752d06cbabb7be57bac9fe692d8a6))
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8ae02604a..a987b2d20 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -62,11 +62,6 @@ dependencies {
implementation(libs.morphe.patcher)
implementation(libs.morphe.library)
- // Exclude xmlpull as it's included in Android already
- configurations.configureEach {
- exclude(group = "xmlpull", module = "xmlpull")
- }
-
implementation(libs.androidx.documentfile)
// Native processes
@@ -80,6 +75,12 @@ dependencies {
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
+ // Ackpine
+ implementation(libs.ackpine.core)
+ implementation(libs.ackpine.ktx)
+ implementation(libs.ackpine.shizuku)
+ implementation(libs.ackpine.shizuku.ktx)
+
// LibSU
implementation(libs.libsu.core)
implementation(libs.libsu.service)
@@ -155,6 +156,7 @@ android {
buildTypes {
debug {
+ applicationIdSuffix = ".debug"
buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
}
@@ -218,7 +220,6 @@ android {
)
jniLibs {
- excludes += "/lib/x86/*.so"
useLegacyPackaging = true
}
}
@@ -258,6 +259,7 @@ kotlin {
freeCompilerArgs.addAll(
"-Xexplicit-backing-fields",
"-Xcontext-parameters",
+ "-opt-in=kotlin.time.ExperimentalTime"
)
}
}
diff --git a/app/google-services.json b/app/google-services.json
index b39d30939..d78abd348 100644
--- a/app/google-services.json
+++ b/app/google-services.json
@@ -23,6 +23,25 @@
"other_platform_oauth_client": []
}
}
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:574971415597:android:5acf99d4cc8260eedc7ca4",
+ "android_client_info": {
+ "package_name": "app.morphe.manager.debug"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyAICrE-tnEhNKXh1vEbfucYWWxOr7NSr_g"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
}
],
"configuration_version": "1"
diff --git a/app/gradle.properties b/app/gradle.properties
index 7307fe49d..ac0712d98 100644
--- a/app/gradle.properties
+++ b/app/gradle.properties
@@ -1 +1 @@
-version = 1.15.0
+version = 1.16.0-dev.16
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 000000000..fdc15b049
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Morphe Debug
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 21430b0da..e87340c4f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -84,6 +84,15 @@
+
+
+
+
+
+
+
+
+
@@ -188,16 +197,6 @@
-
-
-
-
-
-
+
+
diff --git a/app/src/main/java/app/morphe/manager/MainActivity.kt b/app/src/main/java/app/morphe/manager/MainActivity.kt
index e39d714d1..670bde03f 100644
--- a/app/src/main/java/app/morphe/manager/MainActivity.kt
+++ b/app/src/main/java/app/morphe/manager/MainActivity.kt
@@ -8,9 +8,7 @@ import androidx.activity.compose.LocalActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
-import androidx.compose.animation.*
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.tween
+import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
@@ -39,12 +37,16 @@ import app.morphe.manager.ui.screen.HomeScreen
import app.morphe.manager.ui.screen.PatcherScreen
import app.morphe.manager.ui.screen.SettingsScreen
import app.morphe.manager.ui.screen.shared.AnimatedBackground
+import app.morphe.manager.ui.screen.shared.BackgroundType
+import app.morphe.manager.ui.screen.shared.MorpheAnimations
import app.morphe.manager.ui.theme.ManagerTheme
import app.morphe.manager.ui.theme.Theme
import app.morphe.manager.ui.viewmodel.HomeViewModel
import app.morphe.manager.ui.viewmodel.MainViewModel
import app.morphe.manager.ui.viewmodel.PatcherViewModel
+import app.morphe.manager.ui.viewmodel.ThemeSettingsViewModel
import app.morphe.manager.util.UpdateNotificationManager
+import app.morphe.manager.util.hasMppExtension
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@@ -100,6 +102,16 @@ class MainActivity : AppCompatActivity() {
*/
private fun handleDeepLinkIntent(intent: Intent?, vm: MainViewModel) {
val data = intent?.data ?: return
+
+ // Handle .mpp file open from file manager
+ if (intent.action == Intent.ACTION_VIEW && data.scheme in listOf("file", "content")) {
+ if (data.hasMppExtension(contentResolver)) {
+ vm.pendingMppUri = data
+ }
+ // Not .mpp - don't process further regardless
+ return
+ }
+
val isAddSource = data.scheme == "https" &&
data.host == "morphe.software" &&
data.path?.startsWith("/add-source") == true
@@ -116,11 +128,21 @@ class MainActivity : AppCompatActivity() {
private fun MorpheManager(vm: MainViewModel) {
val navController = rememberNavController()
val prefs: PreferencesManager = koinInject()
+ val themeViewModel: ThemeSettingsViewModel = koinViewModel()
val backgroundType by prefs.backgroundType.getAsState()
val enableParallax by prefs.enableBackgroundParallax.getAsState()
+ val randomInterval by prefs.randomBackgroundInterval.getAsState()
+ val resolvedRandomBackground by themeViewModel.resolvedRandomBackground.collectAsStateWithLifecycle()
+
+ // Resolve which background to show whenever RANDOM mode is active or the interval changes
+ LaunchedEffect(backgroundType, randomInterval) {
+ if (backgroundType == BackgroundType.RANDOM) {
+ themeViewModel.resolveRandomBackground(randomInterval)
+ }
+ }
- // Patcher background speed — driven by PatcherViewModel when on patcher screen.
- // Exposed as a top-level mutable state so PatcherScreen can write into it.
+ // Patcher background speed - driven by PatcherViewModel when on patcher screen.
+ // Exposed as top-level mutable state so PatcherScreen can write into it
val patcherBackgroundSpeed = androidx.compose.runtime.remember { androidx.compose.runtime.mutableFloatStateOf(1f) }
val patchingCompleted = androidx.compose.runtime.remember { mutableStateOf(false) }
@@ -138,6 +160,7 @@ private fun MorpheManager(vm: MainViewModel) {
// Show animated background
AnimatedBackground(
type = backgroundType,
+ resolvedType = resolvedRandomBackground,
enableParallax = enableParallax,
speedMultiplier = patcherBackgroundSpeed.floatValue,
patchingCompleted = patchingCompleted.value
@@ -147,38 +170,10 @@ private fun MorpheManager(vm: MainViewModel) {
NavHost(
navController = navController,
startDestination = HomeScreen,
- enterTransition = {
- slideInHorizontally(
- initialOffsetX = { it },
- animationSpec = tween(400, easing = FastOutSlowInEasing)
- ) + fadeIn(
- animationSpec = tween(400, delayMillis = 100)
- )
- },
- exitTransition = {
- slideOutHorizontally(
- targetOffsetX = { -it / 3 },
- animationSpec = tween(400, easing = FastOutSlowInEasing)
- ) + fadeOut(
- animationSpec = tween(300)
- )
- },
- popEnterTransition = {
- slideInHorizontally(
- initialOffsetX = { -it / 3 },
- animationSpec = tween(400, easing = FastOutSlowInEasing)
- ) + fadeIn(
- animationSpec = tween(400, delayMillis = 100)
- )
- },
- popExitTransition = {
- slideOutHorizontally(
- targetOffsetX = { it },
- animationSpec = tween(400, easing = FastOutSlowInEasing)
- ) + fadeOut(
- animationSpec = tween(300)
- )
- },
+ enterTransition = { MorpheAnimations.screenEnter },
+ exitTransition = { MorpheAnimations.screenExit },
+ popEnterTransition = { MorpheAnimations.screenEnter },
+ popExitTransition = { MorpheAnimations.screenExit }
) {
// Shared state between HomeScreen and PatcherScreen for mount install mode.
// Set by HomeViewModel.resolvePrePatchInstallerChoice()
@@ -207,6 +202,14 @@ private fun MorpheManager(vm: MainViewModel) {
}
}
+ // Handle .mpp file opened from file manager
+ LaunchedEffect(vm.pendingMppUri) {
+ vm.pendingMppUri?.let { uri ->
+ homeViewModel.setPendingMpp(uri)
+ vm.pendingMppUri = null
+ }
+ }
+
HomeScreen(
onSettingsClick = { navController.navigate(Settings) },
onStartQuickPatch = { params ->
diff --git a/app/src/main/java/app/morphe/manager/ManagerApplication.kt b/app/src/main/java/app/morphe/manager/ManagerApplication.kt
index 6723e9bb3..a53875cf4 100644
--- a/app/src/main/java/app/morphe/manager/ManagerApplication.kt
+++ b/app/src/main/java/app/morphe/manager/ManagerApplication.kt
@@ -33,6 +33,7 @@ import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.workmanager.koin.workManagerFactory
import org.koin.core.context.startKoin
import org.lsposed.hiddenapibypass.HiddenApiBypass
+import ru.solrudev.ackpine.Ackpine
class ManagerApplication : Application() {
private val scope = MainScope()
@@ -61,6 +62,9 @@ class ManagerApplication : Application() {
)
}
+ // Enable Ackpine logging
+ Ackpine.enableLogcatLogger()
+
// App icon loader (Coil)
val pixels = 512
Coil.setImageLoader(
@@ -170,5 +174,9 @@ class ManagerApplication : Application() {
deleteRecursively()
mkdirs()
}
+ // Logs all app-private directories and their contents with file sizes on fresh start
+ scope.launch(Dispatchers.IO) {
+ fs.logStorageContents()
+ }
}
}
diff --git a/app/src/main/java/app/morphe/manager/data/platform/Filesystem.kt b/app/src/main/java/app/morphe/manager/data/platform/Filesystem.kt
index 46ea0e8a2..af20043f7 100644
--- a/app/src/main/java/app/morphe/manager/data/platform/Filesystem.kt
+++ b/app/src/main/java/app/morphe/manager/data/platform/Filesystem.kt
@@ -6,12 +6,16 @@ import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
+import android.util.Log
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import app.morphe.manager.util.FilenameUtils
import app.morphe.manager.util.RequestManageStorageContract
+import app.morphe.manager.util.formatBytes
import java.io.File
+private const val TAG = "Morphe Filesystem"
+
class Filesystem(private val app: Application) {
/**
* A directory that gets cleared when the app restarts.
@@ -57,4 +61,31 @@ class Filesystem(private val app: Application) {
val safeVersion = FilenameUtils.sanitize(version.ifBlank { "unspecified" })
return patchedAppsDir.resolve("${safePackage}_${safeVersion}.apk")
}
+
+ /**
+ * Logs all app-private directories and their contents with file sizes.
+ * Useful for diagnosing storage issues on startup.
+ */
+ fun logStorageContents() {
+ Log.i(TAG, "=== Storage contents ===")
+ for (dir in listOf(tempDir, uiTempDir, patchedAppsDir, originalApksDir)) {
+ logDir(dir.name, dir)
+ }
+ Log.i(TAG, "=== End of storage contents ===")
+ }
+
+ private fun logDir(label: String, dir: File, indent: String = "") {
+ val totalSize = dir.walkBottomUp().filter { it.isFile }.sumOf { it.length() }
+ Log.i(TAG, "$indent[$label] ${dir.absolutePath} (total: ${formatBytes(totalSize)})")
+ dir.listFiles()
+ ?.sortedWith(compareBy({ it.isFile }, { it.name }))
+ ?.forEach { entry ->
+ if (entry.isDirectory) {
+ logDir(entry.name, entry, "$indent ")
+ } else {
+ Log.i(TAG, "$indent ${entry.name} (${formatBytes(entry.length())})")
+ }
+ }
+ ?: Log.i(TAG, "$indent (empty or unreadable)")
+ }
}
diff --git a/app/src/main/java/app/morphe/manager/data/room/apps/original/OriginalApk.kt b/app/src/main/java/app/morphe/manager/data/room/apps/original/OriginalApk.kt
index e42a0e3b6..f1f580b05 100644
--- a/app/src/main/java/app/morphe/manager/data/room/apps/original/OriginalApk.kt
+++ b/app/src/main/java/app/morphe/manager/data/room/apps/original/OriginalApk.kt
@@ -3,7 +3,6 @@ package app.morphe.manager.data.room.apps.original
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
-import java.io.File
@Entity(tableName = "original_apks")
data class OriginalApk(
@@ -13,6 +12,4 @@ data class OriginalApk(
@ColumnInfo(name = "file_path") val filePath: String,
@ColumnInfo(name = "last_used") val lastUsed: Long = System.currentTimeMillis(),
@ColumnInfo(name = "file_size") val fileSize: Long
-) {
- fun getFile(): File = File(filePath)
-}
+)
diff --git a/app/src/main/java/app/morphe/manager/di/HttpModule.kt b/app/src/main/java/app/morphe/manager/di/HttpModule.kt
index e5a2f2b4f..e2e323dd6 100644
--- a/app/src/main/java/app/morphe/manager/di/HttpModule.kt
+++ b/app/src/main/java/app/morphe/manager/di/HttpModule.kt
@@ -16,20 +16,17 @@ import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import java.net.Inet4Address
-import java.net.InetAddress
val httpModule = module {
fun provideHttpClient(context: Context, json: Json) = HttpClient(OkHttp) {
engine {
config {
- dns(object : Dns {
- override fun lookup(hostname: String): List {
- val addresses = Dns.SYSTEM.lookup(hostname)
- val ipv4Addresses = addresses.filterIsInstance()
- // Force IPv4 if available, fallback to IPv6 only if no IPv4 addresses are found
- return ipv4Addresses.ifEmpty { addresses }
- }
- })
+ dns { hostname ->
+ val addresses = Dns.SYSTEM.lookup(hostname)
+ val ipv4Addresses = addresses.filterIsInstance()
+ // Force IPv4 if available, fallback to IPv6 only if no IPv4 addresses are found
+ ipv4Addresses.ifEmpty { addresses }
+ }
// Force HTTP/1.1 to avoid intermittent HTTP/2 PROTOCOL_ERROR stream resets when
// downloading patch bundles from GitHub-backed endpoints.
protocols(listOf(Protocol.HTTP_1_1))
diff --git a/app/src/main/java/app/morphe/manager/di/ManagerModule.kt b/app/src/main/java/app/morphe/manager/di/ManagerModule.kt
index b1812a6c0..882639ef6 100644
--- a/app/src/main/java/app/morphe/manager/di/ManagerModule.kt
+++ b/app/src/main/java/app/morphe/manager/di/ManagerModule.kt
@@ -1,8 +1,8 @@
package app.morphe.manager.di
+import app.morphe.manager.domain.installer.AckpineInstaller
import app.morphe.manager.domain.installer.InstallerManager
import app.morphe.manager.domain.installer.RootInstaller
-import app.morphe.manager.domain.installer.ShizukuInstaller
import app.morphe.manager.domain.manager.AppIconManager
import app.morphe.manager.domain.manager.HomeAppButtonPreferences
import app.morphe.manager.domain.manager.KeystoreManager
@@ -16,7 +16,7 @@ val managerModule = module {
singleOf(::KeystoreManager)
singleOf(::PM)
singleOf(::RootInstaller)
- singleOf(::ShizukuInstaller)
+ singleOf(::AckpineInstaller)
singleOf(::InstallerManager)
singleOf(::PatchOptionsPreferencesManager)
singleOf(::AppIconManager)
diff --git a/app/src/main/java/app/morphe/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/morphe/manager/domain/bundles/PatchBundleSource.kt
index 429b0169d..3007104cb 100644
--- a/app/src/main/java/app/morphe/manager/domain/bundles/PatchBundleSource.kt
+++ b/app/src/main/java/app/morphe/manager/domain/bundles/PatchBundleSource.kt
@@ -115,13 +115,12 @@ sealed class PatchBundleSource(
val host = uri.host?.lowercase(java.util.Locale.US) ?: return null
val segments = uri.path?.trim('/')?.split('/')?.filter { it.isNotBlank() } ?: return null
- when {
+ when (host) {
// raw.githubusercontent.com/owner/repo/...
- host == "raw.githubusercontent.com" && segments.isNotEmpty() -> segments[0]
+ "raw.githubusercontent.com" if segments.isNotEmpty() -> segments[0]
// github.com/owner/repo/...
- host == "github.com" && segments.isNotEmpty() -> segments[0]
-
+ "github.com" if segments.isNotEmpty() -> segments[0]
else -> null
}
} catch (_: Exception) {
diff --git a/app/src/main/java/app/morphe/manager/domain/installer/AckpineInstaller.kt b/app/src/main/java/app/morphe/manager/domain/installer/AckpineInstaller.kt
new file mode 100644
index 000000000..55b337791
--- /dev/null
+++ b/app/src/main/java/app/morphe/manager/domain/installer/AckpineInstaller.kt
@@ -0,0 +1,192 @@
+package app.morphe.manager.domain.installer
+
+import android.app.Application
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.util.Log
+import app.morphe.manager.R
+import rikka.shizuku.Shizuku
+import rikka.shizuku.ShizukuProvider
+import rikka.sui.Sui
+import ru.solrudev.ackpine.installer.InstallFailure
+import ru.solrudev.ackpine.installer.PackageInstaller
+import ru.solrudev.ackpine.installer.parameters.InstallParameters
+import ru.solrudev.ackpine.installer.parameters.InstallerType
+import ru.solrudev.ackpine.installer.parameters.PackageSource
+import ru.solrudev.ackpine.session.Session
+import ru.solrudev.ackpine.session.await
+import ru.solrudev.ackpine.session.parameters.Confirmation
+import ru.solrudev.ackpine.shizuku.ShizukuPlugin
+import ru.solrudev.ackpine.uninstaller.PackageUninstaller
+import ru.solrudev.ackpine.uninstaller.parameters.UninstallParameters
+import java.io.File
+import kotlin.coroutines.cancellation.CancellationException
+
+private const val TAG = "Morphe AckpineInstaller"
+
+/**
+ * Wraps Ackpine for internal (PackageInstaller API) and Shizuku installs.
+ * Root/mount installs are still handled by [RootInstaller].
+ */
+class AckpineInstaller(private val app: Application) {
+
+ private val packageInstaller: PackageInstaller = PackageInstaller.getInstance(app)
+ private val packageUninstaller: PackageUninstaller = PackageUninstaller.getInstance(app)
+
+ init {
+ val isSui = Sui.init(app.packageName)
+ if (!isSui) {
+ runCatching { ShizukuProvider.requestBinderForNonProviderProcess(app) }
+ }
+ }
+
+ /**
+ * Installs an APK using the standard Android PackageInstaller API via Ackpine.
+ * Suspends until the user confirms or cancels the system dialog.
+ *
+ * @return null on success, or a typed [InstallFailure] the caller can pattern-match on.
+ * @throws InstallCancelledException when the user dismisses the system install dialog.
+ */
+ suspend fun installInternal(apkFile: File): InstallFailure? {
+ require(apkFile.exists()) { "APK file does not exist: ${apkFile.path}" }
+ Log.d(TAG, "installInternal: ${apkFile.name} (${apkFile.length()} bytes)")
+ val session = packageInstaller.createSession(
+ InstallParameters.Builder(Uri.fromFile(apkFile))
+ .setInstallerType(InstallerType.SESSION_BASED)
+ .setConfirmation(Confirmation.IMMEDIATE)
+ .setName(apkFile.name)
+ // PackageSource.LocalFile disables "restricted settings" enforcement on API 33+
+ .setPackageSource(PackageSource.LocalFile)
+ .build()
+ )
+ return try {
+ extractFailure(session.await()).also { failure ->
+ if (failure != null) {
+ Log.w(TAG, "installInternal failed: ${failure.javaClass.simpleName} - ${failure.message}")
+ } else {
+ Log.i(TAG, "installInternal succeeded: ${apkFile.name}")
+ }
+ }
+ } catch (_: CancellationException) {
+ throw InstallCancelledException()
+ } catch (e: Exception) {
+ Log.w(TAG, "installInternal exception: ${e.message}", e)
+ throw e
+ }
+ }
+
+ /**
+ * Installs an APK silently via Shizuku/Sui using Ackpine's ShizukuPlugin.
+ *
+ * @return null on success, or a typed [InstallFailure] the caller can pattern-match on.
+ * @throws InstallCancelledException when aborted.
+ */
+ suspend fun installShizuku(apkFile: File): InstallFailure? {
+ require(apkFile.exists()) { "APK file does not exist: ${apkFile.path}" }
+ Log.d(TAG, "installShizuku: ${apkFile.name} (${apkFile.length()} bytes)")
+ val session = packageInstaller.createSession(
+ InstallParameters.Builder(Uri.fromFile(apkFile))
+ .setInstallerType(InstallerType.SESSION_BASED)
+ .setConfirmation(Confirmation.IMMEDIATE)
+ .setName(apkFile.name)
+ .registerPlugin(
+ ShizukuPlugin::class.java,
+ ShizukuPlugin.InstallParameters.Builder()
+ .setReplaceExisting(true)
+ .build()
+ )
+ .build()
+ )
+ return try {
+ extractFailure(session.await()).also { failure ->
+ if (failure != null) {
+ Log.w(TAG, "installShizuku failed: ${failure.javaClass.simpleName} - ${failure.message}")
+ } else {
+ Log.i(TAG, "installShizuku succeeded: ${apkFile.name}")
+ }
+ }
+ } catch (e: CancellationException) {
+ throw InstallCancelledException().initCause(e)
+ } catch (e: Exception) {
+ Log.w(TAG, "installShizuku exception: ${e.message}", e)
+ throw e
+ }
+ }
+
+ /**
+ * Uninstalls a package via Ackpine. Shows the system confirmation dialog.
+ * Suspends until the user confirms or cancels.
+ *
+ * @throws UninstallCancelledException when the user dismisses the dialog.
+ * @throws UninstallFailedException on any other failure.
+ */
+ suspend fun uninstall(packageName: String) {
+ val session = packageUninstaller.createSession(
+ UninstallParameters.Builder(packageName)
+ .setConfirmation(Confirmation.IMMEDIATE)
+ .build()
+ )
+ try {
+ when (val result = session.await()) {
+ is Session.State.Succeeded -> return
+ is Session.State.Failed -> {
+ throw UninstallFailedException(result.failure.message ?: result.failure.javaClass.simpleName)
+ }
+ }
+ } catch (e: CancellationException) {
+ throw UninstallCancelledException().initCause(e)
+ }
+ }
+
+ private fun extractFailure(result: Session.State.Completed): InstallFailure? =
+ when (result) {
+ is Session.State.Succeeded -> null
+ is Session.State.Failed -> result.failure
+ }
+
+ fun isShizukuInstalled(): Boolean {
+ if (Sui.isSui()) return true
+ return runCatching {
+ app.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0)
+ }.isSuccess
+ }
+
+ fun shizukuAvailability(@Suppress("UNUSED_PARAMETER") target: InstallerManager.InstallTarget): InstallerManager.Availability {
+ if (Shizuku.isPreV11()) {
+ return InstallerManager.Availability(false, R.string.installer_status_shizuku_unsupported)
+ }
+ val binderReady = runCatching { Shizuku.pingBinder() }.getOrElse { false }
+ if (!binderReady) {
+ return InstallerManager.Availability(false, R.string.installer_status_shizuku_not_running)
+ }
+ val permissionGranted = runCatching {
+ Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
+ }.getOrElse { false }
+ if (!permissionGranted) {
+ return InstallerManager.Availability(false, R.string.installer_status_shizuku_permission)
+ }
+ return InstallerManager.Availability(true)
+ }
+
+ fun launchShizukuApp(): Boolean {
+ val intent = app.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE)
+ ?: return false
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ app.startActivity(intent)
+ return true
+ }
+
+ companion object {
+ internal const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
+ }
+}
+
+/** Thrown when the user dismissed the system install dialog. */
+class InstallCancelledException : Exception("Installation cancelled by user")
+
+/** Thrown when the user dismissed the system uninstall dialog. */
+class UninstallCancelledException : Exception("Uninstall cancelled by user")
+
+/** Thrown when Ackpine reports a non-abort uninstall failure. */
+class UninstallFailedException(reason: String) : Exception(reason)
diff --git a/app/src/main/java/app/morphe/manager/domain/installer/InstallerFileProvider.kt b/app/src/main/java/app/morphe/manager/domain/installer/InstallerFileProvider.kt
index 7061275f9..d21332e92 100644
--- a/app/src/main/java/app/morphe/manager/domain/installer/InstallerFileProvider.kt
+++ b/app/src/main/java/app/morphe/manager/domain/installer/InstallerFileProvider.kt
@@ -14,7 +14,7 @@ import java.io.FileNotFoundException
/**
* Lightweight content provider used to expose APK files to external installers.
*
- * It mirrors the behaviour we relied on from [androidx.core.content.FileProvider] while avoiding
+ * It mirrors the behavior we relied on from [androidx.core.content.FileProvider] while avoiding
* the XML parsing crash that occurred on some devices when launching third-party installers.
*/
class InstallerFileProvider : ContentProvider() {
@@ -26,7 +26,7 @@ class InstallerFileProvider : ContentProvider() {
selection: String?,
selectionArgs: Array?,
sortOrder: String?
- ): Cursor? {
+ ): Cursor {
val columns = projection?.takeIf { it.isNotEmpty() }
?: arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
val file = buildFile(contextOrThrow(), uri)
@@ -79,11 +79,10 @@ class InstallerFileProvider : ContentProvider() {
companion object {
private const val APK_MIME = "application/vnd.android.package-archive"
+ const val SHARE_DIR = "installer_share"
fun authority(context: Context): String = "${context.packageName}.installerfileprovider"
- fun buildUri(context: Context, file: File): Uri = buildUri(context, file.name)
-
fun buildUri(context: Context, fileName: String): Uri =
Uri.Builder()
.scheme("content")
@@ -91,6 +90,27 @@ class InstallerFileProvider : ContentProvider() {
.appendPath(fileName)
.build()
+ /**
+ * Copies [file] into the share directory under its original name (overwriting if size
+ * differs) and returns a content URI for it. Used by [AckpineInstaller] for internal
+ * and Shizuku installs, and by [InstallerManager] for external installer intents.
+ */
+ fun getUriForFile(context: Context, file: File): Uri {
+ val shareDir = File(context.cacheDir, SHARE_DIR).also { it.mkdirs() }
+ val dest = File(shareDir, file.name)
+ // Always copy if size or mtime differs. We stamp dest with the source's mtime
+ // after each copy, so dest.lastModified() == file.lastModified() on the next
+ // call only if the source hasn't changed — skipping the copy is then safe.
+ // Using < instead of != would silently serve a stale cached APK when the patcher
+ // rebuilds the same-named output file without changing its size, causing
+ // INSTALL_FAILED_UPDATE_INCOMPATIBLE on update installs
+ if (!dest.exists() || dest.length() != file.length() || dest.lastModified() != file.lastModified()) {
+ file.copyTo(dest, overwrite = true)
+ dest.setLastModified(file.lastModified())
+ }
+ return buildUri(context, dest.name)
+ }
+
private fun buildFile(context: Context, uri: Uri): File {
if (uri.authority != authority(context)) {
throw IllegalArgumentException("Unknown authority: ${uri.authority}")
@@ -102,7 +122,7 @@ class InstallerFileProvider : ContentProvider() {
val fileName = segments.first()
require(".." !in fileName) { "Path traversal is not allowed." }
- val dir = File(context.cacheDir, InstallerManager.SHARE_DIR)
+ val dir = File(context.cacheDir, SHARE_DIR)
val target = File(dir, fileName)
val canonicalDir = dir.canonicalFile
val canonicalTarget = target.canonicalFile
diff --git a/app/src/main/java/app/morphe/manager/domain/installer/InstallerManager.kt b/app/src/main/java/app/morphe/manager/domain/installer/InstallerManager.kt
index 4acac6071..2b773daa0 100644
--- a/app/src/main/java/app/morphe/manager/domain/installer/InstallerManager.kt
+++ b/app/src/main/java/app/morphe/manager/domain/installer/InstallerManager.kt
@@ -1,11 +1,15 @@
package app.morphe.manager.domain.installer
import android.Manifest
+import android.annotation.SuppressLint
import android.app.Application
import android.content.ClipData
import android.content.ComponentName
import android.content.Intent
-import android.content.pm.*
+import android.content.pm.ActivityInfo
+import android.content.pm.ApplicationInfo
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
@@ -14,18 +18,14 @@ import app.morphe.manager.R
import app.morphe.manager.domain.manager.InstallerPreferenceTokens
import app.morphe.manager.domain.manager.PreferencesManager
import java.io.File
-import java.io.IOException
-import java.util.Locale
-import java.util.UUID
class InstallerManager(
private val app: Application,
private val prefs: PreferencesManager,
private val rootInstaller: RootInstaller,
- private val shizukuInstaller: ShizukuInstaller
+ private val ackpineInstaller: AckpineInstaller
) {
private val packageManager: PackageManager = app.packageManager
- private val shareDir: File = File(app.cacheDir, SHARE_DIR).apply { mkdirs() }
private val dummyUri: Uri = InstallerFileProvider.buildUri(app, "dummy.apk")
private val defaultInstallerComponent: ComponentName? by lazy { resolveDefaultInstallerComponent() }
private val defaultInstallerPackage: String? get() = defaultInstallerComponent?.packageName
@@ -223,10 +223,10 @@ class InstallerManager(
if (!availabilityFor(token, target).available) {
null
} else {
- val shared = copyToShareDir(sourceFile)
- val uri = InstallerFileProvider.buildUri(app, shared)
+ val uri = InstallerFileProvider.getUriForFile(app, sourceFile)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, APK_MIME)
+ @SuppressLint("WrongConstant")
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
@@ -238,6 +238,7 @@ class InstallerManager(
putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, app.packageName)
component = token.componentName
}
+ @SuppressLint("WrongConstant")
app.grantUriPermission(
token.componentName.packageName,
uri,
@@ -248,7 +249,7 @@ class InstallerManager(
InstallPlan.External(
target = target,
intent = intent,
- sharedFile = shared,
+ sharedFile = File(app.cacheDir, "${InstallerFileProvider.SHARE_DIR}/${sourceFile.name}"),
uri = uri,
expectedPackage = expectedPackage,
installerLabel = resolveLabel(token.componentName),
@@ -302,7 +303,7 @@ class InstallerManager(
label = app.getString(R.string.installer_shizuku_name),
description = app.getString(R.string.installer_shizuku_description),
availability = availabilityFor(Token.Shizuku, target, checkRoot),
- icon = if (shizukuInstaller.isInstalled()) loadInstallerIcon(ShizukuInstaller.PACKAGE_NAME) else null
+ icon = if (ackpineInstaller.isShizukuInstalled()) loadInstallerIcon(AckpineInstaller.SHIZUKU_PACKAGE) else null
)
is Token.Component -> {
@@ -317,21 +318,6 @@ class InstallerManager(
}
}
- private fun copyToShareDir(source: File): File {
- val target = File(shareDir, "${UUID.randomUUID()}.apk")
- try {
- source.inputStream().use { input ->
- target.outputStream().use { output ->
- input.copyTo(output)
- }
- }
- } catch (error: IOException) {
- target.delete()
- throw error
- }
- return target
- }
-
private fun buildSequence(target: InstallTarget): List {
val tokens = mutableListOf()
val primary = getPrimaryToken()
@@ -358,14 +344,14 @@ class InstallerManager(
Token.AutoSaved -> if (!target.supportsRoot) {
Availability(false, R.string.installer_status_not_supported)
} else if (checkRoot) {
- // Expert mode: check root access
+ // Check root access
if (!rootInstaller.hasRootAccess()) {
Availability(false, R.string.installer_status_requires_root)
} else {
Availability(true)
}
} else {
- // Morphe mode: check if device is rooted without requesting access
+ // Check if device is rooted without requesting access.
// This prevents showing root installer on non-rooted devices
if (!rootInstaller.isDeviceRooted()) {
Availability(false, R.string.installer_status_requires_root)
@@ -376,11 +362,11 @@ class InstallerManager(
}
Token.Shizuku -> {
- if (!shizukuInstaller.isInstalled()) {
+ if (!ackpineInstaller.isShizukuInstalled()) {
Availability(false, R.string.installer_status_shizuku_not_installed)
} else if (checkRoot) {
// Full availability check
- shizukuInstaller.availability(target)
+ ackpineInstaller.shizukuAvailability(target)
} else {
// Just verify Shizuku is installed (for UI display)
Availability(true)
@@ -524,42 +510,37 @@ class InstallerManager(
companion object {
private const val APK_MIME = "application/vnd.android.package-archive"
- internal const val SHARE_DIR = "installer_share"
private const val AOSP_INSTALLER_PACKAGE = "com.google.android.packageinstaller"
private const val AOSP_INSTALLER_LABEL = "Package installer"
- private const val TAG = "InstallerManager"
+ private const val TAG = "Morphe InstallerManager"
}
- fun openShizukuApp(): Boolean = shizukuInstaller.launchApp()
-
- fun formatFailureHint(status: Int, extraMessage: String?): String? {
- val normalizedExtra = extraMessage?.takeIf { it.isNotBlank() }
- val base = when (status) {
- PackageInstaller.STATUS_FAILURE -> app.getString(R.string.installer_hint_generic)
- PackageInstaller.STATUS_FAILURE_ABORTED -> app.getString(R.string.installer_hint_aborted)
- PackageInstaller.STATUS_FAILURE_BLOCKED -> app.getString(R.string.installer_hint_blocked)
- PackageInstaller.STATUS_FAILURE_CONFLICT -> app.getString(R.string.installer_hint_conflict)
- PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> app.getString(R.string.installer_hint_incompatible)
- PackageInstaller.STATUS_FAILURE_INVALID -> app.getString(R.string.installer_hint_invalid)
- PackageInstaller.STATUS_FAILURE_STORAGE -> app.getString(R.string.installer_hint_storage)
- PackageInstaller.STATUS_FAILURE_TIMEOUT -> app.getString(R.string.installer_hint_timeout)
- else -> null
- }
+ fun openShizukuApp(): Boolean = ackpineInstaller.launchShizukuApp()
- return when {
- base == null -> normalizedExtra
- normalizedExtra == null -> base
- else -> app.getString(R.string.installer_hint_with_reason, base, normalizedExtra)
+ /**
+ * Returns a deduplicated list of entries for [target], ensuring [token] is always present
+ * even if it's not in the raw list (e.g. a previously selected external installer).
+ */
+ fun ensureValidEntries(
+ entries: List,
+ token: Token,
+ target: InstallTarget
+ ): List {
+ val normalized = buildList {
+ val seen = mutableSetOf()
+ entries.forEach { entry ->
+ val key = when (val t = entry.token) {
+ is Token.Component -> t.componentName
+ else -> t
+ }
+ if (seen.add(key)) add(entry)
+ }
}
- }
-
- fun isSignatureMismatch(message: String?): Boolean {
- val normalized = message?.lowercase(Locale.ROOT)?.trim().orEmpty()
- if (normalized.isEmpty()) return false
- return normalized.contains("install_failed_update_incompatible") ||
- normalized.contains("install_failed_signature_inconsistent") ||
- normalized.contains("signatures do not match") ||
- normalized.contains("signature mismatch")
+ val tokenExists = token == Token.Internal ||
+ token == Token.AutoSaved ||
+ normalized.any { tokensEqual(it.token, token) }
+ return if (tokenExists) normalized
+ else describeEntry(token, target)?.let { normalized + it } ?: normalized
}
}
diff --git a/app/src/main/java/app/morphe/manager/domain/installer/ShizukuInstaller.kt b/app/src/main/java/app/morphe/manager/domain/installer/ShizukuInstaller.kt
deleted file mode 100644
index 6350ebdc0..000000000
--- a/app/src/main/java/app/morphe/manager/domain/installer/ShizukuInstaller.kt
+++ /dev/null
@@ -1,256 +0,0 @@
-package app.morphe.manager.domain.installer
-
-import android.app.Application
-import android.content.Intent
-import android.content.IntentSender
-import android.content.pm.IPackageInstaller
-import android.content.pm.IPackageInstallerSession
-import android.content.pm.IPackageManager
-import android.content.pm.PackageInstaller
-import android.content.pm.PackageManager
-import android.os.Build
-import android.os.Bundle
-import android.os.IBinder
-import android.os.Process
-import android.os.RemoteException
-import app.morphe.manager.R
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import rikka.shizuku.Shizuku
-import rikka.shizuku.ShizukuBinderWrapper
-import rikka.shizuku.ShizukuProvider
-import rikka.shizuku.SystemServiceHelper
-import rikka.sui.Sui
-import java.io.File
-import java.io.IOException
-import java.lang.reflect.Constructor
-
-class ShizukuInstaller(private val app: Application) {
-
- init {
- val isSui = Sui.init(app.packageName)
- if (!isSui) {
- runCatching { ShizukuProvider.requestBinderForNonProviderProcess(app) }
- }
- }
-
- data class InstallResult(val status: Int, val message: String?)
-
- fun availability(target: InstallerManager.InstallTarget): InstallerManager.Availability {
- if (Shizuku.isPreV11()) {
- return InstallerManager.Availability(false, R.string.installer_status_shizuku_unsupported)
- }
- val binderReady = runCatching { Shizuku.pingBinder() }.getOrElse { false }
- if (!binderReady) {
- return InstallerManager.Availability(false, R.string.installer_status_shizuku_not_running)
- }
- val permissionGranted = runCatching { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED }.getOrElse { false }
- if (!permissionGranted) {
- return InstallerManager.Availability(false, R.string.installer_status_shizuku_permission)
- }
- return InstallerManager.Availability(true)
- }
-
- fun isInstalled(): Boolean {
- if (Sui.isSui()) return true
- return runCatching {
- app.packageManager.getPackageInfo(PACKAGE_NAME, 0)
- }.isSuccess
- }
-
- fun launchApp(): Boolean {
- val intent = app.packageManager.getLaunchIntentForPackage(PACKAGE_NAME)
- ?: return false
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- app.startActivity(intent)
- return true
- }
-
- suspend fun install(sourceFile: File, expectedPackage: String): InstallResult = withContext(Dispatchers.IO) {
- val packageInstaller = obtainPackageInstaller()
- val isRoot = runCatching { Shizuku.getUid() }.getOrDefault(-1) == 0
- val installerPackageName = if (isRoot) app.packageName else SHELL_PACKAGE
- val installerAttributionTag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) app.attributionTag else null
- val userId = if (isRoot) currentUserId() else 0
-
- val packageInstallerWrapper = PackageInstallerCompat.createPackageInstaller(
- packageInstaller,
- installerPackageName,
- installerAttributionTag,
- userId,
- app
- )
- val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
- runCatching { setAppPackageName(expectedPackage) }
- setInstallReason(PackageManager.INSTALL_REASON_USER)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- setRequestUpdateOwnership(true)
- }
- }
- PackageInstallerCompat.applyFlags(params)
-
- val sessionId = packageInstallerWrapper.createSession(params)
- val sessionBinder = IPackageInstallerSession.Stub.asInterface(
- ShizukuBinderWrapper(packageInstaller.openSession(sessionId).asBinder())
- )
- val session = PackageInstallerCompat.createSession(sessionBinder)
-
- try {
- sourceFile.inputStream().use { input ->
- session.openWrite(BASE_APK_NAME, 0, sourceFile.length()).use { output ->
- input.copyTo(output)
- session.fsync(output)
- }
- }
-
- val resultDeferred = CompletableDeferred()
- val intentSender = IntentSenderCompat.create { intent ->
- val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
- val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
- resultDeferred.complete(InstallResult(status, message))
- }
-
- session.commit(intentSender)
- val result = resultDeferred.await()
- if (result.status != PackageInstaller.STATUS_SUCCESS) {
- throw InstallerOperationException(result.status, result.message)
- }
- result
- } finally {
- runCatching { session.close() }
- }
- }
-
- private fun obtainPackageInstaller(): IPackageInstaller {
- val binder = SystemServiceHelper.getSystemService("package")
- ?: throw IOException("Package service unavailable")
- try {
- val manager = IPackageManager.Stub.asInterface(ShizukuBinderWrapper(binder))
- val installer = manager.packageInstaller
- return IPackageInstaller.Stub.asInterface(ShizukuBinderWrapper(installer.asBinder()))
- } catch (error: RemoteException) {
- throw IOException(error)
- }
- }
-
- private fun currentUserId(): Int = Process.myUid() / 100000
-
- class InstallerOperationException(val status: Int, override val message: String?) : Exception(message)
-
- companion object {
- private const val SHELL_PACKAGE = "com.android.shell"
- private const val BASE_APK_NAME = "base.apk"
- internal const val PACKAGE_NAME = "moe.shizuku.privileged.api"
- }
-}
-
-private object PackageInstallerCompat {
- private const val INSTALL_REPLACE_EXISTING = 0x00000002
- private const val INSTALL_ALLOW_TEST = 0x00000004
-
- fun createPackageInstaller(
- remote: IPackageInstaller,
- installerPackageName: String,
- installerAttributionTag: String?,
- userId: Int,
- app: Application
- ): PackageInstaller {
- return try {
- when {
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- PackageInstaller::class.java
- .getDeclaredConstructor(
- IPackageInstaller::class.java,
- String::class.java,
- String::class.java,
- Int::class.javaPrimitiveType
- )
- .apply { isAccessible = true }
- .newInstance(remote, installerPackageName, installerAttributionTag, userId)
- }
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
- PackageInstaller::class.java
- .getDeclaredConstructor(
- IPackageInstaller::class.java,
- String::class.java,
- Int::class.javaPrimitiveType
- )
- .apply { isAccessible = true }
- .newInstance(remote, installerPackageName, userId)
- }
- else -> {
- PackageInstaller::class.java
- .getDeclaredConstructor(
- android.content.Context::class.java,
- PackageManager::class.java,
- IPackageInstaller::class.java,
- String::class.java,
- Int::class.javaPrimitiveType
- )
- .apply { isAccessible = true }
- .newInstance(app, app.packageManager, remote, installerPackageName, userId)
- }
- }
- } catch (error: ReflectiveOperationException) {
- throw RuntimeException(error)
- }
- }
-
- fun createSession(remote: IPackageInstallerSession): PackageInstaller.Session {
- return try {
- PackageInstaller.Session::class.java
- .getDeclaredConstructor(IPackageInstallerSession::class.java)
- .apply { isAccessible = true }
- .newInstance(remote)
- } catch (error: ReflectiveOperationException) {
- throw RuntimeException(error)
- }
- }
-
- fun applyFlags(params: PackageInstaller.SessionParams) {
- runCatching {
- val field = PackageInstaller.SessionParams::class.java.getDeclaredField("installFlags")
- field.isAccessible = true
- val current = field.getInt(params)
- field.setInt(params, current or INSTALL_REPLACE_EXISTING or INSTALL_ALLOW_TEST)
- }
- }
-}
-
-private object IntentSenderCompat {
- fun create(callback: (Intent) -> Unit): IntentSender {
- val binder = object : android.content.IIntentSender.Stub() {
- override fun send(
- code: Int,
- intent: Intent?,
- resolvedType: String?,
- finishedReceiver: android.content.IIntentReceiver?,
- requiredPermission: String?,
- options: Bundle?
- ): Int {
- intent?.let(callback)
- return 0
- }
-
- override fun send(
- code: Int,
- intent: Intent?,
- resolvedType: String?,
- whitelistToken: IBinder?,
- finishedReceiver: android.content.IIntentReceiver?,
- requiredPermission: String?,
- options: Bundle?
- ) {
- intent?.let(callback)
- }
- }
- return try {
- val ctor: Constructor = IntentSender::class.java.getDeclaredConstructor(android.content.IIntentSender::class.java)
- ctor.isAccessible = true
- ctor.newInstance(binder)
- } catch (error: ReflectiveOperationException) {
- throw RuntimeException(error)
- }
- }
-}
diff --git a/app/src/main/java/app/morphe/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/morphe/manager/domain/manager/PreferencesManager.kt
index f81f6c9ba..8b4953574 100644
--- a/app/src/main/java/app/morphe/manager/domain/manager/PreferencesManager.kt
+++ b/app/src/main/java/app/morphe/manager/domain/manager/PreferencesManager.kt
@@ -12,9 +12,11 @@ import app.morphe.manager.patcher.runtime.calculateAdaptiveMemoryLimit
import app.morphe.manager.ui.screen.shared.BackgroundType
import app.morphe.manager.ui.theme.Theme
import app.morphe.manager.ui.viewmodel.BundleSnapshot
+import app.morphe.manager.ui.viewmodel.RandomInterval
import app.morphe.manager.util.isArmV7
import app.morphe.manager.util.tag
import app.morphe.manager.worker.UpdateCheckInterval
+import app.morphe.patcher.dex.BytecodeMode
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
@@ -25,9 +27,11 @@ class PreferencesManager(
// Appearance tab
val backgroundType = enumPreference("background_type", BackgroundType.CIRCLES)
val enableBackgroundParallax = booleanPreference("enable_background_parallax", true)
+ val randomBackgroundInterval = enumPreference("random_background_interval", RandomInterval.ON_LAUNCH)
val dynamicColor = booleanPreference("dynamic_color", true)
val pureBlackTheme = booleanPreference("pure_black_theme", false)
+ val showGreetingPhrases = booleanPreference("show_greeting_phrases", true)
val themePresetSelectionEnabled = booleanPreference("theme_preset_selection_enabled", true)
val themePresetSelectionName = stringPreference("theme_preset_selection_name", "DEFAULT")
val customAccentColor = stringPreference("custom_accent_color", "")
@@ -58,6 +62,15 @@ class PreferencesManager(
val stripUnusedNativeLibs = booleanPreference("strip_unused_native_libs", false)
+ /**
+ * Bytecode processing mode for the patcher.
+ * Defaults to [BytecodeMode.STRIP_FAST].
+ */
+ val bytecodeModePreference = enumPreference(
+ "bytecode_mode",
+ BytecodeMode.STRIP_FAST
+ )
+
// System tab
val installerPrimary = stringPreference("installer_primary", InstallerPreferenceTokens.INTERNAL)
val promptInstallerOnInstall = booleanPreference("prompt_installer_on_install", false)
@@ -65,9 +78,9 @@ class PreferencesManager(
val installerHiddenComponents = stringSetPreference("installer_hidden_components", emptySet())
val useProcessRuntime = booleanPreference(
- "use_process_runtime",
- // Use process runtime fails for Android 10 and lower.
- // Armv7 silently fails and nobody has researched why yet.
+ "process_runtime", // Old key was 'use_process_runtime' and may have the wrong default for some devices.
+ // Process runtime fails for Android 10 and lower.
+ // Armv7 silently fails and nobody has researched why.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !isArmV7()
)
val patcherProcessMemoryLimit = intPreference("use_process_runtime_memory_limit", PROCESS_RUNTIME_MEMORY_NOT_SET)
@@ -81,10 +94,11 @@ class PreferencesManager(
val allowMeteredUpdates = booleanPreference("allow_metered_updates", true)
val firstLaunch = booleanPreference("first_launch", true)
+
val installationTime = longPreference("manager_installation_time", 0)
val disablePatchVersionCompatCheck = booleanPreference("disable_patch_version_compatibility_check", false)
- // Hidden preference to track if prerelease was auto-enabled
+ /** Hidden preference to track if prerelease was auto-enabled. */
private val prereleaseAutoEnabled = booleanPreference("prerelease_auto_enabled", false)
init {
@@ -159,11 +173,14 @@ class PreferencesManager(
val patchSelectionHiddenActions: Set? = null,
val acknowledgedDownloaderPlugins: Set? = null,
val autoSaveDownloaderApks: Boolean? = null,
+ val showGreetingPhrases: Boolean? = null,
val backgroundType: BackgroundType? = null,
+ val randomBackgroundInterval: RandomInterval? = null,
val useExpertMode: Boolean? = null,
val backgroundUpdateNotifications: Boolean? = null,
val updateCheckInterval: UpdateCheckInterval? = null,
val customBundles: List? = null,
+ val bytecodeModePreference: BytecodeMode? = null,
)
suspend fun exportSettings() = SettingsSnapshot(
@@ -191,10 +208,13 @@ class PreferencesManager(
bundlePrereleasesEnabled = bundlePrereleasesEnabled.get(),
bundleExperimentalVersionsEnabled = bundleExperimentalVersionsEnabled.get(),
disablePatchVersionCompatCheck = disablePatchVersionCompatCheck.get(),
+ showGreetingPhrases = showGreetingPhrases.get(),
backgroundType = backgroundType.get(),
+ randomBackgroundInterval = randomBackgroundInterval.get(),
useExpertMode = useExpertMode.get(),
backgroundUpdateNotifications = backgroundUpdateNotifications.get(),
- updateCheckInterval = updateCheckInterval.get()
+ updateCheckInterval = updateCheckInterval.get(),
+ bytecodeModePreference = bytecodeModePreference.get(),
)
suspend fun importSettings(snapshot: SettingsSnapshot) = edit {
@@ -222,10 +242,13 @@ class PreferencesManager(
snapshot.bundlePrereleasesEnabled?.let { bundlePrereleasesEnabled.value = it }
snapshot.bundleExperimentalVersionsEnabled?.let { bundleExperimentalVersionsEnabled.value = it }
snapshot.disablePatchVersionCompatCheck?.let { disablePatchVersionCompatCheck.value = it }
+ snapshot.showGreetingPhrases?.let { showGreetingPhrases.value = it }
snapshot.backgroundType?.let { backgroundType.value = it }
+ snapshot.randomBackgroundInterval?.let { randomBackgroundInterval.value = it }
snapshot.useExpertMode?.let { useExpertMode.value = it }
snapshot.backgroundUpdateNotifications?.let { backgroundUpdateNotifications.value = it }
snapshot.updateCheckInterval?.let { updateCheckInterval.value = it }
+ snapshot.bytecodeModePreference?.let { bytecodeModePreference.value = it }
}
companion object {
diff --git a/app/src/main/java/app/morphe/manager/domain/repository/InstalledAppRepository.kt b/app/src/main/java/app/morphe/manager/domain/repository/InstalledAppRepository.kt
index c0e49951f..bf5160e51 100644
--- a/app/src/main/java/app/morphe/manager/domain/repository/InstalledAppRepository.kt
+++ b/app/src/main/java/app/morphe/manager/domain/repository/InstalledAppRepository.kt
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
-private const val TAG = "InstalledAppRepository"
+private const val TAG = "Morphe InstalledAppRepository"
class InstalledAppRepository(
db: AppDatabase,
diff --git a/app/src/main/java/app/morphe/manager/domain/repository/OriginalApkRepository.kt b/app/src/main/java/app/morphe/manager/domain/repository/OriginalApkRepository.kt
index 7147679ed..bbbae8bf5 100644
--- a/app/src/main/java/app/morphe/manager/domain/repository/OriginalApkRepository.kt
+++ b/app/src/main/java/app/morphe/manager/domain/repository/OriginalApkRepository.kt
@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.withContext
import java.io.File
-private const val TAG = "OriginalApkRepository"
+private const val TAG = "Morphe OriginalApkRepository"
class OriginalApkRepository(
db: AppDatabase,
diff --git a/app/src/main/java/app/morphe/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/morphe/manager/domain/repository/PatchBundleRepository.kt
index 15d617415..93e025887 100644
--- a/app/src/main/java/app/morphe/manager/domain/repository/PatchBundleRepository.kt
+++ b/app/src/main/java/app/morphe/manager/domain/repository/PatchBundleRepository.kt
@@ -17,9 +17,13 @@ import app.morphe.manager.data.room.bundles.PatchBundleProperties
import app.morphe.manager.data.room.bundles.Source
import app.morphe.manager.domain.bundles.*
import app.morphe.manager.domain.manager.PreferencesManager
+import app.morphe.manager.network.utils.APIError
+import app.morphe.manager.patcher.patch.BundleAppMetadata
import app.morphe.manager.patcher.patch.PatchBundle
import app.morphe.manager.patcher.patch.PatchBundleInfo
+import app.morphe.manager.ui.viewmodel.BundleSnapshot
import app.morphe.manager.util.*
+import io.ktor.client.plugins.ResponseException
import io.ktor.http.Url
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.mutate
@@ -39,10 +43,6 @@ import java.nio.ByteOrder
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.Locale
-import app.morphe.manager.util.syncFcmTopics
-import app.morphe.manager.network.utils.APIError
-import app.morphe.manager.ui.viewmodel.BundleSnapshot
-import io.ktor.client.plugins.ResponseException
import app.morphe.manager.data.room.bundles.Source as SourceInfo
class PatchBundleRepository(
@@ -55,20 +55,33 @@ class PatchBundleRepository(
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
private val scope = CoroutineScope(Dispatchers.Default)
- private val store = Store(scope, State())
+ private val store = Store(scope, BundleState.Loading)
+
+ val bundleState: StateFlow = store.state
+ .stateIn(scope, SharingStarted.Eagerly, BundleState.Loading)
- val sources = store.state.map { it.sources.values.toList() }
+ val sources = store.state.map { (it as? BundleState.Ready)?.sources?.values?.toList() ?: emptyList() }
val bundles = store.state.map {
- it.sources.mapNotNull { (uid, src) ->
+ (it as? BundleState.Ready)?.sources?.mapNotNull { (uid, src) ->
uid to (src.patchBundle ?: return@mapNotNull null)
- }.toMap()
+ }?.toMap() ?: emptyMap()
}
- val allBundlesInfoFlow = store.state.map { it.info }
+ val allBundlesInfoFlow = store.state.map { (it as? BundleState.Ready)?.info ?: persistentMapOf() }
val enabledBundlesInfoFlow = allBundlesInfoFlow.map { info ->
info.filter { (_, bundleInfo) -> bundleInfo.enabled }
}
val bundleInfoFlow = enabledBundlesInfoFlow
+ /**
+ * Pre-built [BundleAppMetadata] map, updated whenever enabled bundles change.
+ * Shared across all consumers so [BundleAppMetadata.buildFrom] is never called more
+ * than once per bundle reload.
+ */
+ val appMetadata: StateFlow