From 530f2a3f8369af408589ba77bc687cd81e1462b7 Mon Sep 17 00:00:00 2001 From: Jordan Ye Date: Sat, 1 Aug 2026 04:04:45 -0400 Subject: [PATCH 1/2] fix: harden release and System Mode lifecycle --- .github/actions/setup/action.yml | 12 +- .github/workflows/android.yml | 111 +- .github/workflows/security-lab.yml | 26 +- .gitignore | 1 + README.MD | 155 ++- TestKey-2024.jks | Bin 3019 -> 0 bytes .../topjohnwu/magisk/utils/APKInstall.java | 213 ++-- app/src/main/AndroidManifest.xml | 3 +- .../java/com/topjohnwu/magisk/core/App.kt | 3 +- .../java/com/topjohnwu/magisk/core/Config.kt | 8 +- .../java/com/topjohnwu/magisk/core/Const.kt | 4 +- .../topjohnwu/magisk/core/CoroutineScopes.kt | 8 + .../java/com/topjohnwu/magisk/core/Info.kt | 64 +- .../com/topjohnwu/magisk/core/JobService.kt | 80 +- .../com/topjohnwu/magisk/core/Receiver.kt | 15 +- .../magisk/core/base/BaseActivity.kt | 17 +- .../magisk/core/download/DownloadEngine.kt | 112 +- .../topjohnwu/magisk/core/download/Subject.kt | 1 + .../com/topjohnwu/magisk/core/ktx/XJVM.kt | 26 +- .../topjohnwu/magisk/core/model/UpdateInfo.kt | 3 +- .../magisk/core/repository/NetworkService.kt | 8 +- .../core/repository/UpdateCheckResult.kt | 40 +- .../topjohnwu/magisk/core/tasks/HideAPK.kt | 167 ++- .../magisk/core/tasks/MagiskInstaller.kt | 62 +- .../magisk/dialog/ManagerInstallDialog.kt | 15 +- .../magisk/dialog/SystemModeWarningDialog.kt | 25 + .../com/topjohnwu/magisk/ui/SplashActivity.kt | 18 +- .../topjohnwu/magisk/ui/home/HomeViewModel.kt | 1 + .../magisk/ui/install/InstallViewModel.kt | 77 +- .../magisk/ui/settings/SettingsItems.kt | 3 - app/src/main/res/raw/manager.sh | 591 ++++++++--- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-vi/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 3 +- .../repository/UpdateChannelPolicyTest.kt | 92 +- build.py | 51 +- buildSrc/src/main/java/Plugin.kt | 29 +- buildSrc/src/main/java/Setup.kt | 30 +- config.prop | 24 - config.prop.sample | 12 +- docs/build.md | 13 +- docs/faithfulness-audit.md | 79 +- docs/hide-migration-v13.md | 93 +- docs/release-notes.md | 22 + docs/security-lab.md | 60 +- docs/status.md | 113 +- docs/system-mode/failure-injection.md | 5 +- native/src/Application.mk | 8 +- native/src/base/cus.cpp | 38 - native/src/base/cus.hpp | 3 - native/src/boot/cpio.rs | 4 +- native/src/core/db.cpp | 190 +++- native/src/core/db_migrations.hpp | 41 +- native/src/core/deny/revert.cpp | 27 +- native/src/core/deny/utils.cpp | 14 - native/src/core/magisk.cpp | 23 +- native/src/core/package.cpp | 6 +- native/src/core/selinux.cpp | 14 +- native/src/core/su/su_daemon.cpp | 6 +- native/src/init/logging.rs | 13 +- native/src/init/twostage.cpp | 5 +- scripts/addon.d.sh | 38 +- scripts/avd_test.sh | 100 +- scripts/flash_script.sh | 27 +- scripts/security_avd_test.sh | 2 +- scripts/util_functions.sh | 31 +- security/generated/dependency-inventory.json | 968 ++++++++---------- security/generated/rustsec-report.json | 10 +- security/generated/signature-contract.json | 16 +- security/generated/upstream-ledger.json | 334 +++++- security/signature-threat-model.json | 8 +- security/upstream-policy.json | 2 +- stub/build.gradle.kts | 1 + .../topjohnwu/magisk/DownloadActivity.java | 60 +- .../com/topjohnwu/magisk/net/Networking.java | 6 +- .../com/topjohnwu/magisk/net/Request.java | 9 +- tests/security_lab/test_artifact_contract.py | 294 ++++++ tests/security_lab/test_device_corpus.py | 25 + tests/security_lab/test_security_tools.py | 30 +- tests/security_lab/test_sqlite_faults.py | 28 +- tests/security_lab/test_upstream_ledger.py | 12 +- tests/system_mode/test_hide_migration.py | 118 ++- tests/system_mode/test_installer_safety.py | 514 ++++++++++ tools/security_lab/artifact_contract.py | 515 ++++++++++ tools/security_lab/rustsec.py | 30 +- tools/security_lab/signature_contract.py | 43 +- tools/security_lab/sqlite_fault.py | 21 +- tools/security_lab/upstream_ledger.py | 8 + 90 files changed, 4531 insertions(+), 1614 deletions(-) delete mode 100644 TestKey-2024.jks create mode 100644 app/src/main/java/com/topjohnwu/magisk/core/CoroutineScopes.kt create mode 100644 app/src/main/java/com/topjohnwu/magisk/dialog/SystemModeWarningDialog.kt delete mode 100644 config.prop create mode 100644 docs/release-notes.md create mode 100644 tests/security_lab/test_artifact_contract.py create mode 100644 tests/system_mode/test_installer_safety.py create mode 100644 tools/security_lab/artifact_contract.py diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 564f65ecc..788a61ec6 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -4,18 +4,18 @@ runs: using: "composite" steps: - name: Set up JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: "temurin" java-version: "17" - name: Set up Python 3 - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.x" + python-version: "3.13" - name: Set up sccache - uses: hendrikmuhs/ccache-action@v1.2 + uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 with: variant: sccache key: ${{ runner.os }}-${{ github.sha }} @@ -23,7 +23,7 @@ runs: max-size: 10000M - name: Cache Gradle dependencies - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches @@ -33,7 +33,7 @@ runs: restore-keys: ${{ runner.os }}-gradle- - name: Cache build cache - uses: actions/cache@v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches/build-cache-* diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 95852ce55..849131585 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,12 +8,6 @@ on: - kitsune - next-system workflow_dispatch: - inputs: - publish_canary: - description: Publish the tested release APKs as an experimental canary - required: false - type: boolean - default: false permissions: contents: read @@ -28,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -72,7 +66,7 @@ jobs: SCCACHE_DIRECT: false steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive fetch-depth: 0 @@ -80,27 +74,63 @@ jobs: - name: Set up build environment uses: ./.github/actions/setup + - name: Create isolated ephemeral release signer + shell: bash + run: | + set -euo pipefail + umask 077 + signing_pass="$(openssl rand -hex 24)" + signing_store="$RUNNER_TEMP/kitsune-ci-release.jks" + signing_config="$RUNNER_TEMP/kitsune-ci-release.prop" + keytool -genkeypair -noprompt \ + -keystore "$signing_store" \ + -storepass "$signing_pass" \ + -keypass "$signing_pass" \ + -alias kitsune-ci \ + -keyalg RSA \ + -keysize 4096 \ + -validity 2 \ + -dname "CN=Ephemeral Kitsune CI" + { + printf 'keyStore=%s\n' "$signing_store" + printf 'keyStorePass=%s\n' "$signing_pass" + printf 'keyAlias=kitsune-ci\n' + printf 'keyPass=%s\n' "$signing_pass" + } > "$signing_config" + printf 'RELEASE_CONFIG=%s\n' "$signing_config" >> "$GITHUB_ENV" + - name: Build release - run: ./build.py -vr all + run: ./build.py -vr -c "$RELEASE_CONFIG" all - name: Build debug run: ./build.py -v all - - name: Run JVM unit-test tasks + - name: Run JVM unit tests and Android lint run: >- ./gradlew :app:testDebugUnitTest - :app:shared:testDebugUnitTest - :native:testDebugUnitTest - :stub:testDebugUnitTest - -PconfigPath="$PWD/config.prop" + :app:lintDebug + :app:shared:lintDebug + :stub:lintDebug + -PconfigPath="$RUNNER_TEMP/no-config.prop" + + - name: Verify APK signer and 16 KiB ELF/ZIP contracts + run: >- + python3 -m tools.security_lab.artifact_contract + --apksigner "$ANDROID_SDK_ROOT/build-tools/34.0.0/apksigner" + --apk out/app-debug.apk + --apk out/stub-debug.apk + --apk out/app-release.apk + --apk out/stub-release.apk + --reject-certificate a9342f305e5d7ecc0245f86c931226267389358c48139f5d7ed6a80cd4329629 + --output out/artifact-contract.json - name: Stop Gradle daemon if: always() run: ./gradlew --stop - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ github.sha }} path: out @@ -109,7 +139,7 @@ jobs: retention-days: 14 - name: Upload mapping and native debug symbols - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ github.sha }}-symbols path: app/build/outputs @@ -128,17 +158,17 @@ jobs: steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up Python 3 - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: 3.x + python-version: "3.13" - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ github.sha }} path: out @@ -170,44 +200,3 @@ jobs: test "$STATIC_RESULT" = success test "$BUILD_RESULT" = success test "$EMULATOR_RESULT" = success - - publish-canary: - name: Publish tested canary - if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_canary }} - runs-on: ubuntu-latest - needs: - - build - - product-gate - permissions: - contents: write - steps: - - name: Download tested artifacts - uses: actions/download-artifact@v4 - with: - name: ${{ github.sha }} - path: out - - - name: Compute canary tag - id: version - run: echo "tag=kitsune-canary-${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" - - - name: Publish experimental canary - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.tag }} - target_commitish: ${{ github.sha }} - name: KitsuneMagisk ${{ steps.version.outputs.tag }} - prerelease: true - fail_on_unmatched_files: true - body: | - Experimental KitsuneMagisk canary built from `${{ github.sha }}`. - - This artifact passed source checks, debug/release compilation, JVM test tasks, - and the existing API 23/29/35 normal-Magisk AVD smoke matrix. That matrix does - not yet qualify persistent Direct-System/System Mode on commercial emulators. - - `31.0-kitsune` is an inherited compatibility value, not a claim that this tree - contains a newer Magisk core than official Magisk. - files: | - out/app-release.apk - out/stub-release.apk diff --git a/.github/workflows/security-lab.yml b/.github/workflows/security-lab.yml index 3d752a30a..bd2a2fecf 100644 --- a/.github/workflows/security-lab.yml +++ b/.github/workflows/security-lab.yml @@ -8,8 +8,15 @@ on: - ".gitmodules" - "**/*.gradle.kts" - "**/Cargo.toml" + - "app/shared/src/main/java/com/topjohnwu/magisk/utils/APKInstall.java" + - "app/src/main/java/com/topjohnwu/magisk/core/Info.kt" + - "app/src/main/java/com/topjohnwu/magisk/core/JobService.kt" + - "app/src/main/java/com/topjohnwu/magisk/core/download/**" - "app/src/main/java/com/topjohnwu/magisk/core/di/Networking.kt" - "app/src/main/java/com/topjohnwu/magisk/core/repository/UpdateCheckResult.kt" + - "app/src/main/java/com/topjohnwu/magisk/core/tasks/HideAPK.kt" + - "app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt" + - "app/src/main/res/raw/manager.sh" - "app/src/test/java/com/topjohnwu/magisk/core/repository/UpdateChannelPolicyTest.kt" - "app/gradle/libs.versions.toml" - "build.py" @@ -19,12 +26,17 @@ on: - "native/src/Application.mk" - "native/src/Cargo.lock" - "native/src/boot/**" + - "native/src/core/db.cpp" + - "native/src/core/db_migrations.hpp" + - "native/src/core/package.cpp" + - "native/src/core/su/**" - "native/src/external/**" - "native/src/sepolicy/**" - "scripts/avd_test.sh" - "scripts/security_avd_test.sh" - "scripts/util_functions.sh" - "security/**" + - "stub/src/main/java/com/topjohnwu/magisk/**" - "tests/security_lab/**" - "tools/security_lab/**" - "tools/termux-elf-cleaner" @@ -46,7 +58,7 @@ jobs: timeout-minutes: 90 steps: - name: Check out complete fork history - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 submodules: recursive @@ -90,7 +102,7 @@ jobs: run: >- ./gradlew :app:dependencies --configuration debugRuntimeClasspath - -PconfigPath="$PWD/config.prop" + -PconfigPath="$RUNNER_TEMP/no-config.prop" > "$RUNNER_TEMP/gradle-runtime.txt" - name: Verify dependency license evidence @@ -111,11 +123,12 @@ jobs: - name: Verify package-signature and hidden-manager recovery contract run: python3 -m tools.security_lab.signature_contract --check - - name: Run host security/property/fault tests + - name: Run scheduled host security/property/fault tests + if: ${{ github.event_name != 'pull_request' }} run: python3 -m unittest discover -s tests -p 'test_*.py' -v - name: Upload upstream and submodule evidence - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: upstream-security-ledger-${{ github.sha }} path: ${{ runner.temp }}/submodule-availability.json @@ -124,11 +137,12 @@ jobs: sanitized-device-corpus: name: UBSan Android parser corpus + if: ${{ github.event_name != 'pull_request' }} runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 submodules: recursive @@ -153,7 +167,7 @@ jobs: - name: Upload architecture-specific corpus evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: security-corpus-${{ github.sha }} path: out/security-corpus-*.json diff --git a/.gitignore b/.gitignore index 24b1751b5..1fe63ea83 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ native/out /.idea /build /captures +.DS_Store # Python host tooling and tests __pycache__/ diff --git a/README.MD b/README.MD index 91718326f..59b8c830d 100644 --- a/README.MD +++ b/README.MD @@ -1,118 +1,79 @@ -# KitsuneMagisk +![](docs/images/logo.png#gh-dark-mode-only) +![](docs/images/logo_dark.png#gh-light-mode-only) -> **Experimental active-development project. There is currently no generally supported stable release.** +# KitsuneMagisk -KitsuneMagisk is an unofficial Magisk fork whose primary product goal is persistent -**Direct-System/System Mode** installation for Android environments where normal boot-image -installation is unavailable or impractical. The main targets are commercial Android emulators and -selected real devices with a recoverable, persistently writable system layout. +KitsuneMagisk is an unofficial Magisk fork for Android 6.0 and newer. Its main +fork-specific feature is **Direct-System/System Mode** for controlled Android +environments where a normal boot-image installation is unavailable or impractical. This project is not affiliated with or supported by -[topjohnwu/Magisk](https://github.com/topjohnwu/Magisk). Do not ask the official Magisk project to -support Kitsune-specific behavior. - -## What Kitsune is for - -System Mode is the primary differentiator, but Kitsune remains a complete Magisk-derived root and -systemless-customization platform. It should preserve normal authorized boot/`init_boot`/ -`vendor_boot` installation, superuser policy, modules and boot scripts, BusyBox, `resetprop`, -`magiskboot`, `magiskpolicy`, manager recovery, and safe uninstall. Its secondary fork features are -measured MagiskHide/DenyList/SuList behavior, hidden-manager recovery, SELinux-disabled compatibility, -and a versioned external-Zygisk boundary. - -The project also maintains capability diagnostics, exact emulator/device qualification, upstream -change tracking, and an authorized disposable lab for fuzzing, vulnerability discovery, and -hardening. It does not promise to root arbitrary locked devices without a bootstrap or controlled -image, bypass every detector/attestation service, or support an architecture merely because its -binaries compile. See the roadmap's product-purpose hierarchy and evidence gates for the complete -contract. - -## Current status - -Development has resumed, but the existing `kitsune` branch is a reference implementation—not a -qualified claim of support for every emulator. - -- The project currently builds and passes the existing normal Magisk AVD smoke flow on API 23, 29, - and 35. -- Direct-System exists and has real emulator-specific history, but it does not yet have a dedicated - automated install/cold-boot/upgrade/uninstall test. -- Support will be published for exact emulator versions and Android images only after repeatable - qualification. “Works on one emulator” is useful evidence, not a universal compatibility claim. -- The current `31.0-kitsune` label is a compatibility/Android upgrade number inherited from earlier - maintainers. It does not mean this tree contains a newer Magisk core than official Magisk. - -See [the purpose and faithfulness audit](docs/faithfulness-audit.md) for the history-backed product -report, [the current support statement](docs/status.md) for the exact limitations, and -[the development roadmap](DEVELOPMENT_ROADMAP.md) for the audited implementation plan. - -## Important current limitations - -- Treat every existing APK as experimental. Automatic stable publication from every `kitsune` - commit is disabled by the current CI design. -- Built-in app and stub updates now fail closed with an explicit message; the dead prior-maintainer - endpoints are no longer contacted. Install test builds manually from this repository's Actions - artifacts or explicitly labeled canary releases until PR10 adds a project-owned update service. -- Published artifacts through `25fa2159` converged normal hide selections on `denylist` without an - upgrade migration. Current source adds transactional database migration v13, preserves a verified - version-12 backup and the legacy table, and tests divergent/SuList fixtures; published older APKs - remain subject to the original caveat. -- Debug native builds automatically grant ADB shell root during the current test flow. Debug APKs - are development artifacts and must not be described as ordinary stable releases. -- ReZygisk, NeoZygisk, DenyList, and SuList compatibility is provider- and version-specific. No - blanket external-Zygisk or current SuList guarantee is made. -- Direct-System modifies persistent Android partitions and boot-time policy/init files. Use a - disposable emulator instance or a recoverable device snapshot until the transactional installer - and rollback gates in the roadmap are complete. - -## Development branches - -- `kitsune` remains the known-working comparison branch. It keeps its name and receives only tests - or fixes needed by another build that will actually be released. -- `next-system` will start from the latest audited official stable (currently Magisk v30.7 after a - branch-cut recheck) and forward-port System Mode first. -- `next-system` becomes the new `kitsune` only after it meets or beats the current branch on the same - emulator/device parity matrix. The old implementation is then retained as a tag, not maintained as - a second product. - -## How to contribute - -Use the **Proposed pull-request sequence** in [DEVELOPMENT_ROADMAP.md](DEVELOPMENT_ROADMAP.md) as the -execution order. The P0/P1/P2 sections are the detailed requirements, evidence, test cases, and -acceptance criteria used while implementing those PRs; they are not a competing second task list. - -For the current development phase: - -1. Keep System Mode as the product gate. -2. Add a failing test or reproducible emulator case before changing boot/init/SELinux behavior. -3. Keep current-branch fixes conditional; do not modernize the old and new cores in parallel. -4. Run the same evidence-driven checks on current `kitsune` and `next-system` before promotion. +[topjohnwu/Magisk](https://github.com/topjohnwu/Magisk). Please do not send +Kitsune-specific support requests to the official Magisk project. + +## Features + +- **MagiskSU** — root access with app policy management +- **Magisk Modules** — systemless Android customization +- **MagiskBoot** and **MagiskPolicy** — boot-image and SELinux policy tools +- **Direct-System/System Mode** — persistent installation on explicitly qualified, + writable emulator or device layouts +- **Kitsune hiding modes** — MagiskHide/DenyList/SuList behavior retained where it is + tested and compatible + +## Project status + +The resumed project has not published its first production release. Current APKs are +development/test builds while installer recovery, signing migration, update ownership, +and exact emulator/device support are qualified. + +System Mode can modify persistent system, init, and policy files. Use a disposable +emulator or a device with a verified restore path. Support is recorded for exact +Android images and emulator/device versions; a successful build alone is not a support +claim. + +See the [current status](docs/status.md), [development roadmap](DEVELOPMENT_ROADMAP.md), +and [release notes](docs/release-notes.md) for details. ## Building -Requirements and platform details are in [docs/build.md](docs/build.md). The short path is: +Requirements and platform setup are documented in [docs/build.md](docs/build.md). ```sh -export ANDROID_SDK_ROOT=/path/to/android-sdk git submodule update --init --recursive ./build.py ndk -./build.py all # debug APKs and native binaries -./build.py -r all # release variant for testing +./build.py all ``` -Build outputs are written under `out/`. A clean checkout should use `config.prop.sample` as the -configuration reference. +Debug builds use the normal Android debug key. A release build uses one private, +long-lived maintainer keystore configured in the ignored `config.prop`, or in a +custom config file: + +```sh +./build.py -r all +# or: ./build.py -r -c /path/to/release.prop all +``` + +The config format is shown in [config.prop.sample](config.prop.sample). Private signing +material must never be committed to the repository. ## Documentation -- [Current support and known limitations](docs/status.md) -- [Purpose, history, install-route, and faithfulness audit](docs/faithfulness-audit.md) -- [Development roadmap and commit audit](DEVELOPMENT_ROADMAP.md) -- [Building and development](docs/build.md) - [Installation background](docs/install.md) +- [Building and development](docs/build.md) +- [Current support and limitations](docs/status.md) +- [Development roadmap](DEVELOPMENT_ROADMAP.md) - [Internal details](docs/details.md) -- [GPLv3 license](LICENSE) + +## Bug reports and contributions + +Use a debug build when reporting a reproducible issue. Include install logs for install +failures and logcat or kernel/Magisk logs for runtime failures. Never attach private +keys, account credentials, or device secrets. + +Translations live under each module's `src/main/res/values-*` directory. ## License -KitsuneMagisk and its submodules remain free software under the GNU General Public License, version -3 or later. See [LICENSE](LICENSE). +KitsuneMagisk and its submodules are free software under the GNU General Public +License, version 3 or later. See [LICENSE](LICENSE). diff --git a/TestKey-2024.jks b/TestKey-2024.jks deleted file mode 100644 index dee101eea5e7e12bdc53255f20f609606e692294..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3019 zcmb7GXHb)i5=}w?rI!Fo2_=LU$`_C#f`D|8DxrfSAXS7QO_UNaRFSS!>AfmQ6B1gC zA_@d)BE85}5Co;eC_59ZLIlb7$>u0}CX2fe4u6_%k!m9glf zmfy3OBw9L$);EkqXg!ioPM#SVtizCRYQ<=2>l3@ug?%@d`81H{1tuDZoS8;3p&H@RAHc?S zG;Q%v#9I{H^-r+1%3h!kQ~1KPR03cAgaqTWY<5hJcCwUmbYv1*ASr9CvHBsl$XX*;POtkOM_ApyDqo<)Kwc1Pt+SV|XWtyBTfI}OU1F>8cGN;p&%mZ@(6wQs1cJg^gJzXIqsYtsLNa(Iao}XF z&14FRQxL36U3Kt?-5^b;3mJ4hVz{cdOI!fb2tq_;zoyep2Ks7ap0hy zRcj>*X~Uacfs@P_Xw#Xz$QDNMerXbI5fE}QreIAG%?>0->l_^&J+fl85W4zE*w=87 z=yEDc5?HB~R7Rwz=y{?Byhth* z#6I6d{CsjM!2LrCuPV#@tH3}9MRib;;rhAK=XUcVQHI&c=yJ#?SL&VK;B_Dv^gR7!Xx_Lv*k&nL3cP z7?zTy_+pKX;X@Yc%FoY}V%UVtN9LVhRnRj_q5umlRFuEkfIg1WGvNs8rNtXF^{nNZS1Kttnb-?1D zJkV>vI!#rjPaRHuUK7n>s^jZf&zilqJx1k0ASJWmeAE^^D+2aN)MXnotkIQ1jiaR) zMcxedoW^B#?dwJmS6Il~Cja?iffp$eLkoogx_k6Q#jqJ(ADY_?Sm-rPTdbe>)6tgxIa>e8Na$yxQqsmS} zp?eb(Mal|s%vmCOgp-^-kk_ifpVbZY;Jh_=d{aCMr>JmfB}~XX$3=`yL(aTx(dI!h!!L0nkaH!qT2fQH*?v*~%b3Yyp!RptEcI*j zi#4v!VDiX%Ogqa-FNm8iiIJ=>4*8^{E^eb=L?7sdSalM9&|-bLwAL7JsAEe)7#EKC z$oOTZHYEx6W;N~@m&x0DOXI*XSjRtFe3!MRO3l77NyAoVs`gf%;;G`FXxh}llTbEx)?>x}Zd+0kt||paPM?fB z(2)#(R&FnTgFI09lqL{&x@TC;kk=k;8KQe0y-mxui;WhtTRw!`W%WsIkp3Z1 zAG%?N_oO(~{`}Ff8%&EB)pq7&7daqVFY_{k-%CWcCZ2Go_I<&_oUJ7-9BlTc^9GJm zclBu=)Jnl*l4j#RCZl0~=e-10PwX{5N83clA z0ywA|fP<75P(i>D2+X3I)$(;PyP-)@7gLc^oT+%#TrucgXyV3fHax| zV5gzNK&coQEL{EX8@h%_$pW&M{zKC-FlhL=VDEXjXt~}Fa03Lm7|)7m*-OA>Sw%Ep zdx2RFkOKfYwA{am|GOg&T=d^VoxK25IPg;tfCI-vaNtN#?JlS1xX8nU237tQ-tUZE z?S^HDcZFJ&4m5q8=0fWbVlmVR$x;$t02dSsX^P3)bX4`kcZ%?*2-w8+TV~IkBC{Og z?`4}QL`1Twt{A@JNka{7*Bi7@f=6|Rj<T0MkkjiBkJq2vKP$gE%+ZDSd2dW5*`cw5mtMsCgr+BH^bKOj`G2=dw+=@C%s4 zf%gl7!IT+ByhN>PpH9j<4L`kWM_V_>?~;uBwea|%N#+t4O0_xP%K-o1Wd!sBz!~ws z1m>3Sy;Rzdq$ zn1hs$21cG;O4YWpQKswAUCQ~^!unP^u`iE~gi<84;st^sE~IA;Fbjo&a~7{W=du%TD(v54psTz9VIw6%_?Xz zq`KDdSXq$*LK}8(Cl_BE-xNP*KxouRekPA7Et|Xear*v3q74zIKBG6mQiU(==I|ki)l0wLlE2GLNPlh1qjoVvf6G0-C(1z1KeujU_p0Jc^{iayID_Q5CAA?w QX= 0) { } + } + return matchesSha256(digest.digest(), expected); + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") public static void registerReceiver( Context context, BroadcastReceiver receiver, IntentFilter filter) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // noinspection InlinedApi context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); } else { + // Android 13 is the first release that requires an explicit + // exported state for dynamically registered non-system receivers. context.registerReceiver(receiver, filter); } } public static Session startSession(Context context) { - return startSession(context, null, null, null); + return startSession(context, null, null); } - public static Session startSession(Context context, String pkg, - Runnable onFailure, Runnable onSuccess) { - var receiver = new InstallReceiver(pkg, onSuccess, onFailure); + public static Session startSession( + Context context, Runnable onSuccess, Runnable onFailure) { context = context.getApplicationContext(); - if (pkg != null) { - // If pkg is not null, look for package added event - var filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED); - filter.addDataScheme("package"); - registerReceiver(context, receiver, filter); + var receiver = new InstallReceiver(context, onSuccess, onFailure); + try { + registerReceiver(context, receiver, new IntentFilter(receiver.sessionId)); + } catch (RuntimeException e) { + receiver.unregister(); + throw e; } - registerReceiver(context, receiver, new IntentFilter(receiver.sessionId)); return receiver; } + @SuppressWarnings("deprecation") + private static Intent getUserAction(Intent intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent.class); + } + return intent.getParcelableExtra(Intent.EXTRA_INTENT); + } + public interface Session { // @WorkerThread - OutputStream openStream(Context context) throws IOException; + void write(InputStream in) throws IOException; // @WorkerThread @Nullable Intent waitIntent(); + boolean isSuccessful(); } private static class InstallReceiver extends BroadcastReceiver implements Session { - private final String packageName; + private final Context context; private final Runnable onSuccess; private final Runnable onFailure; private final CountDownLatch latch = new CountDownLatch(1); + private final AtomicBoolean terminal = new AtomicBoolean(false); private Intent userAction = null; + private volatile boolean successful = false; + private volatile int installerSessionId = -1; final String sessionId = UUID.randomUUID().toString(); - private InstallReceiver(String packageName, Runnable onSuccess, Runnable onFailure) { - this.packageName = packageName; + private InstallReceiver(Context context, Runnable onSuccess, Runnable onFailure) { + this.context = context; this.onSuccess = onSuccess; this.onFailure = onFailure; } @Override public void onReceive(Context context, Intent intent) { - if (Intent.ACTION_PACKAGE_ADDED.equals(intent.getAction())) { - Uri data = intent.getData(); - if (data == null) - return; - String pkg = data.getSchemeSpecificPart(); - if (pkg.equals(packageName)) { - onSuccess(context); - } - } else if (sessionId.equals(intent.getAction())) { + if (sessionId.equals(intent.getAction())) { int status = intent.getIntExtra(EXTRA_STATUS, STATUS_FAILURE_INVALID); switch (status) { - case STATUS_PENDING_USER_ACTION -> - userAction = intent.getParcelableExtra(Intent.EXTRA_INTENT); - case STATUS_SUCCESS -> { - if (packageName == null) { - onSuccess(context); + case STATUS_PENDING_USER_ACTION -> { + userAction = getUserAction(intent); + if (userAction == null) { + abandon(intent.getIntExtra(EXTRA_SESSION_ID, installerSessionId)); + onFailure(); + } else { + latch.countDown(); } } + case STATUS_SUCCESS -> onSuccess(); default -> { - int id = intent.getIntExtra(EXTRA_SESSION_ID, 0); - var installer = context.getPackageManager().getPackageInstaller(); - try { - installer.abandonSession(id); - } catch (SecurityException ignored) { - } - if (onFailure != null) { - onFailure.run(); - } - context.getApplicationContext().unregisterReceiver(this); + int id = intent.getIntExtra(EXTRA_SESSION_ID, installerSessionId); + abandon(id); + onFailure(); } } + } + } + + private void onSuccess() { + if (!terminal.compareAndSet(false, true)) + return; + successful = true; + installerSessionId = -1; + try { + if (onSuccess != null) + onSuccess.run(); + } finally { + unregister(); latch.countDown(); } } - private void onSuccess(Context context) { - if (onSuccess != null) - onSuccess.run(); - context.getApplicationContext().unregisterReceiver(this); + private void onFailure() { + if (!terminal.compareAndSet(false, true)) + return; + userAction = null; + try { + if (onFailure != null) + onFailure.run(); + } finally { + unregister(); + latch.countDown(); + } + } + + private void unregister() { + try { + context.unregisterReceiver(this); + } catch (IllegalArgumentException ignored) { + } + } + + private void abandon(int id) { + if (id < 0) + return; + try { + context.getPackageManager().getPackageInstaller().abandonSession(id); + } catch (SecurityException | IllegalArgumentException ignored) { + } finally { + installerSessionId = -1; + } } @Override public Intent waitIntent() { + boolean completed = false; try { - // noinspection ResultOfMethodCallIgnored - latch.await(5, TimeUnit.SECONDS); - } catch (Exception ignored) {} + completed = latch.await(INSTALL_RESULT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + if (!completed) { + abandon(installerSessionId); + onFailure(); + } return userAction; } @Override - public OutputStream openStream(Context context) throws IOException { + public boolean isSuccessful() { + return successful; + } + + @Override + public void write(InputStream in) throws IOException { // noinspection InlinedApi var flag = PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE; var intent = new Intent(sessionId).setPackage(context.getPackageName()); @@ -150,20 +236,25 @@ public OutputStream openStream(Context context) throws IOException { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { params.setRequireUserAction(SessionParams.USER_ACTION_NOT_REQUIRED); } - var session = installer.openSession(installer.createSession(params)); - var out = session.openWrite(sessionId, 0, -1); - return new FilterOutputStream(out) { - @Override - public void write(byte[] b, int off, int len) throws IOException { - out.write(b, off, len); - } - @Override - public void close() throws IOException { - super.close(); + int id = -1; + boolean committed = false; + try { + id = installer.createSession(params); + installerSessionId = id; + try (var session = installer.openSession(id)) { + try (var out = session.openWrite(sessionId, 0, -1)) { + transfer(in, out); + session.fsync(out); + } session.commit(pending.getIntentSender()); - session.close(); + committed = true; } - }; + } finally { + if (!committed) { + abandon(id); + onFailure(); + } + } } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6add229d4..51c203cd3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> result.info.apply { - remote = this - updateCheckResult = result + // Capture the state before waiting for the mutex. If another caller + // completes the same request first, consume its result instead of + // immediately repeating a failed or successful network operation. + val observedState = remoteState + return remoteFetchLock.withLock { + val beforeFetch = remoteState + if (beforeFetch !== observedState && + beforeFetch.result !is UpdateCheckResult.NotChecked + ) { + return@withLock if (beforeFetch.result is UpdateCheckResult.Success) { + beforeFetch.info + } else { + null } + } + when (beforeFetch.result) { + is UpdateCheckResult.Success -> return@withLock beforeFetch.info is UpdateCheckResult.Unavailable -> { - updateCheckResult = result - null + if (beforeFetch.result.reason != UpdateUnavailableReason.REQUEST_FAILED) { + return@withLock null + } } - UpdateCheckResult.NotChecked -> null + UpdateCheckResult.NotChecked -> Unit } - } else remote + + val fetched = svc.fetchUpdate() + // A settings or network reset may occur while this request is in + // flight. Do not overwrite that newer state. + if (remoteState === beforeFetch) { + remoteState = when (fetched) { + is UpdateCheckResult.Success -> RemoteState(fetched.info, fetched) + is UpdateCheckResult.Unavailable -> RemoteState(EMPTY_REMOTE, fetched) + UpdateCheckResult.NotChecked -> beforeFetch + } + } + val current = remoteState + if (current.result is UpdateCheckResult.Success) current.info else null + } } // Device state diff --git a/app/src/main/java/com/topjohnwu/magisk/core/JobService.kt b/app/src/main/java/com/topjohnwu/magisk/core/JobService.kt index 57aa0dbae..4fd611d6d 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/JobService.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/JobService.kt @@ -14,23 +14,36 @@ import com.topjohnwu.magisk.core.di.ServiceLocator import com.topjohnwu.magisk.core.download.DownloadEngine import com.topjohnwu.magisk.core.download.Subject import com.topjohnwu.magisk.core.repository.UpdateChannelPolicy -import com.topjohnwu.magisk.core.repository.UpdateCheckResult import com.topjohnwu.magisk.core.repository.UpdateEndpointResolution import com.topjohnwu.magisk.view.Notifications +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit class JobService : BaseJobService() { + @Volatile private var mSession: Session? = null + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Volatile + private var updateJob: Job? = null @TargetApi(value = 34) inner class Session( + @Volatile private var params: JobParameters ) : DownloadEngine.Session { + @Volatile + private var stopped = false + override val context get() = this@JobService val engine = DownloadEngine(this) @@ -44,7 +57,17 @@ class JobService : BaseJobService() { } override fun onDownloadComplete() { - jobFinished(params, false) + if (mSession === this) { + mSession = null + } + if (!stopped) { + jobFinished(params, false) + } + } + + fun stop() { + stopped = true + engine.cancel() } } @@ -57,7 +80,29 @@ class JobService : BaseJobService() { } } - override fun onStopJob(params: JobParameters?) = false + override fun onStopJob(params: JobParameters?): Boolean { + return if (params?.jobId == Const.ID.CHECK_UPDATE_JOB_ID) { + val job = updateJob + if (job?.isActive == true) { + job.cancel() + updateJob = null + true + } else { + false + } + } else { + mSession?.stop() + mSession = null + false + } + } + + override fun onDestroy() { + mSession?.stop() + mSession = null + serviceScope.cancel() + super.onDestroy() + } @TargetApi(value = 34) private fun downloadFile(params: JobParameters): Boolean { @@ -77,21 +122,28 @@ class JobService : BaseJobService() { } private fun checkUpdate(params: JobParameters): Boolean { - GlobalScope.launch(Dispatchers.IO) { + // Start lazily so the completion path can never run before updateJob + // publishes the exact Job instance used for compare-and-clear. + val job = serviceScope.launch(start = CoroutineStart.LAZY) { try { - when (val result = ServiceLocator.networkService.fetchUpdate()) { - is UpdateCheckResult.Success -> { - Info.remote = result.info - if (Info.env.isActive && BuildConfig.VERSION_CODE < result.info.magisk.versionCode) - Notifications.updateAvailable() - } - is UpdateCheckResult.Unavailable, - UpdateCheckResult.NotChecked -> Unit + val info = Info.getRemote(ServiceLocator.networkService) + if (info != null && Info.env.isActive && + BuildConfig.VERSION_CODE < info.magisk.versionCode + ) { + Notifications.updateAvailable() } } finally { - jobFinished(params, false) + if (currentCoroutineContext().isActive) { + jobFinished(params, false) + } + val completed = currentCoroutineContext()[Job] + if (updateJob === completed) { + updateJob = null + } } } + updateJob = job + job.start() return true } diff --git a/app/src/main/java/com/topjohnwu/magisk/core/Receiver.kt b/app/src/main/java/com/topjohnwu/magisk/core/Receiver.kt index 635e384a1..1ea4cd9a9 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/Receiver.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/Receiver.kt @@ -11,7 +11,7 @@ import com.topjohnwu.magisk.core.download.Subject import com.topjohnwu.magisk.view.Notifications import com.topjohnwu.magisk.view.Shortcuts import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch open class Receiver : BaseReceiver() { @@ -33,8 +33,15 @@ open class Receiver : BaseReceiver() { intent ?: return super.onReceive(context, intent) - fun rmPolicy(uid: Int) = GlobalScope.launch { - policyDB.delete(uid) + fun rmPolicy(uid: Int) { + val pendingResult = goAsync() + appScope.launch(Dispatchers.IO) { + try { + policyDB.delete(uid) + } finally { + pendingResult.finish() + } + } } when (intent.action ?: return) { @@ -53,7 +60,7 @@ open class Receiver : BaseReceiver() { getUid(intent)?.let { rmPolicy(it) } } Intent.ACTION_PACKAGE_FULLY_REMOVED -> { - getPkg(intent)?.let { Shell.cmd("magisk --denylist rm $it").submit() } + getPkg(intent)?.let { Shell.cmd("magisk magiskhide rm $it").submit() } } Intent.ACTION_LOCALE_CHANGED -> Shortcuts.setupDynamic(context) Intent.ACTION_MY_PACKAGE_REPLACED -> { diff --git a/app/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt b/app/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt index 70b5a53a0..c106ffc0a 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt @@ -26,6 +26,7 @@ import com.topjohnwu.magisk.core.wrap interface ContentResultCallback: ActivityResultCallback, Parcelable { fun onActivityLaunch() {} + fun onActivityCancel() {} // Make the result type explicitly non-null override fun onActivityResult(result: Uri) } @@ -40,7 +41,11 @@ abstract class BaseActivity : AppCompatActivity() { private var installCallback: ((Boolean) -> Unit)? = null private val requestInstall = registerForActivityResult(RequestInstall()) { - installCallback?.invoke(it) + // Settings implementations do not consistently return RESULT_OK after + // changing this app-op. Query the authoritative state on return. + val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + packageManager.canRequestPackageInstalls() + installCallback?.invoke(granted) installCallback = null } @@ -53,6 +58,7 @@ abstract class BaseActivity : AppCompatActivity() { private var contentCallback: ContentResultCallback? = null private val getContent = registerForActivityResult(GetContent()) { if (it != null) contentCallback?.onActivityResult(it) + else contentCallback?.onActivityCancel() contentCallback = null } @@ -104,7 +110,12 @@ abstract class BaseActivity : AppCompatActivity() { } if (permission == REQUEST_INSTALL_PACKAGES) { installCallback = callback - requestInstall.launch(Unit) + try { + requestInstall.launch(Unit) + } catch (e: ActivityNotFoundException) { + installCallback?.invoke(false) + installCallback = null + } } else { permissionCallback = callback requestPermission.launch(permission) @@ -117,6 +128,8 @@ abstract class BaseActivity : AppCompatActivity() { getContent.launch(type) callback.onActivityLaunch() } catch (e: ActivityNotFoundException) { + contentCallback?.onActivityCancel() + contentCallback = null toast(R.string.app_not_found, Toast.LENGTH_SHORT) } } diff --git a/app/src/main/java/com/topjohnwu/magisk/core/download/DownloadEngine.kt b/app/src/main/java/com/topjohnwu/magisk/core/download/DownloadEngine.kt index c6650df16..7b1d706c7 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/download/DownloadEngine.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/download/DownloadEngine.kt @@ -10,6 +10,7 @@ import android.content.Context import android.net.Uri import android.os.Build import android.os.Bundle +import android.system.Os import androidx.collection.SparseArrayCompat import androidx.collection.isNotEmpty import androidx.core.content.getSystemService @@ -32,20 +33,25 @@ import com.topjohnwu.magisk.core.ktx.forEach import com.topjohnwu.magisk.core.ktx.set import com.topjohnwu.magisk.core.ktx.withStreams import com.topjohnwu.magisk.core.ktx.writeTo +import com.topjohnwu.magisk.core.repository.UpdateChannelPolicy import com.topjohnwu.magisk.core.tasks.HideAPK import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream import com.topjohnwu.magisk.core.utils.ProgressInputStream import com.topjohnwu.magisk.utils.APKInstall import com.topjohnwu.magisk.view.Notifications +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import okhttp3.ResponseBody import timber.log.Timber +import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.io.InputStream -import java.io.OutputStream +import java.security.DigestInputStream +import java.security.MessageDigest import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipInputStream @@ -173,13 +179,18 @@ class DownloadEngine( } else { notifyFinish(subject) } - } catch (e: IOException) { + } catch (e: CancellationException) { + notifyRemove(subject.notifyId) + throw e + } catch (e: Exception) { Timber.e(e) notifyFail(subject) } } } + fun cancel() = job.cancel() + @Synchronized fun reattach() { val builder = notifications[attachedId] ?: return @@ -265,13 +276,36 @@ class DownloadEngine( } private suspend fun handleApp(stream: InputStream, subject: Subject.App) { - val external = subject.file.outputStream() + // Notification IDs are stable UI identifiers, not unique transaction + // identifiers. Use a private per-download file so overlapping retries + // cannot truncate or install each other's verified bytes. + val downloadedApk = File.createTempFile( + "update-${subject.notifyId}-", + ".apk", + context.cacheDir, + ) + try { + val digest = MessageDigest.getInstance("SHA-256") + DigestInputStream(stream, digest).copyAndClose(downloadedApk.outputStream()) + if (!UpdateChannelPolicy.matchesSha256(subject.sha256, digest.digest())) { + throw IOException("Downloaded APK failed SHA-256 verification") + } - if (isRunningAsStub) { - val updateApk = StubApk.update(context) - try { + val external = subject.file.outputStream() + if (isRunningAsStub) { + val updateApk = StubApk.update(context) // Download full APK to stub update path - stream.copyAndClose(TeeOutputStream(external, updateApk.outputStream())) + downloadedApk.inputStream().copyAndClose(external) + val staging = File.createTempFile("update-", ".apk", updateApk.parentFile) + try { + FileOutputStream(staging).use { output -> + downloadedApk.inputStream().use { it.copyAll(output) } + output.fd.sync() + } + Os.rename(staging.path, updateApk.path) + } finally { + staging.delete() + } // Also upgrade stub notifyUpdate(subject.notifyId) { @@ -281,25 +315,37 @@ class DownloadEngine( } // Extract stub - val zf = ZipFile(updateApk) val apk = context.cachedFile("stub.apk") - apk.delete() - zf.getInputStream(zf.getEntry("assets/stub.apk")).writeTo(apk) - zf.close() - - // Patch and install - subject.intent = HideAPK.upgrade(context, apk) - ?: throw IOException("HideAPK patch error") - apk.delete() - } catch (e: Exception) { - // If any error occurred, do not let stub load the new APK - updateApk.delete() - throw e + try { + apk.delete() + ZipFile(updateApk).use { zip -> + val entry = zip.getEntry("assets/stub.apk") + ?: throw IOException("Verified manager APK contains no stub payload") + zip.getInputStream(entry).use { it.writeTo(apk) } + } + + // Patch and install + val (accepted, intent) = HideAPK.upgrade(context, apk) + if (!accepted) throw IOException("HideAPK patch or install error") + subject.intent = intent + } finally { + apk.delete() + } + } else { + downloadedApk.inputStream().copyAndClose(external) + val session = APKInstall.startSession(context) + downloadedApk.inputStream().use { session.write(it) } + subject.intent = session.waitIntent() + if (subject.intent == null && !session.isSuccessful) { + throw IOException("Package installer did not accept the verified APK") + } } - } else { - val session = APKInstall.startSession(context) - stream.copyAndClose(TeeOutputStream(external, session.openStream(context))) - subject.intent = session.waitIntent() + } catch (e: Exception) { + // Never let the stub load an unverified or partially written update. + if (isRunningAsStub) StubApk.update(context).delete() + throw e + } finally { + downloadedApk.delete() } } @@ -330,24 +376,6 @@ class DownloadEngine( } } - private class TeeOutputStream( - private val o1: OutputStream, - private val o2: OutputStream - ) : OutputStream() { - override fun write(b: Int) { - o1.write(b) - o2.write(b) - } - override fun write(b: ByteArray?, off: Int, len: Int) { - o1.write(b, off, len) - o2.write(b, off, len) - } - override fun close() { - o1.close() - o2.close() - } - } - private fun ResponseBody.toProgressStream(subject: Subject): InputStream { val max = contentLength() val total = max.toFloat() / 1048576 diff --git a/app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt b/app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt index d82ecae5c..a801f0186 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt @@ -55,6 +55,7 @@ sealed class Subject : Parcelable { ) : Subject() { override val title: String get() = "Magisk-${json.version}(${json.versionCode})" override val url: String get() = json.link + val sha256: String get() = json.sha256 @IgnoredOnParcel override val file by lazy { diff --git a/app/src/main/java/com/topjohnwu/magisk/core/ktx/XJVM.kt b/app/src/main/java/com/topjohnwu/magisk/core/ktx/XJVM.kt index 0c70309b0..af2d19abf 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/ktx/XJVM.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/ktx/XJVM.kt @@ -1,15 +1,19 @@ package com.topjohnwu.magisk.core.ktx +import android.system.Os import androidx.collection.SparseArrayCompat import com.topjohnwu.magisk.core.utils.currentLocale import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream @@ -47,18 +51,35 @@ suspend fun InputStream.copyAll( dispatcher: CoroutineDispatcher = Dispatchers.IO ): Long { return withContext(dispatcher) { + val context = currentCoroutineContext() var bytesCopied: Long = 0 val buffer = ByteArray(bufferSize) var bytes = read(buffer) - while (isActive && bytes >= 0) { + while (bytes >= 0) { + context.ensureActive() out.write(buffer, 0, bytes) bytesCopied += bytes bytes = read(buffer) } + context.ensureActive() bytesCopied } } +fun File.writeTextAtomically(text: String) { + val parent = parentFile ?: throw IOException("File has no parent directory: $this") + val staging = File.createTempFile("kitsune-write-", ".tmp", parent) + try { + FileOutputStream(staging).use { output -> + output.write(text.toByteArray()) + output.fd.sync() + } + Os.rename(staging.path, path) + } finally { + staging.delete() + } +} + @Throws(IOException::class) suspend inline fun InputStream.copyAndClose( out: OutputStream, @@ -86,6 +107,7 @@ fun MutableMap.synchronized(): MutableMap = Collections.synch fun Class<*>.reflectField(name: String): Field = getDeclaredField(name).apply { isAccessible = true } +@OptIn(ExperimentalCoroutinesApi::class) inline fun Flow.concurrentMap(crossinline transform: suspend (T) -> R): Flow { return flatMapMerge { value -> flow { emit(transform(value)) } diff --git a/app/src/main/java/com/topjohnwu/magisk/core/model/UpdateInfo.kt b/app/src/main/java/com/topjohnwu/magisk/core/model/UpdateInfo.kt index 60b3a11d4..175377160 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/model/UpdateInfo.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/model/UpdateInfo.kt @@ -15,7 +15,8 @@ data class MagiskJson( val version: String = "", val versionCode: Int = -1, val link: String = "", - val note: String = "" + val note: String = "", + val sha256: String = "" ) : Parcelable @JsonClass(generateAdapter = true) diff --git a/app/src/main/java/com/topjohnwu/magisk/core/repository/NetworkService.kt b/app/src/main/java/com/topjohnwu/magisk/core/repository/NetworkService.kt index 88f458e09..d587dcb43 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/repository/NetworkService.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/repository/NetworkService.kt @@ -27,7 +27,13 @@ class NetworkService( endpoint as UpdateEndpointResolution.Remote return try { - UpdateCheckResult.Success(pages.fetchUpdateJSON(endpoint.url)) + val info = pages.fetchUpdateJSON(endpoint.url) + val validationError = UpdateChannelPolicy.validate(info) + if (validationError == null) { + UpdateCheckResult.Success(info) + } else { + UpdateCheckResult.Unavailable(validationError) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/app/src/main/java/com/topjohnwu/magisk/core/repository/UpdateCheckResult.kt b/app/src/main/java/com/topjohnwu/magisk/core/repository/UpdateCheckResult.kt index 573c7c953..b0aae2117 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/repository/UpdateCheckResult.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/repository/UpdateCheckResult.kt @@ -2,6 +2,7 @@ package com.topjohnwu.magisk.core.repository import com.topjohnwu.magisk.core.Config import com.topjohnwu.magisk.core.model.UpdateInfo +import com.topjohnwu.magisk.utils.APKInstall import java.net.URI sealed class UpdateCheckResult { @@ -15,6 +16,7 @@ enum class UpdateUnavailableReason { OFFLINE, EMPTY_CUSTOM_URL, INVALID_CUSTOM_URL, + INVALID_UPDATE_METADATA, REQUEST_FAILED, } @@ -31,6 +33,18 @@ sealed class UpdateEndpointResolution { * request. A user-supplied custom channel remains available only over HTTPS. */ object UpdateChannelPolicy { + private val sha256Pattern = Regex("^[0-9a-fA-F]{64}$") + + private fun isValidHttpsUrl(value: String): Boolean { + if (value.isEmpty() || value != value.trim()) return false + val uri = runCatching { URI(value) }.getOrNull() ?: return false + return uri.scheme?.lowercase() == "https" && + !uri.host.isNullOrBlank() && + uri.userInfo == null && + uri.fragment == null && + (uri.port == -1 || uri.port in 1..65535) + } + fun resolve(channel: Int, customUrl: String): UpdateEndpointResolution { if (channel == Config.Value.CUSTOM_CHANNEL) { val normalized = customUrl.trim() @@ -39,14 +53,7 @@ object UpdateChannelPolicy { UpdateUnavailableReason.EMPTY_CUSTOM_URL ) } - val uri = runCatching { URI(normalized) }.getOrNull() - if (uri == null || - uri.scheme?.lowercase() != "https" || - uri.host.isNullOrBlank() || - uri.userInfo != null || - uri.fragment != null || - (uri.port != -1 && uri.port !in 1..65535) - ) { + if (!isValidHttpsUrl(normalized)) { return UpdateEndpointResolution.Unavailable( UpdateUnavailableReason.INVALID_CUSTOM_URL ) @@ -58,4 +65,21 @@ object UpdateChannelPolicy { UpdateUnavailableReason.PROJECT_SERVICE_NOT_CONFIGURED ) } + + fun validate(info: UpdateInfo): UpdateUnavailableReason? { + val apk = info.magisk + if (apk.version.isBlank() || + apk.versionCode <= 0 || + !isValidHttpsUrl(apk.link) || + (apk.note.isNotEmpty() && !isValidHttpsUrl(apk.note)) || + !sha256Pattern.matches(apk.sha256) + ) { + return UpdateUnavailableReason.INVALID_UPDATE_METADATA + } + return null + } + + fun matchesSha256(expected: String, actual: ByteArray): Boolean { + return APKInstall.matchesSha256(actual, expected) + } } diff --git a/app/src/main/java/com/topjohnwu/magisk/core/tasks/HideAPK.kt b/app/src/main/java/com/topjohnwu/magisk/core/tasks/HideAPK.kt index 0c3d1073c..f8896090f 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/tasks/HideAPK.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/tasks/HideAPK.kt @@ -12,7 +12,6 @@ import com.topjohnwu.magisk.core.Config import com.topjohnwu.magisk.core.Const import com.topjohnwu.magisk.core.Provider import com.topjohnwu.magisk.core.ktx.await -import com.topjohnwu.magisk.core.ktx.copyAndClose import com.topjohnwu.magisk.core.ktx.toast import com.topjohnwu.magisk.core.ktx.writeTo import com.topjohnwu.magisk.core.utils.AXML @@ -21,6 +20,7 @@ import com.topjohnwu.magisk.signing.JarMap import com.topjohnwu.magisk.signing.SignApk import com.topjohnwu.magisk.utils.APKInstall import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Runnable import kotlinx.coroutines.withContext @@ -30,6 +30,7 @@ import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream import java.security.SecureRandom +import java.util.concurrent.atomic.AtomicBoolean import kotlin.random.asKotlinRandom object HideAPK { @@ -170,40 +171,59 @@ object HideAPK { activity.finish() } - private suspend fun patchAndHide(activity: Activity, label: String, onFailure: Runnable): Boolean { + private suspend fun patchAndHide( + activity: Activity, + label: String, + onFailure: Runnable, + ): Boolean { val stub = File(activity.cacheDir, "stub.apk") - try { - activity.assets.open("stub.apk").writeTo(stub) - } catch (e: IOException) { - Timber.e(e) - return false - } - - // Generate a new random package name and signature val repack = File(activity.cacheDir, "patched.apk") - val pkg = genPackageName() - Config.keyStoreRaw = "" + try { + try { + activity.assets.open("stub.apk").writeTo(stub) + } catch (e: IOException) { + Timber.e(e) + return false + } - if (!patch(activity, stub, FileOutputStream(repack), pkg, label)) - return false + // Generate a new random package name and signature + val pkg = genPackageName() + Config.keyStoreRaw = "" - // Install and auto launch app - val session = APKInstall.startSession(activity, pkg, onFailure) { - launchApp(activity, pkg) - } + if (!FileOutputStream(repack).use { patch(activity, stub, it, pkg, label) }) + return false - Config.suManager = pkg - val cmd = "adb_pm_install $repack $pkg" - if (Shell.cmd(cmd).exec().isSuccess) return true + val cmd = "adb_pm_install $repack $pkg" + if (Shell.cmd(cmd).exec().isSuccess) { + Config.suManager = pkg + withContext(Dispatchers.Main) { launchApp(activity, pkg) } + return true + } - try { - repack.inputStream().copyAndClose(session.openStream(activity)) - } catch (e: IOException) { - Timber.e(e) - return false + // Install and auto launch app + val session = APKInstall.startSession( + activity, + { + Config.suManager = pkg + launchApp(activity, pkg) + }, + onFailure, + ) + try { + repack.inputStream().use { session.write(it) } + } catch (e: IOException) { + Timber.e(e) + return false + } + val intent = session.waitIntent() + if (intent != null) { + withContext(Dispatchers.Main) { activity.startActivity(intent) } + } + return intent != null || session.isSuccessful + } finally { + stub.delete() + repack.delete() } - session.waitIntent()?.let { activity.startActivity(it) } ?: return false - return true } @Suppress("DEPRECATION") @@ -214,12 +234,22 @@ object HideAPK { setCancelable(false) show() } + val failureReported = AtomicBoolean(false) val onFailure = Runnable { - dialog.dismiss() - activity.toast(R.string.failure, Toast.LENGTH_LONG) + if (failureReported.compareAndSet(false, true)) { + activity.runOnUiThread { + dialog.dismiss() + activity.toast(R.string.failure, Toast.LENGTH_LONG) + } + } } - val success = withContext(Dispatchers.IO) { - patchAndHide(activity, label, onFailure) + val success = try { + withContext(Dispatchers.IO) { patchAndHide(activity, label, onFailure) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e) + false } if (!success) onFailure.run() } @@ -232,41 +262,72 @@ object HideAPK { setCancelable(false) show() } + val failureReported = AtomicBoolean(false) val onFailure = Runnable { - dialog.dismiss() - activity.toast(R.string.failure, Toast.LENGTH_LONG) + if (failureReported.compareAndSet(false, true)) { + activity.runOnUiThread { + dialog.dismiss() + activity.toast(R.string.failure, Toast.LENGTH_LONG) + } + } } val apk = StubApk.current(activity) - val session = APKInstall.startSession(activity, APPLICATION_ID, onFailure) { - launchApp(activity, APPLICATION_ID) + val cmd = "adb_pm_install $apk $APPLICATION_ID" + if (Shell.cmd(cmd).await().isSuccess) { + Config.suManager = "" dialog.dismiss() + launchApp(activity, APPLICATION_ID) + return } - Config.suManager = "" - val cmd = "adb_pm_install $apk $APPLICATION_ID" - if (Shell.cmd(cmd).await().isSuccess) return - val success = withContext(Dispatchers.IO) { - try { - apk.inputStream().copyAndClose(session.openStream(activity)) - } catch (e: IOException) { - Timber.e(e) - return@withContext false + val session = APKInstall.startSession( + activity, + { + Config.suManager = "" + launchApp(activity, APPLICATION_ID) + dialog.dismiss() + }, + onFailure, + ) + val success = try { + withContext(Dispatchers.IO) { + try { + apk.inputStream().use { session.write(it) } + } catch (e: IOException) { + Timber.e(e) + return@withContext false + } + val intent = session.waitIntent() + if (intent != null) { + withContext(Dispatchers.Main) { activity.startActivity(intent) } + } + return@withContext intent != null || session.isSuccessful } - session.waitIntent()?.let { activity.startActivity(it) } ?: return@withContext false - return@withContext true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e) + false } if (!success) onFailure.run() } @WorkerThread - fun upgrade(context: Context, apk: File): Intent? { + fun upgrade(context: Context, apk: File): Pair { val label = context.applicationInfo.nonLocalizedLabel val pkg = context.packageName - val session = APKInstall.startSession(context) - session.openStream(context).use { - if (!patch(context, apk, it, pkg, label)) { - return null + val repack = File.createTempFile("magisk-upgrade-", ".apk", context.cacheDir) + return try { + FileOutputStream(repack).use { + if (!patch(context, apk, it, pkg, label)) { + return false to null + } } + val session = APKInstall.startSession(context) + repack.inputStream().use { session.write(it) } + val intent = session.waitIntent() + (intent != null || session.isSuccessful) to intent + } finally { + repack.delete() } - return session.waitIntent() } } diff --git a/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt b/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt index 48a12a091..001e5c3f3 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/tasks/MagiskInstaller.kt @@ -93,7 +93,7 @@ abstract class MagiskInstallImpl protected constructor( return true } - private suspend fun extractFiles(): Boolean { + private suspend fun extractFiles(includeSystemModeManager: Boolean = false): Boolean { console.add("- Device platform: ${Const.CPU_ABI}") console.add("- Installing: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") @@ -145,6 +145,10 @@ abstract class MagiskInstallImpl protected constructor( val dest = File(installDir, script) context.assets.open(script).writeTo(dest) } + if (includeSystemModeManager) { + val dest = File(installDir, "system_mode_manager.sh") + context.resources.openRawResource(R.raw.manager).writeTo(dest) + } // Extract chromeos tools File(installDir, "chromeos").mkdir() for (file in listOf("futility", "kernel_data_key.vbprivk", "kernel.keyblock")) { @@ -537,7 +541,31 @@ abstract class MagiskInstallImpl protected constructor( protected suspend fun direct() = findImage() && extractFiles() && patchBoot() && flashBoot() - protected suspend fun direct_system() = extractFiles() && "xdirect_install_system \"$installDir\" \"dummy\" \"$AppApkPath\"".sh().isSuccess + protected suspend fun direct_system(): Boolean { + if (!BuildConfig.DEBUG) { + console.add("! System Mode is disabled in release builds") + return false + } + if (!extractFiles(includeSystemModeManager = true)) + return false + + val busybox = "$installDir/busybox" + val manager = "$installDir/system_mode_manager.sh" + val command = """ + "$busybox" unshare -m "$busybox" sh -c ' + mount --make-rprivate / || exit 1 + . "${'$'}1/system_mode_manager.sh" || exit 1 + rm -f "${'$'}1/system_mode_manager.sh" || exit 1 + . "${'$'}1/util_functions.sh" || exit 1 + app_init + xdirect_install_system "${'$'}1" "${'$'}2" + ' system-mode "$installDir" "$AppApkPath" + _system_mode_rc=${'$'}? + rm -f "$manager" + (exit "${'$'}_system_mode_rc") + """.trimIndent() + return command.sh().isSuccess + } protected suspend fun secondSlot() = findSecondary() && extractFiles() && patchBoot() && flashBoot() && postOTA() @@ -546,15 +574,33 @@ abstract class MagiskInstallImpl protected constructor( protected fun uninstall() = "run_uninstaller $AppApkPath".sh().isSuccess + protected fun cleanupInstallDir() { + if (::installDir.isInitialized) { + Shell.cmd("rm -rf \"$installDir\"").exec() + } + } + @WorkerThread protected abstract suspend fun operations(): Boolean + @WorkerThread + protected open fun finishOperation(success: Boolean) = Unit + open suspend fun exec(): Boolean { if (haveActiveSession.getAndSet(true)) return false - val result = withContext(Dispatchers.IO) { operations() } - haveActiveSession.set(false) - return result + return try { + withContext(Dispatchers.IO) { + var success = false + try { + operations().also { success = it } + } finally { + finishOperation(success) + } + } + } finally { + haveActiveSession.set(false) + } } companion object { @@ -567,15 +613,13 @@ abstract class MagiskInstaller( logs: MutableList ) : MagiskInstallImpl(console, logs) { - override suspend fun exec(): Boolean { - val success = super.exec() + override fun finishOperation(success: Boolean) { if (success) { console.add("- All done!") } else { - Shell.cmd("rm -rf $installDir").submit() + cleanupInstallDir() console.add("! Installation failed") } - return success } class Patch( diff --git a/app/src/main/java/com/topjohnwu/magisk/dialog/ManagerInstallDialog.kt b/app/src/main/java/com/topjohnwu/magisk/dialog/ManagerInstallDialog.kt index a02c040af..f6601b692 100644 --- a/app/src/main/java/com/topjohnwu/magisk/dialog/ManagerInstallDialog.kt +++ b/app/src/main/java/com/topjohnwu/magisk/dialog/ManagerInstallDialog.kt @@ -6,7 +6,9 @@ import com.topjohnwu.magisk.core.di.AppContext import com.topjohnwu.magisk.core.di.ServiceLocator import com.topjohnwu.magisk.core.download.DownloadEngine import com.topjohnwu.magisk.core.download.Subject +import com.topjohnwu.magisk.core.ktx.writeTextAtomically import com.topjohnwu.magisk.view.MagiskDialog +import timber.log.Timber import java.io.File class ManagerInstallDialog : MarkDownDialog() { @@ -14,13 +16,14 @@ class ManagerInstallDialog : MarkDownDialog() { private val svc get() = ServiceLocator.networkService override suspend fun getMarkdownText(): String { - val text = svc.fetchString(Info.remote.magisk.note) - // Cache the changelog - AppContext.cacheDir.listFiles { _, name -> name.endsWith(".md") }.orEmpty().forEach { - it.delete() + val remote = Info.remote.magisk + if (remote.note.isEmpty()) return "" + val cache = File(AppContext.cacheDir, "update-note-${remote.versionCode}.md") + if (cache.isFile) return cache.readText() + return svc.fetchString(remote.note).also { + runCatching { cache.writeTextAtomically(it) } + .onFailure { error -> Timber.w(error, "Unable to cache update notes") } } - File(AppContext.cacheDir, "${Info.remote.magisk.versionCode}.md").writeText(text) - return text } override fun build(dialog: MagiskDialog) { diff --git a/app/src/main/java/com/topjohnwu/magisk/dialog/SystemModeWarningDialog.kt b/app/src/main/java/com/topjohnwu/magisk/dialog/SystemModeWarningDialog.kt new file mode 100644 index 000000000..acf999b65 --- /dev/null +++ b/app/src/main/java/com/topjohnwu/magisk/dialog/SystemModeWarningDialog.kt @@ -0,0 +1,25 @@ +package com.topjohnwu.magisk.dialog + +import com.topjohnwu.magisk.R +import com.topjohnwu.magisk.events.DialogBuilder +import com.topjohnwu.magisk.view.MagiskDialog + +class SystemModeWarningDialog( + private val onConfirm: () -> Unit, +) : DialogBuilder { + + override fun build(dialog: MagiskDialog) { + dialog.apply { + setTitle(android.R.string.dialog_alert_title) + setMessage(R.string.system_mode_warning) + setButton(MagiskDialog.ButtonType.POSITIVE) { + text = android.R.string.ok + onClick { onConfirm() } + } + setButton(MagiskDialog.ButtonType.NEGATIVE) { + text = android.R.string.cancel + } + setCancelable(true) + } + } +} diff --git a/app/src/main/java/com/topjohnwu/magisk/ui/SplashActivity.kt b/app/src/main/java/com/topjohnwu/magisk/ui/SplashActivity.kt index fa1073f1e..30be399cf 100644 --- a/app/src/main/java/com/topjohnwu/magisk/ui/SplashActivity.kt +++ b/app/src/main/java/com/topjohnwu/magisk/ui/SplashActivity.kt @@ -27,11 +27,12 @@ import com.topjohnwu.magisk.ui.theme.Theme import com.topjohnwu.magisk.view.MagiskDialog import com.topjohnwu.magisk.view.Shortcuts import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File -import java.io.IOException @SuppressLint("CustomSplashScreen") abstract class SplashActivity : NavigationActivity() { @@ -155,11 +156,20 @@ abstract class SplashActivity : NavigationActivity