71 Commits
main ... miuix

Author SHA1 Message Date
shirkneko
d5be402710 manager: fix sulog error
Some checks failed
Build Manager / build-lkm (push) Has been cancelled
Build Manager / build-user_scanner (ubuntu-latest, All-linux-android) (push) Has been cancelled
Build Manager / build-ksud (ubuntu-latest, aarch64-linux-android) (push) Has been cancelled
Build Manager / build-ksud (ubuntu-latest, armv7-linux-androideabi) (push) Has been cancelled
Build Manager / build-ksud (ubuntu-latest, x86_64-linux-android) (push) Has been cancelled
Build Manager / build-manager (false) (push) Has been cancelled
Build Manager / build-manager (true) (push) Has been cancelled
2025-11-27 21:55:22 +08:00
YuKongA
809b74a5f3 manager: fix the color of TextField in TemplateEditor 2025-11-27 20:39:39 +08:00
YuKongA
a98a718d61 manager: add new AID 2025-11-27 20:35:39 +08:00
shirkneko
45ea8455fc kernel: fix build 2025-11-27 20:33:01 +08:00
YuKongA
f81023246f manager: add markdown-ext-tables 2025-11-27 19:33:58 +08:00
YuKongA
8b06df3468 manager: fix Markdown table rendering 2025-11-27 19:31:31 +08:00
YuKongA
46cfd936a0 userspace: add feature get --config
* fix #2980
2025-11-27 19:28:30 +08:00
YuKongA
de04ea9db0 manager: redo fetchAppList onCreate 2025-11-27 19:19:52 +08:00
YuKongA
3853928305 manager: add basic module repo support 2 2025-11-27 19:19:36 +08:00
weishu
5e64eee624 kernel: Fix execve filename access on ARM64 2025-11-27 19:12:10 +08:00
YuKongA
3a97e6580f manager: add basic module repo support 2025-11-27 19:11:31 +08:00
YuKongA
228b6b1273 manager: fix dark theme color 2025-11-27 19:08:19 +08:00
YuKongA
314fbe8cf7 manager: fix SearchPager color 2025-11-27 19:04:06 +08:00
YuKongA
ccb38061ee manager: upgrade ndk29 2025-11-27 19:03:56 +08:00
weishu
b631344e7c kernel: Add preempt_{disable|enable}_notrace for MODULE 2025-11-27 19:02:10 +08:00
weishu
85d739a153 sucompat: Fix execve filename access on ARM64 2025-11-27 19:00:35 +08:00
KOWX712
9817724a10 manager: provide monet color to webui (#2981)
Provide app color scheme using css variable in suPath, follow MMRL monet
color scheme standard since some module has already support this for a
while. This will not break current module's WebUI, it is an opt-in
feature, you'll need to import before using it. Example:
```css
@import url('https://mui.kernelsu.org/internal/colors.css');
:root {
    --my-background-color: var(--surface, #FEF7FF);
    --my-text-color: var(--onSurface, #1D1B20);
}
```
This is only provided when monet color is selected in settings.

Co-Authored-By: Der_Googler
<54764558+dergoogler@users.noreply.github.com>
Co-Authored-By: Rifat Azad <33044977+rifsxd@users.noreply.github.com>
Signed-off-by: KOWX712 <leecc0503@gmail.com>

---------

Signed-off-by: KOWX712 <leecc0503@gmail.com>
Co-authored-by: Der_Googler <54764558+dergoogler@users.noreply.github.com>
Co-authored-by: Rifat Azad <33044977+rifsxd@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-26 17:14:54 +08:00
Wang Han
7c7e72f111 kernel: Remove unreachable vfs_statx handling 2025-11-26 17:11:40 +08:00
5ec1cff
c5d473c815 ksud: refine boot patch, add --out-name arg to boot-patch and boot-restore command (#2982) 2025-11-24 11:35:53 +08:00
ShirkNeko
f2de18bc26 kernel: Avoid duplicating the built in uninstall path adder with the path manager. 2025-11-24 11:33:30 +08:00
ShirkNeko
61f5785729 manager: Remove unused susfs functionality status 2025-11-24 00:04:11 +08:00
ShirkNeko
a7713f0445 manager: Updated the susfs binary file
- made the umount manager publicly
available

- and removed the “try umount” functionality
2025-11-23 23:16:29 +08:00
Wang Han
0109723187 kernel: Unmount all isolated process which forks from zygote
Kernel has few information about which isolated process belongs to which
application, so there is actually no good choice if we don't implement a
userspace daemon. One choice is to access cmdline memory from kernel,
but cmdline is __user, and it is likely to trigger detections. Before we
have more good ideas, use this.
2025-11-23 19:17:54 +08:00
ShirkNeko
15fe454b6d Step 7-2: Removed redundant pop-ups from the susfs interface and fixed path issues (on Android 16). 2025-11-23 13:32:06 +08:00
ShirkNeko
5c80febdbd manager: Deprecated AUTO_ADD_TRY_UMOUNT_FOR_BIND_MOUNT, the leftover add_sus_mount cli and umount_for_zygote_system_process
Reason:
 - AUTO_ADD_TRY_UMOUNT_FOR_BIND_MOUNT is also causing a bit more performance overheads and still it cannot catch all the sus mounts in all situations. Actually it can easily be done in boot-completed.sh, and it should be more accurate, see module templates for more details.

- Official KernelSU also allows ksud to add custom path to try_umount list as well, users can use their own way to add only the desired sus mounts to try_umount list, but remember to disable susfs ADD_TRY_UMOUNT in kernel if users want to use the official one.

- There are less use cases for umount_for_zygote_system_process, and sometimes enabling this may cause bootloop with some modules enabled, instead user can use busybox nsenter to umount the sus mounts for specific process later by themmselves.
2025-11-22 22:54:10 +08:00
ShirkNeko
39f4a5991a Add cursor rules 2025-11-22 22:08:52 +08:00
Ylarod
b2565fda08 ksud: fmt 2025-11-22 17:41:53 +08:00
Wang Han
923ba8c213 ksud: Set KSU_MODULE only for module script (#2971) 2025-11-22 17:41:49 +08:00
Ylarod
c94608a2eb ksud: config set support read from stdin, and less restriction 2025-11-22 17:41:46 +08:00
Ylarod
ccb59cb7ca ksud: larger config value size limit, update docs 2025-11-22 17:15:57 +08:00
Tools-app
36d93501c8 Fix compile on x86_64
Co-authored-by: weishu <twsxtd@gmail.com>
Signed-off-by: Tools-app <localhost.hutao@gmail.com>
2025-11-22 16:58:25 +08:00
生于生时 亡于亡刻
27f6db889a chore(ksud): enable clippy::all, clippy::pedantic && make clippy happy (#617)
* Revert "chore(ksud): bump ksud's deps (#585)"
* Because it may cause compilation errors.

This reverts commit c8020b2066.

* chore(ksud): remove unused Result

Signed-off-by: Tools-app <localhost.hutao@gmail.com>

* chore(ksud): enable clippy::all, clippy::pedantic && make clippy happy

https://rust-lang.github.io/rust-clippy/master/index.html#map_unwrap_or

https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args

https://rust-lang.github.io/rust-clippy/master/index.html#used_underscore_items

https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls

https://rust-lang.github.io/rust-clippy/master/index.html#redundant_pub_crate
...
and use some #![allow(...)] or #[allow(...)]

Signed-off-by: Tools-app <localhost.hutao@gmail.com>

---------

Signed-off-by: Tools-app <localhost.hutao@gmail.com>
2025-11-22 16:58:19 +08:00
ShirkNeko
6898d82daf Step 7-1: Optimize the susfs interface format and refactor the code
- Remove unused resources
2025-11-22 16:56:51 +08:00
ShirkNeko
8d8d0180ae manager: fix build 2025-11-22 04:22:01 +08:00
ShirkNeko
f7b875fc16 Revert "Step 7: Add Custom Background (beta):"
This reverts commit a585989a03.
2025-11-22 04:16:24 +08:00
ShirkNeko
0d73908d1b Step 7: Add susfs interface 2025-11-22 04:16:04 +08:00
5ec1cff
3dd210cfec manager: no need to check overlayfs 2025-11-22 00:56:04 +08:00
YuKongA
18c65c8495 manager: try fix HyperOS2- edgeToEdge 2025-11-21 22:53:33 +08:00
KOWX712
4f9b745cd0 manager: make inset synchronize 2025-11-21 22:25:58 +08:00
ShirkNeko
a585989a03 Step 7: Add Custom Background (beta): 2025-11-21 22:24:22 +08:00
weishu
ba6f29557e meta-overlayfs: Moved to module repo 2025-11-21 14:03:20 +08:00
Wang Han
79b78e35ba ksud: Use regex to validate module id (#2968)
https://github.com/tiann/KernelSU/blob/main/website/docs/guide/module.md?plain=1#L106
2025-11-21 13:10:15 +08:00
ShirkNeko
932fabd35c Step 6: feat: add direct zip flash for AnyKernel3 and modules
- fix Chrome zip open failure
- one-tap flash AnyKernel3 kernel packages
- bulk install with state de-duplication
- refine share UI & color scheme

---------------------------------
Co-Authored-By: Der_Googler <54764558+dergoogler@users.noreply.github.com>
Co-authored-by: rifsxd <rifat.44.azad.rifs@gmail.com>
Co-authored-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com>
Co-authored-by: KOWX712 <leecc0503@gmail.com>
Signed-off-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com>
2025-11-21 13:08:54 +08:00
weishu
4ea5c8f450 metaovl: Fix incorrect permission, Add updateJson and changelog 2025-11-21 11:12:56 +08:00
ShirkNeko
c6b184793e Step 5-2: Simplify and separate the main logic for flashing anykernel3 2025-11-21 01:48:33 +08:00
Ylarod
e3ef521de5 add module config, migrate managedFeatures (#2965)
Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
2025-11-20 22:06:12 +08:00
ShirkNeko
3d4e0e48b4 [skip ci]ksud: fmt & clippy 2025-11-20 20:30:06 +08:00
ShirkNeko
ff3071ca08 Step 5-1-1: No longer need to add unmounting for default mount points 2025-11-20 19:53:18 +08:00
weishu
dd969eac22 meta-overlayfs: quote rel path removal 2025-11-20 19:08:19 +08:00
weishu
9f2e5f513d metaovl: copy selinux context when install 2025-11-20 19:08:02 +08:00
ShirkNeko
385f4ab2c5 Step 5-1: Move the KPM interface to the settings
- Avoid multiple page re-rendering
- Add hook type information
- Clean up code
2025-11-20 18:38:53 +08:00
ShirkNeko
6826406494 manager: fix dark mode color issue
Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
2025-11-20 17:01:54 +08:00
ShirkNeko
6465e7a874 Step 5: Add a settings tool page to migrate some settings to it
- Add SELinux status toggle

- Add backup and restore for the allowlist
2025-11-20 16:28:37 +08:00
ShirkNeko
c753dd1345 ci: Fix omitted checks 2025-11-20 14:39:45 +08:00
weishu
06c8580788 metaovl: Use xcp to copy image faster. 2025-11-20 14:33:25 +08:00
ShirkNeko
5f228f1896 ci :Skip the disguised manager build for the MIUIX branch 2025-11-20 14:28:57 +08:00
weishu
2368c5afd5 metaovl: use cp instead of mv to copy files 2025-11-20 14:02:19 +08:00
ShirkNeko
16ec695b63 kernel/makefile: Adjust the build branch 2025-11-20 14:02:05 +08:00
ShirkNeko
404352b536 Step 4-1: Fixed incorrect homepage indexing after enabling KPM
- Adjusted the position of the personalized menu
2025-11-20 13:59:40 +08:00
ShirkNeko
8e7f1f1cc7 manager: Support monet colors
Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
2025-11-20 12:02:49 +08:00
ShirkNeko
d2a6fa4513 Step 4: Add KPM interface and flash anykernel3 2025-11-20 03:18:13 +08:00
YuKongA
9574409955 manager: fix dialog text of multiple modules install 2025-11-19 23:50:14 +08:00
weishu
9c2924de78 meta-overlayfs: avoid moving skip-mount modules 2025-11-19 23:50:07 +08:00
ShirkNeko
d7878ddd45 manager: If SELinuxStatus is the last information component, set the margin to 0. 2025-11-19 23:49:24 +08:00
ShirkNeko
bc3399fd1b Step 3: Added theme mode switching, introduced uninstall path manager and user-mode scanning toggle 2025-11-19 23:39:07 +08:00
ShirkNeko
ba1aaaa160 ksud: fix build 2025-11-19 21:18:53 +08:00
weishu
a4e5a571bd ksud: Fix the metamodule's non-meta stage script, which is executed twice. 2025-11-19 21:11:10 +08:00
ShirkNeko
3c501295b7 Step 2: Add the remaining dynamic manager configurations 2025-11-19 21:10:34 +08:00
ShirkNeko
a8acea9180 support metamodule, remove built-in overlayfs mount
Co-authored-by: weishu <twsxtd@gmail.com>
Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
Co-authored-by: Ylarod <me@ylarod.cn>
2025-11-19 19:33:01 +08:00
ShirkNeko
4f79c94ab9 Step 1: Import susfs and sulog to modify 2025-11-19 18:45:00 +08:00
ShirkNeko
a14551b3ec Introducing miuix
Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
2025-11-19 16:11:33 +08:00
225 changed files with 22227 additions and 39117 deletions

71
.cursor/rules/general.mdc Normal file
View File

@@ -0,0 +1,71 @@
---
description: "Project-wide coding standards and tooling rules for Kotlin, Rust, C++, C, and Java (Gradle)."
globs:
- "**/*.kt"
- "**/*.rs"
- "**/*.cpp"
- "**/*.c"
- "**/*.java"
alwaysApply: true
---
# Project Coding Standards
## Universal Rules
- Role: You are an expert senior engineer. Always generate maintainable, secure, performant, and idiomatic code.
- Prefer readability over cleverness. Keep changes focused and minimal.
- Follow SOLID, clean architecture, immutability-first, modular design.
- Remove unused imports, dead code, commented-out blocks, TODOs, debug logs.
- Use descriptive naming; avoid magic numbers.
- Document non-obvious logic and unsafe operations.
## Formatting & Tooling
- Formatters to apply automatically on save or pre-commit: `ktlint`, `detekt`, `rustfmt`, `cargo clippy`, `clang-format`, `clang-tidy`, `spotless`.
- Pre-commit hooks should run: `ktlint --format`, `cargo fmt`, `clang-format -i`, `gradle spotlessApply`.
- CI must enforce formatting and linting; any violation should fail the build.
## Kotlin (files matching `**/*.kt`)
- Target Kotlin version 1.9.
- Use `val` over `var` by default.
- Use data classes and sealed classes for value types.
- Use structured concurrency with coroutines; avoid `GlobalScope`.
- Avoid forced unwraps (`!!`); prefer safe calls (`?.`) and Elvis (`?:`) operators.
- Prefer extension functions for utilities.
- Tests must be written using JUnit 5.
## Rust (files matching `**/*.rs`)
- Use Rust edition 2024.
- Replace `unwrap()` and `expect()` in production code with `Result` + `?` propagation.
- Avoid unnecessary `.clone()`; use borrowing and lifetimes properly.
- Avoid `unsafe` blocks unless strictly necessary, and document why.
- Always run `cargo fmt` and `cargo clippy`.
## C++ (files matching `**/*.cpp`, `**/*.hpp`)
- Use C++23 standard.
- Avoid raw pointers; prefer `std::unique_ptr`, `std::shared_ptr`, `std::optional`.
- Use RAII for resource management.
- Prefer STL algorithms over manual loops.
- Avoid macros and global mutable state.
- Tests should be written using Google Test or equivalent.
## C (files matching `**/*.c`, `**/*.h`)
- Use C23 standard where possible.
- Check return values of all system/library calls.
- Avoid global mutable state.
- Use `const` correctness in declarations.
- Leverage static analysis tools: `cppcheck`, `valgrind`.
- Use header/implementation separation properly.
## Java + Gradle (files matching `build.gradle.kts`, `**/*.java`)
- Target Java version 21 (or higher if project permits).
- Use Gradle Kotlin DSL for build scripts.
- Prefer `record` for simple data carriers.
- Use `Optional` and `Stream` responsibly; prefer immutability.
- Build must be reproducible and minimal: avoid unnecessary plugins.
- Use `spotless` for formatting and `checkstyle` for static analysis.
## Testing Rules
- For each new module, write unit tests.
- Test naming convention: `should<Behavior>_when<Condition>`.
- Tests must be fast, deterministic, not rely on network/external I/O (use mocks as needed).
- Aim for coverage: critical paths ≥ 90%, overall modules ≥ 70%.

View File

@@ -2,7 +2,7 @@ name: Build Manager
on: on:
push: push:
branches: [ "main", "dev", "ci" ] branches: [ "main", "dev", "ci", "miuix" ]
paths: paths:
- '.github/workflows/build-manager.yml' - '.github/workflows/build-manager.yml'
- '.github/workflows/build-lkm.yml' - '.github/workflows/build-lkm.yml'
@@ -11,13 +11,14 @@ on:
- 'userspace/ksud/**' - 'userspace/ksud/**'
- 'userspace/user_scanner/**' - 'userspace/user_scanner/**'
pull_request: pull_request:
branches: [ "main", "dev" ] branches: [ "main", "dev", "miuix" ]
paths: paths:
- '.github/workflows/build-manager.yml' - '.github/workflows/build-manager.yml'
- '.github/workflows/build-lkm.yml' - '.github/workflows/build-lkm.yml'
- 'manager/**' - 'manager/**'
- 'kernel/**' - 'kernel/**'
- 'userspace/ksud/**' - 'userspace/ksud/**'
- 'userspace/user_scanner/**'
workflow_call: workflow_call:
jobs: jobs:
@@ -81,7 +82,14 @@ jobs:
- name: Determine manager variant for telegram bot - name: Determine manager variant for telegram bot
id: determine id: determine
run: | run: |
if [ "${{ matrix.spoofed }}" == "true" ]; then if [ "${{ github.ref_name }}" == "miuix" ] && [ "${{ matrix.spoofed }}" == "true" ]; then
echo "SKIP=true" >> $GITHUB_OUTPUT
exit 0
fi
if [ "${{ github.ref_name }}" == "miuix" ]; then
echo "title=Manager" >> $GITHUB_OUTPUT
echo "topicid=${{ vars.MESSAGE_MIUIX_THREAD_ID }}" >> $GITHUB_OUTPUT
elif [ "${{ matrix.spoofed }}" == "true" ]; then
echo "title=Spoofed-Manager" >> $GITHUB_OUTPUT echo "title=Spoofed-Manager" >> $GITHUB_OUTPUT
# maybe need a new var # maybe need a new var
echo "topicid=${{ vars.MESSAGE_SPOOFED_THREAD_ID }}" >> $GITHUB_OUTPUT echo "topicid=${{ vars.MESSAGE_SPOOFED_THREAD_ID }}" >> $GITHUB_OUTPUT
@@ -91,13 +99,13 @@ jobs:
fi fi
- name: Run randomizer - name: Run randomizer
if: ${{ matrix.spoofed == 'true' }} if: ${{ matrix.spoofed == 'true' && steps.determine.outputs.SKIP != 'true' }}
run: | run: |
chmod +x randomizer chmod +x randomizer
./randomizer ./randomizer
- name: Write key - name: Write key
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' )) || github.ref_type == 'tag' }} if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/miuix' )) || github.ref_type == 'tag' }}
run: | run: |
if [ ! -z "${{ secrets.KEYSTORE }}" ]; then if [ ! -z "${{ secrets.KEYSTORE }}" ]; then
{ {
@@ -164,24 +172,25 @@ jobs:
cp -f ../armeabi-v7a/uid_scanner ../manager/app/src/main/jniLibs/armeabi-v7a/libuid_scanner.so cp -f ../armeabi-v7a/uid_scanner ../manager/app/src/main/jniLibs/armeabi-v7a/libuid_scanner.so
- name: Build with Gradle - name: Build with Gradle
if: ${{ steps.determine.outputs.SKIP != 'true' }}
run: ./gradlew clean assembleRelease run: ./gradlew clean assembleRelease
- name: Upload build artifact - name: Upload build artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' )) || github.ref_type == 'tag' }} if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/miuix' )) || github.ref_type == 'tag' }}
with: with:
name: ${{ steps.determine.outputs.title }} name: ${{ steps.determine.outputs.title }}
path: manager/app/build/outputs/apk/release/*.apk path: manager/app/build/outputs/apk/release/*.apk
- name: Upload mappings - name: Upload mappings
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' )) || github.ref_type == 'tag' }} if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/miuix' )) || github.ref_type == 'tag' }}
with: with:
name: "${{ steps.determine.outputs.title }}-mappings" name: "${{ steps.determine.outputs.title }}-mappings"
path: "manager/app/build/outputs/mapping/release/" path: "manager/app/build/outputs/mapping/release/"
- name: Upload to telegram - name: Upload to telegram
if: github.event_name != 'pull_request' && steps.need_upload.outputs.UPLOAD == 'true' if: github.event_name != 'pull_request' && steps.need_upload.outputs.UPLOAD == 'true' && steps.determine.outputs.SKIP != 'true'
env: env:
CHAT_ID: ${{ vars.CHAT_ID }} CHAT_ID: ${{ vars.CHAT_ID }}
BOT_TOKEN: ${{ secrets.BOT_TOKEN }} BOT_TOKEN: ${{ secrets.BOT_TOKEN }}

View File

@@ -17,6 +17,7 @@ kernelsu-objs += ksud.o
kernelsu-objs += embed_ksud.o kernelsu-objs += embed_ksud.o
kernelsu-objs += seccomp_cache.o kernelsu-objs += seccomp_cache.o
kernelsu-objs += file_wrapper.o kernelsu-objs += file_wrapper.o
kernelsu-objs += util.o
kernelsu-objs += throne_comm.o kernelsu-objs += throne_comm.o
kernelsu-objs += sulog.o kernelsu-objs += sulog.o
@@ -37,7 +38,7 @@ obj-$(CONFIG_KPM) += kpm/
REPO_OWNER := SukiSU-Ultra REPO_OWNER := SukiSU-Ultra
REPO_NAME := SukiSU-Ultra REPO_NAME := SukiSU-Ultra
REPO_BRANCH := main REPO_BRANCH := miuix
KSU_VERSION_API := 4.0.0 KSU_VERSION_API := 4.0.0
GIT_BIN := /usr/bin/env PATH="$$PATH":/usr/bin:/usr/local/bin git GIT_BIN := /usr/bin/env PATH="$$PATH":/usr/bin:/usr/local/bin git

View File

@@ -8,6 +8,8 @@
#define PER_USER_RANGE 100000 #define PER_USER_RANGE 100000
#define FIRST_APPLICATION_UID 10000 #define FIRST_APPLICATION_UID 10000
#define LAST_APPLICATION_UID 19999 #define LAST_APPLICATION_UID 19999
#define FIRST_ISOLATED_UID 99000
#define LAST_ISOLATED_UID 99999
void ksu_allowlist_init(void); void ksu_allowlist_init(void);
@@ -41,6 +43,12 @@ static inline bool is_appuid(uid_t uid)
return appid >= FIRST_APPLICATION_UID && appid <= LAST_APPLICATION_UID; return appid >= FIRST_APPLICATION_UID && appid <= LAST_APPLICATION_UID;
} }
static inline bool is_isolated_process(uid_t uid)
{
uid_t appid = uid % PER_USER_RANGE;
return appid >= FIRST_ISOLATED_UID && appid <= LAST_ISOLATED_UID;
}
#ifdef CONFIG_KSU_MANUAL_SU #ifdef CONFIG_KSU_MANUAL_SU
bool ksu_temp_grant_root_once(uid_t uid); bool ksu_temp_grant_root_once(uid_t uid);
void ksu_temp_revoke_root_once(uid_t uid); void ksu_temp_revoke_root_once(uid_t uid);

View File

@@ -106,7 +106,7 @@ int ksu_handle_umount(uid_t old_uid, uid_t new_uid)
{ {
struct umount_tw *tw; struct umount_tw *tw;
// this hook is used for umounting overlayfs for some uid, if there isn't any module mounted, just ignore it! // if there isn't any module mounted, just ignore it!
if (!ksu_module_mounted) { if (!ksu_module_mounted) {
return 0; return 0;
} }
@@ -115,18 +115,24 @@ int ksu_handle_umount(uid_t old_uid, uid_t new_uid)
return 0; return 0;
} }
// FIXME: isolated process which directly forks from zygote is not handled // There are 5 scenarios:
if (!is_appuid(new_uid)) { // 1. Normal app: zygote -> appuid
// 2. Isolated process forked from zygote: zygote -> isolated_process
// 3. App zygote forked from zygote: zygote -> appuid
// 4. Isolated process froked from app zygote: appuid -> isolated_process (already handled by 3)
// 5. Isolated process froked from webview zygote (no need to handle, app cannot run custom code)
if (!is_appuid(new_uid) && !is_isolated_process(new_uid)) {
return 0; return 0;
} }
if (!ksu_uid_should_umount(new_uid)) { if (!ksu_uid_should_umount(new_uid) && !is_isolated_process(new_uid)) {
return 0; return 0;
} }
// check old process's selinux context, if it is not zygote, ignore it! // check old process's selinux context, if it is not zygote, ignore it!
// because some su apps may setuid to untrusted_app but they are in global mount namespace // because some su apps may setuid to untrusted_app but they are in global mount namespace
// when we umount for such process, that is a disaster! // when we umount for such process, that is a disaster!
// also handle case 4 and 5
bool is_zygote_child = is_zygote(get_current_cred()); bool is_zygote_child = is_zygote(get_current_cred());
if (!is_zygote_child) { if (!is_zygote_child) {
pr_info("handle umount ignore non zygote child: %d\n", current->pid); pr_info("handle umount ignore non zygote child: %d\n", current->pid);

View File

@@ -22,6 +22,7 @@
#include "arch.h" #include "arch.h"
#include "klog.h" // IWYU pragma: keep #include "klog.h" // IWYU pragma: keep
#include "ksud.h" #include "ksud.h"
#include "util.h"
#include "selinux/selinux.h" #include "selinux/selinux.h"
#include "throne_tracker.h" #include "throne_tracker.h"
@@ -88,11 +89,11 @@ void on_post_fs_data(void)
is_boot_phase = false; is_boot_phase = false;
ksu_file_sid = ksu_get_ksu_file_sid(); ksu_file_sid = ksu_get_ksu_file_sid();
pr_info("ksu_file sid: %d\n", ksu_file_sid); pr_info("ksu_file sid: %d\n", ksu_file_sid);
} }
extern void ext4_unregister_sysfs(struct super_block *sb); extern void ext4_unregister_sysfs(struct super_block *sb);
int nuke_ext4_sysfs(const char* mnt) int nuke_ext4_sysfs(const char *mnt)
{ {
#ifdef CONFIG_EXT4_FS #ifdef CONFIG_EXT4_FS
struct path path; struct path path;
@@ -117,12 +118,14 @@ int nuke_ext4_sysfs(const char* mnt)
#endif #endif
} }
void on_module_mounted(void){ void on_module_mounted(void)
{
pr_info("on_module_mounted!\n"); pr_info("on_module_mounted!\n");
ksu_module_mounted = true; ksu_module_mounted = true;
} }
void on_boot_completed(void){ void on_boot_completed(void)
{
ksu_boot_completed = true; ksu_boot_completed = true;
pr_info("on_boot_completed!\n"); pr_info("on_boot_completed!\n");
track_throne(true); track_throne(true);
@@ -527,12 +530,25 @@ static int sys_execve_handler_pre(struct kprobe *p, struct pt_regs *regs)
struct user_arg_ptr argv = { .ptr.native = __argv }; struct user_arg_ptr argv = { .ptr.native = __argv };
struct filename filename_in, *filename_p; struct filename filename_in, *filename_p;
char path[32]; char path[32];
long ret;
unsigned long addr;
const char __user *fn;
if (!filename_user) if (!filename_user)
return 0; return 0;
addr = untagged_addr((unsigned long)*filename_user);
fn = (const char __user *)addr;
memset(path, 0, sizeof(path)); memset(path, 0, sizeof(path));
strncpy_from_user_nofault(path, *filename_user, 32); ret = strncpy_from_user_nofault(path, fn, 32);
if (ret < 0 && try_set_access_flag(addr)) {
ret = strncpy_from_user_nofault(path, fn, 32);
}
if (ret < 0) {
pr_err("Access filename failed for execve_handler_pre\n");
return 0;
}
filename_in.name = path; filename_in.name = path;
filename_p = &filename_in; filename_p = &filename_in;

View File

@@ -1,10 +1,13 @@
#include "linux/compiler.h" #include <linux/compiler_types.h>
#include "linux/printk.h" #include <linux/preempt.h>
#include <linux/printk.h>
#include <linux/mm.h>
#include <linux/pgtable.h>
#include <linux/uaccess.h>
#include <asm/current.h> #include <asm/current.h>
#include <linux/cred.h> #include <linux/cred.h>
#include <linux/fs.h> #include <linux/fs.h>
#include <linux/types.h> #include <linux/types.h>
#include <linux/uaccess.h>
#include <linux/version.h> #include <linux/version.h>
#include <linux/sched/task_stack.h> #include <linux/sched/task_stack.h>
#include <linux/ptrace.h> #include <linux/ptrace.h>
@@ -15,7 +18,7 @@
#include "ksud.h" #include "ksud.h"
#include "sucompat.h" #include "sucompat.h"
#include "app_profile.h" #include "app_profile.h"
#include "syscall_hook_manager.h" #include "util.h"
#include "sulog.h" #include "sulog.h"
@@ -69,7 +72,7 @@ static char __user *ksud_user_path(void)
} }
int ksu_handle_faccessat(int *dfd, const char __user **filename_user, int *mode, int ksu_handle_faccessat(int *dfd, const char __user **filename_user, int *mode,
int *__unused_flags) int *__unused_flags)
{ {
const char su[] = SU_PATH; const char su[] = SU_PATH;
@@ -107,20 +110,6 @@ int ksu_handle_stat(int *dfd, const char __user **filename_user, int *flags)
char path[sizeof(su) + 1]; char path[sizeof(su) + 1];
memset(path, 0, sizeof(path)); memset(path, 0, sizeof(path));
// Remove this later!! we use syscall hook, so this will never happen!!!!!
#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 18, 0) && 0
// it becomes a `struct filename *` after 5.18
// https://elixir.bootlin.com/linux/v5.18/source/fs/stat.c#L216
const char sh[] = SH_PATH;
struct filename *filename = *((struct filename **)filename_user);
if (IS_ERR(filename)) {
return 0;
}
if (likely(memcmp(filename->name, su, sizeof(su))))
return 0;
pr_info("vfs_statx su->sh!\n");
memcpy((void *)filename->name, sh, sizeof(sh));
#else
strncpy_from_user_nofault(path, *filename_user, sizeof(path)); strncpy_from_user_nofault(path, *filename_user, sizeof(path));
if (unlikely(!memcmp(path, su, sizeof(su)))) { if (unlikely(!memcmp(path, su, sizeof(su)))) {
@@ -130,7 +119,6 @@ int ksu_handle_stat(int *dfd, const char __user **filename_user, int *flags)
pr_info("newfstatat su->sh!\n"); pr_info("newfstatat su->sh!\n");
*filename_user = sh_user_path(); *filename_user = sh_user_path();
} }
#endif
return 0; return 0;
} }
@@ -140,17 +128,14 @@ int ksu_handle_execve_sucompat(const char __user **filename_user,
int *__never_use_flags) int *__never_use_flags)
{ {
const char su[] = SU_PATH; const char su[] = SU_PATH;
const char __user *fn;
char path[sizeof(su) + 1]; char path[sizeof(su) + 1];
long ret;
unsigned long addr;
if (unlikely(!filename_user)) if (unlikely(!filename_user))
return 0; return 0;
memset(path, 0, sizeof(path));
strncpy_from_user_nofault(path, *filename_user, sizeof(path));
if (likely(memcmp(path, su, sizeof(su))))
return 0;
#if __SULOG_GATE #if __SULOG_GATE
bool is_allowed = ksu_is_allow_uid_for_current(current_uid().val); bool is_allowed = ksu_is_allow_uid_for_current(current_uid().val);
ksu_sulog_report_syscall(current_uid().val, NULL, "execve", path); ksu_sulog_report_syscall(current_uid().val, NULL, "execve", path);
@@ -165,6 +150,32 @@ int ksu_handle_execve_sucompat(const char __user **filename_user,
} }
#endif #endif
addr = untagged_addr((unsigned long)*filename_user);
fn = (const char __user *)addr;
memset(path, 0, sizeof(path));
ret = strncpy_from_user_nofault(path, fn, sizeof(path));
if (ret < 0 && try_set_access_flag(addr)) {
ret = strncpy_from_user_nofault(path, fn, sizeof(path));
}
if (ret < 0 && preempt_count()) {
/* This is crazy, but we know what we are doing:
* Temporarily exit atomic context to handle page faults, then restore it */
pr_info("Access filename failed, try rescue..\n");
preempt_enable_no_resched_notrace();
ret = strncpy_from_user(path, fn, sizeof(path));
preempt_disable_notrace();
}
if (ret < 0) {
pr_warn("Access filename when execve failed: %ld", ret);
return 0;
}
if (likely(memcmp(path, su, sizeof(su))))
return 0;
pr_info("sys_execve su found\n"); pr_info("sys_execve su found\n");
*filename_user = ksud_user_path(); *filename_user = ksud_user_path();

View File

@@ -18,6 +18,7 @@
#include "sucompat.h" #include "sucompat.h"
#include "setuid_hook.h" #include "setuid_hook.h"
#include "selinux/selinux.h" #include "selinux/selinux.h"
#include "util.h"
// Tracepoint registration count management // Tracepoint registration count management
// == 1: just us // == 1: just us
@@ -246,12 +247,22 @@ static inline bool check_syscall_fastpath(int nr)
int ksu_handle_init_mark_tracker(const char __user **filename_user) int ksu_handle_init_mark_tracker(const char __user **filename_user)
{ {
char path[64]; char path[64];
unsigned long addr;
const char __user *fn;
long ret;
if (unlikely(!filename_user)) if (unlikely(!filename_user))
return 0; return 0;
addr = untagged_addr((unsigned long)*filename_user);
fn = (const char __user *)addr;
memset(path, 0, sizeof(path)); memset(path, 0, sizeof(path));
strncpy_from_user_nofault(path, *filename_user, sizeof(path)); ret = strncpy_from_user_nofault(path, fn, sizeof(path));
if (ret < 0 && try_set_access_flag(addr)) {
ret = strncpy_from_user_nofault(path, fn, sizeof(path));
pr_info("ksu_handle_init_mark_tracker: %ld\n", ret);
}
if (likely(strstr(path, "/app_process") == NULL && strstr(path, "/adbd") == NULL && strstr(path, "/ksud") == NULL)) { if (likely(strstr(path, "/app_process") == NULL && strstr(path, "/adbd") == NULL && strstr(path, "/ksud") == NULL)) {
pr_info("hook_manager: unmark %d exec %s", current->pid, path); pr_info("hook_manager: unmark %d exec %s", current->pid, path);

View File

@@ -12,7 +12,7 @@
static struct umount_manager g_umount_mgr = { static struct umount_manager g_umount_mgr = {
.entry_count = 0, .entry_count = 0,
.max_entries = 64, .max_entries = 512,
}; };
static void try_umount_path(struct umount_entry *entry) static void try_umount_path(struct umount_entry *entry)
@@ -33,35 +33,123 @@ static struct umount_entry *find_entry_locked(const char *path)
return NULL; return NULL;
} }
static int init_default_entries(void) static bool is_path_in_mount_list(const char *path)
{ {
int ret; struct mount_entry *entry;
bool found = false;
const struct { down_read(&mount_list_lock);
const char *path; list_for_each_entry(entry, &mount_list, list) {
int flags; if (entry->umountable && strcmp(entry->umountable, path) == 0) {
} defaults[] = { found = true;
{ "/odm", 0 }, break;
{ "/system", 0 },
{ "/vendor", 0 },
{ "/product", 0 },
{ "/system_ext", 0 },
{ "/data/adb/modules", MNT_DETACH },
{ "/debug_ramdisk", MNT_DETACH },
};
for (int i = 0; i < ARRAY_SIZE(defaults); i++) {
ret = ksu_umount_manager_add(defaults[i].path,
defaults[i].flags,
true); // is_default = true
if (ret) {
pr_err("Failed to add default entry: %s, ret=%d\n",
defaults[i].path, ret);
return ret;
} }
} }
up_read(&mount_list_lock);
pr_info("Initialized %zu default umount entries\n", ARRAY_SIZE(defaults)); return found;
}
static int copy_mount_entry_to_user(struct ksu_umount_entry_info __user *entries,
u32 idx, const char *path, int flags)
{
struct ksu_umount_entry_info info;
memset(&info, 0, sizeof(info));
strncpy(info.path, path, sizeof(info.path) - 1);
info.path[sizeof(info.path) - 1] = '\0';
info.flags = flags;
info.is_default = 1;
info.state = UMOUNT_STATE_IDLE;
info.ref_count = 0;
if (copy_to_user(&entries[idx], &info, sizeof(info))) {
return -EFAULT;
}
return 0;
}
static int copy_umount_entry_to_user(struct ksu_umount_entry_info __user *entries,
u32 idx, struct umount_entry *entry)
{
struct ksu_umount_entry_info info;
memset(&info, 0, sizeof(info));
strncpy(info.path, entry->path, sizeof(info.path) - 1);
info.path[sizeof(info.path) - 1] = '\0';
info.flags = entry->flags;
info.is_default = entry->is_default;
info.state = entry->state;
info.ref_count = entry->ref_count;
if (copy_to_user(&entries[idx], &info, sizeof(info))) {
return -EFAULT;
}
return 0;
}
static int collect_mount_list_entries(struct ksu_umount_entry_info __user *entries,
u32 max_count, u32 *out_idx)
{
struct mount_entry *mount_entry;
u32 idx = 0;
down_read(&mount_list_lock);
list_for_each_entry(mount_entry, &mount_list, list) {
if (idx >= max_count) {
break;
}
if (!mount_entry->umountable) {
continue;
}
if (copy_mount_entry_to_user(entries, idx, mount_entry->umountable,
mount_entry->flags)) {
up_read(&mount_list_lock);
return -EFAULT;
}
idx++;
}
up_read(&mount_list_lock);
*out_idx = idx;
return 0;
}
static int collect_umount_manager_entries(struct ksu_umount_entry_info __user *entries,
u32 start_idx, u32 max_count, u32 *out_idx)
{
struct umount_entry *entry;
unsigned long flags;
u32 idx = start_idx;
spin_lock_irqsave(&g_umount_mgr.lock, flags);
list_for_each_entry(entry, &g_umount_mgr.entry_list, list) {
if (idx >= max_count) {
break;
}
if (is_path_in_mount_list(entry->path)) {
continue;
}
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
if (copy_umount_entry_to_user(entries, idx, entry)) {
return -EFAULT;
}
idx++;
spin_lock_irqsave(&g_umount_mgr.lock, flags);
}
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
*out_idx = idx;
return 0; return 0;
} }
@@ -70,7 +158,7 @@ int ksu_umount_manager_init(void)
INIT_LIST_HEAD(&g_umount_mgr.entry_list); INIT_LIST_HEAD(&g_umount_mgr.entry_list);
spin_lock_init(&g_umount_mgr.lock); spin_lock_init(&g_umount_mgr.lock);
return init_default_entries(); return 0;
} }
void ksu_umount_manager_exit(void) void ksu_umount_manager_exit(void)
@@ -104,6 +192,11 @@ int ksu_umount_manager_add(const char *path, int flags, bool is_default)
return -EINVAL; return -EINVAL;
} }
if (is_path_in_mount_list(path)) {
pr_warn("Umount manager: path already exists in mount_list: %s\n", path);
return -EEXIST;
}
spin_lock_irqsave(&g_umount_mgr.lock, irqflags); spin_lock_irqsave(&g_umount_mgr.lock, irqflags);
if (g_umount_mgr.entry_count >= g_umount_mgr.max_entries) { if (g_umount_mgr.entry_count >= g_umount_mgr.max_entries) {
@@ -216,37 +309,23 @@ void ksu_umount_manager_execute_all(const struct cred *cred)
int ksu_umount_manager_get_entries(struct ksu_umount_entry_info __user *entries, u32 *count) int ksu_umount_manager_get_entries(struct ksu_umount_entry_info __user *entries, u32 *count)
{ {
struct umount_entry *entry;
struct ksu_umount_entry_info info;
unsigned long flags;
u32 idx = 0;
u32 max_count = *count; u32 max_count = *count;
u32 idx;
int ret;
spin_lock_irqsave(&g_umount_mgr.lock, flags); ret = collect_mount_list_entries(entries, max_count, &idx);
if (ret) {
return ret;
}
list_for_each_entry(entry, &g_umount_mgr.entry_list, list) { if (idx < max_count) {
if (idx >= max_count) { ret = collect_umount_manager_entries(entries, idx, max_count, &idx);
break; if (ret) {
return ret;
} }
memset(&info, 0, sizeof(info));
strncpy(info.path, entry->path, sizeof(info.path) - 1);
info.flags = entry->flags;
info.is_default = entry->is_default;
info.state = entry->state;
info.ref_count = entry->ref_count;
if (copy_to_user(&entries[idx], &info, sizeof(info))) {
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
return -EFAULT;
}
idx++;
} }
*count = idx; *count = idx;
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
return 0; return 0;
} }

76
kernel/util.c Normal file
View File

@@ -0,0 +1,76 @@
#include <linux/mm.h>
#include <linux/pgtable.h>
#include <linux/printk.h>
#include <asm/current.h>
#include "util.h"
bool try_set_access_flag(unsigned long addr)
{
#ifdef CONFIG_ARM64
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma;
pgd_t *pgd;
p4d_t *p4d;
pud_t *pud;
pmd_t *pmd;
pte_t *ptep, pte;
spinlock_t *ptl;
bool ret = false;
if (!mm)
return false;
if (!mmap_read_trylock(mm))
return false;
vma = find_vma(mm, addr);
if (!vma || addr < vma->vm_start)
goto out_unlock;
pgd = pgd_offset(mm, addr);
if (!pgd_present(*pgd))
goto out_unlock;
p4d = p4d_offset(pgd, addr);
if (!p4d_present(*p4d))
goto out_unlock;
pud = pud_offset(p4d, addr);
if (!pud_present(*pud))
goto out_unlock;
pmd = pmd_offset(pud, addr);
if (!pmd_present(*pmd))
goto out_unlock;
if (pmd_trans_huge(*pmd))
goto out_unlock;
ptep = pte_offset_map_lock(mm, pmd, addr, &ptl);
if (!ptep)
goto out_unlock;
pte = *ptep;
if (!pte_present(pte))
goto out_pte_unlock;
if (pte_young(pte)) {
ret = true;
goto out_pte_unlock;
}
ptep_set_access_flags(vma, addr, ptep, pte_mkyoung(pte), 0);
pr_info("set AF for addr %lx\n", addr);
ret = true;
out_pte_unlock:
pte_unmap_unlock(ptep, ptl);
out_unlock:
mmap_read_unlock(mm);
return ret;
#else
return false;
#endif
}

24
kernel/util.h Normal file
View File

@@ -0,0 +1,24 @@
#ifndef __KSU_UTIL_H
#define __KSU_UTIL_H
#include <linux/types.h>
#ifndef preempt_enable_no_resched_notrace
#define preempt_enable_no_resched_notrace() \
do { \
barrier(); \
__preempt_count_dec(); \
} while (0)
#endif
#ifndef preempt_disable_notrace
#define preempt_disable_notrace() \
do { \
__preempt_count_inc(); \
barrier(); \
} while (0)
#endif
bool try_set_access_flag(unsigned long addr);
#endif

View File

@@ -10,8 +10,6 @@ plugins {
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.lsplugin.apksign) alias(libs.plugins.lsplugin.apksign)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
val managerVersionCode: Int by rootProject.extra val managerVersionCode: Int by rootProject.extra
@@ -25,7 +23,6 @@ apksign {
keyPasswordProperty = "KEY_PASSWORD" keyPasswordProperty = "KEY_PASSWORD"
} }
android { android {
/**signingConfigs { /**signingConfigs {
@@ -117,13 +114,9 @@ dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.foundation)
implementation(libs.androidx.documentfile) implementation(libs.androidx.documentfile)
implementation(libs.androidx.compose.foundation)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
@@ -145,24 +138,14 @@ dependencies {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation(libs.me.zhanghai.android.appiconloader.coil)
implementation(libs.sheet.compose.dialogs.core)
implementation(libs.sheet.compose.dialogs.list)
implementation(libs.sheet.compose.dialogs.input)
implementation(libs.markdown) implementation(libs.markdown)
implementation(libs.markdown.ext.tables)
implementation(libs.androidx.webkit) implementation(libs.androidx.webkit)
implementation(libs.lsposed.cxx) implementation(libs.lsposed.cxx)
implementation(libs.com.github.topjohnwu.libsu.core) implementation(libs.miuix)
implementation(libs.haze)
implementation(libs.mmrl.platform) implementation(libs.capsule)
compileOnly(libs.mmrl.hidden.api) }
implementation(libs.mmrl.webui)
implementation(libs.mmrl.ui)
implementation(libs.accompanist.drawablepainter)
}

View File

@@ -1,48 +0,0 @@
-verbose
-optimizationpasses 5
-dontwarn org.conscrypt.**
-dontwarn kotlinx.serialization.**
# Please add these rules to your existing keep rules in order to suppress warnings.
# This is generated automatically by the Android Gradle plugin.
-dontwarn com.google.auto.service.AutoService
-dontwarn com.google.j2objc.annotations.RetainedWith
-dontwarn javax.lang.model.SourceVersion
-dontwarn javax.lang.model.element.AnnotationMirror
-dontwarn javax.lang.model.element.AnnotationValue
-dontwarn javax.lang.model.element.Element
-dontwarn javax.lang.model.element.ElementKind
-dontwarn javax.lang.model.element.ElementVisitor
-dontwarn javax.lang.model.element.ExecutableElement
-dontwarn javax.lang.model.element.Modifier
-dontwarn javax.lang.model.element.Name
-dontwarn javax.lang.model.element.PackageElement
-dontwarn javax.lang.model.element.TypeElement
-dontwarn javax.lang.model.element.TypeParameterElement
-dontwarn javax.lang.model.element.VariableElement
-dontwarn javax.lang.model.type.ArrayType
-dontwarn javax.lang.model.type.DeclaredType
-dontwarn javax.lang.model.type.ExecutableType
-dontwarn javax.lang.model.type.TypeKind
-dontwarn javax.lang.model.type.TypeMirror
-dontwarn javax.lang.model.type.TypeVariable
-dontwarn javax.lang.model.type.TypeVisitor
-dontwarn javax.lang.model.util.AbstractAnnotationValueVisitor8
-dontwarn javax.lang.model.util.AbstractTypeVisitor8
-dontwarn javax.lang.model.util.ElementFilter
-dontwarn javax.lang.model.util.Elements
-dontwarn javax.lang.model.util.SimpleElementVisitor8
-dontwarn javax.lang.model.util.SimpleTypeVisitor7
-dontwarn javax.lang.model.util.SimpleTypeVisitor8
-dontwarn javax.lang.model.util.Types
-dontwarn javax.tools.Diagnostic$Kind
# MMRL:webui reflection
-keep class com.dergoogler.mmrl.webui.interfaces.** { *; }
-keep class com.sukisu.ultra.ui.webui.WebViewInterface { *; }
-keep,allowobfuscation class * extends com.dergoogler.mmrl.platform.content.IService { *; }
-keep interface com.sukisu.zako.** { *; }

View File

@@ -3,40 +3,24 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application <application
android:name=".KernelSUApplication" android:name=".KernelSUApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="false"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.KernelSU" android:theme="@style/Theme.KernelSU"
tools:targetApi="34"> tools:targetApi="34">
<!-- 专门为小米手机桌面卸载添加了提示,提升用户体验 -->
<meta-data
android:name="app_description_title"
android:resource="@string/miui_uninstall_title" />
<meta-data
android:name="app_description_content"
android:resource="@string/miui_uninstall_content" />
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
android:enabled="true" android:theme="@style/Theme.KernelSU"
android:launchMode="standard" android:windowSoftInputMode="adjustResize">
android:documentLaunchMode="intoExisting"
android:autoRemoveFromRecents="true"
android:theme="@style/Theme.KernelSU">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -62,39 +46,6 @@
<data android:mimeType="application/vnd.android.package-archive" /> <data android:mimeType="application/vnd.android.package-archive" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- 切换图标 -->
<activity-alias
android:name=".ui.MainActivityAlias"
android:targetActivity=".ui.MainActivity"
android:exported="true"
android:enabled="false"
android:icon="@mipmap/ic_launcher_alt"
android:roundIcon="@mipmap/ic_launcher_alt_round">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/zip" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent-filter>
</activity-alias>
<activity <activity
android:name=".ui.webui.WebUIActivity" android:name=".ui.webui.WebUIActivity"
@@ -103,13 +54,6 @@
android:exported="false" android:exported="false"
android:theme="@style/Theme.KernelSU.WebUI" /> android:theme="@style/Theme.KernelSU.WebUI" />
<activity
android:name=".ui.webui.WebUIXActivity"
android:autoRemoveFromRecents="true"
android:documentLaunchMode="intoExisting"
android:exported="false"
android:theme="@style/Theme.KernelSU.WebUI" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View File

@@ -2,9 +2,8 @@
package com.sukisu.zako; package com.sukisu.zako;
import android.content.pm.PackageInfo; import android.content.pm.PackageInfo;
import java.util.List; import rikka.parcelablelist.ParcelableListSlice;
interface IKsuInterface { interface IKsuInterface {
int getPackageCount(); ParcelableListSlice<PackageInfo> getPackages(int flags);
List<PackageInfo> getPackages(int start, int maxCount);
} }

View File

@@ -15,14 +15,4 @@ add_library(kernelsu
find_library(log-lib log) find_library(log-lib log)
if(ANDROID_ABI STREQUAL "arm64-v8a") target_link_libraries(kernelsu ${log-lib})
set(zakosign-lib ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libzakosign.so)
elseif(ANDROID_ABI STREQUAL "armeabi-v7a")
set(zakosign-lib ${CMAKE_SOURCE_DIR}/../jniLibs/armeabi-v7a/libzakosign.so)
endif()
if(ANDROID_ABI STREQUAL "arm64-v8a" OR ANDROID_ABI STREQUAL "armeabi-v7a")
target_link_libraries(kernelsu ${log-lib} ${zakosign-lib})
else()
target_link_libraries(kernelsu ${log-lib})
endif()

View File

@@ -335,11 +335,6 @@ NativeBridge(getUserName, jstring, jint uid) {
return NULL; return NULL;
} }
// Check if KPM is enabled
NativeBridgeNP(isKPMEnabled, jboolean) {
return is_KPM_enable();
}
// Get HOOK type // Get HOOK type
NativeBridgeNP(getHookType, jstring) { NativeBridgeNP(getHookType, jstring) {
char hook_type[32] = { 0 }; char hook_type[32] = { 0 };
@@ -419,7 +414,7 @@ NativeBridgeNP(getManagersList, jobject) {
LogDebug("getManagersList: count=%d", managerListInfo.count); LogDebug("getManagersList: count=%d", managerListInfo.count);
return obj; return obj;
} }
#if 0
NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) { NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) {
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM) #if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
if (!modulePath) { if (!modulePath) {
@@ -438,6 +433,7 @@ NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) {
return false; return false;
#endif #endif
} }
#endif
NativeBridgeNP(isUidScannerEnabled, jboolean) { NativeBridgeNP(isUidScannerEnabled, jboolean) {
return is_uid_scanner_enabled(); return is_uid_scanner_enabled();

View File

@@ -14,6 +14,7 @@
#include "prelude.h" #include "prelude.h"
#include "ksu.h" #include "ksu.h"
#if 0
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM) #if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
// Zako extern declarations // Zako extern declarations
@@ -23,6 +24,7 @@ extern uint32_t zako_file_verify_esig(int fd, uint32_t flags);
extern const char* zako_file_verrcidx2str(uint8_t index); extern const char* zako_file_verrcidx2str(uint8_t index);
#endif // __aarch64__ || _M_ARM64 || __arm__ || _M_ARM #endif // __aarch64__ || _M_ARM64 || __arm__ || _M_ARM
#endif
static int fd = -1; static int fd = -1;
@@ -257,14 +259,6 @@ void get_full_version(char* buff) {
} }
} }
bool is_KPM_enable(void) {
struct ksu_enable_kpm_cmd cmd = {};
if (ksuctl(KSU_IOCTL_ENABLE_KPM, &cmd) == 0 && cmd.enabled) {
return true;
}
return legacy_is_KPM_enable();
}
void get_hook_type(char *buff) { void get_hook_type(char *buff) {
struct ksu_hook_type_cmd cmd = {0}; struct ksu_hook_type_cmd cmd = {0};
if (ksuctl(KSU_IOCTL_HOOK_TYPE, &cmd) == 0) { if (ksuctl(KSU_IOCTL_HOOK_TYPE, &cmd) == 0) {
@@ -348,6 +342,7 @@ bool clear_uid_scanner_environment(void)
return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd); return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd);
} }
#if 0
bool verify_module_signature(const char* input) { bool verify_module_signature(const char* input) {
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM) #if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
if (input == NULL) { if (input == NULL) {
@@ -404,3 +399,4 @@ bool verify_module_signature(const char* input) {
return false; return false;
#endif #endif
} }
#endif

View File

@@ -119,7 +119,9 @@ bool clear_dynamic_manager();
bool get_managers_list(struct manager_list_info* info); bool get_managers_list(struct manager_list_info* info);
#if 0
bool verify_module_signature(const char* input); bool verify_module_signature(const char* input);
#endif
bool is_uid_scanner_enabled(); bool is_uid_scanner_enabled();
@@ -227,10 +229,6 @@ struct ksu_hook_type_cmd {
char hook_type[32]; // Output: hook type string char hook_type[32]; // Output: hook type string
}; };
struct ksu_enable_kpm_cmd {
uint8_t enabled; // Output: true if KPM is enabled
};
struct ksu_dynamic_manager_cmd { struct ksu_dynamic_manager_cmd {
struct dynamic_manager_user_config config; // Input/Output: dynamic manager config struct dynamic_manager_user_config config; // Input/Output: dynamic manager config
}; };

View File

@@ -88,12 +88,6 @@ bool legacy_is_su_enabled() {
return enabled; return enabled;
} }
bool legacy_is_KPM_enable() {
int enabled = false;
ksuctl(CMD_ENABLE_KPM, &enabled, NULL);
return enabled;
}
bool legacy_get_hook_type(char* hook_type, size_t size) { bool legacy_get_hook_type(char* hook_type, size_t size) {
if (hook_type == NULL || size == 0) { if (hook_type == NULL || size == 0) {
return false; return false;

View File

@@ -5,15 +5,7 @@ import android.system.Os
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.ViewModelStoreOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import coil.Coil
import coil.ImageLoader
import com.dergoogler.mmrl.platform.Platform
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import okhttp3.Cache import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.io.File import java.io.File
@@ -30,24 +22,8 @@ class KernelSUApplication : Application(), ViewModelStoreOwner {
super.onCreate() super.onCreate()
ksuApp = this ksuApp = this
// For faster response when first entering superuser or webui activity
val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java] val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java]
CoroutineScope(Dispatchers.Main).launch { superUserViewModel.loadAppList()
superUserViewModel.fetchAppList()
}
Platform.setHiddenApiExemptions()
val context = this
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
Coil.setImageLoader(
ImageLoader.Builder(context)
.components {
add(AppIconKeyer())
add(AppIconFetcher.Factory(iconSize, false, context))
}
.build()
)
val webroot = File(dataDir, "webroot") val webroot = File(dataDir, "webroot")
if (!webroot.exists()) { if (!webroot.exists()) {
@@ -62,11 +38,12 @@ class KernelSUApplication : Application(), ViewModelStoreOwner {
.addInterceptor { block -> .addInterceptor { block ->
block.proceed( block.proceed(
block.request().newBuilder() block.request().newBuilder()
.header("User-Agent", "SukiSU/${BuildConfig.VERSION_CODE}") .header("User-Agent", "KernelSU/${BuildConfig.VERSION_CODE}")
.header("Accept-Language", Locale.getDefault().toLanguageTag()).build() .header("Accept-Language", Locale.getDefault().toLanguageTag()).build()
) )
}.build() }.build()
} }
override val viewModelStore: ViewModelStore override val viewModelStore: ViewModelStore
get() = appViewModelStore get() = appViewModelStore
} }

View File

@@ -8,11 +8,23 @@ import android.system.Os
*/ */
data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) { data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) {
override fun toString(): String = "$major.$patchLevel.$subLevel" override fun toString(): String {
fun isGKI(): Boolean = when { return "$major.$patchLevel.$subLevel"
major > 5 -> true }
major == 5 && patchLevel >= 10 -> true
else -> false fun isGKI(): Boolean {
// kernel 6.x
if (major > 5) {
return true
}
// kernel 5.10.x
if (major == 5) {
return patchLevel >= 10
}
return false
} }
} }

View File

@@ -17,27 +17,18 @@ object Natives {
// 10977: change groups_count and groups to avoid overflow write // 10977: change groups_count and groups to avoid overflow write
// 11071: Fix the issue of failing to set a custom SELinux type. // 11071: Fix the issue of failing to set a custom SELinux type.
// 12143: breaking: new supercall impl // 12143: breaking: new supercall impl
const val MINIMAL_SUPPORTED_KERNEL = 12143 const val MINIMAL_SUPPORTED_KERNEL = 22000
// Get full version
external fun getFullVersion(): String
const val MINIMAL_SUPPORTED_KERNEL_FULL = "v4.0.0"
// 12040: Support disable sucompat mode // 12040: Support disable sucompat mode
const val KERNEL_SU_DOMAIN = "u:r:su:s0" const val KERNEL_SU_DOMAIN = "u:r:su:s0"
const val MINIMAL_SUPPORTED_KERNEL_FULL = "v3.1.8"
const val MINIMAL_SUPPORTED_KPM = 12800
const val MINIMAL_SUPPORTED_DYNAMIC_MANAGER = 13215
const val MINIMAL_SUPPORTED_UID_SCANNER = 13347
const val MINIMAL_NEW_IOCTL_KERNEL = 13490
const val ROOT_UID = 0 const val ROOT_UID = 0
const val ROOT_GID = 0 const val ROOT_GID = 0
// 获取完整版本号
external fun getFullVersion(): String
fun isVersionLessThan(v1Full: String, v2Full: String): Boolean { fun isVersionLessThan(v1Full: String, v2Full: String): Boolean {
fun extractVersionParts(version: String): List<Int> { fun extractVersionParts(version: String): List<Int> {
val match = Regex("""v\d+(\.\d+)*""").find(version) val match = Regex("""v\d+(\.\d+)*""").find(version)
@@ -61,7 +52,6 @@ object Natives {
} }
init { init {
System.loadLibrary("zakosign")
System.loadLibrary("kernelsu") System.loadLibrary("kernelsu")
} }
@@ -118,6 +108,11 @@ object Natives {
external fun isEnhancedSecurityEnabled(): Boolean external fun isEnhancedSecurityEnabled(): Boolean
external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean
/**
* Get the user name for the uid.
*/
external fun getUserName(uid: Int): String?
/** /**
* Su Log can be enabled/disabled. * Su Log can be enabled/disabled.
* 0: disabled * 0: disabled
@@ -126,15 +121,8 @@ object Natives {
*/ */
external fun isSuLogEnabled(): Boolean external fun isSuLogEnabled(): Boolean
external fun setSuLogEnabled(enabled: Boolean): Boolean external fun setSuLogEnabled(enabled: Boolean): Boolean
external fun isKPMEnabled(): Boolean
external fun getHookType(): String external fun getHookType(): String
/**
* Get SUSFS feature status from kernel
* @return SusfsFeatureStatus object containing all feature states, or null if failed
*/
/** /**
* Set dynamic managerature configuration * Set dynamic managerature configuration
* @param size APK signature size * @param size APK signature size
@@ -162,9 +150,6 @@ object Natives {
*/ */
external fun getManagersList(): ManagersList? external fun getManagersList(): ManagersList?
// 模块签名验证
external fun verifyModuleSignature(modulePath: String): Boolean
/** /**
* Check if UID scanner is currently enabled * Check if UID scanner is currently enabled
* @return true if UID scanner is enabled, false otherwise * @return true if UID scanner is enabled, false otherwise
@@ -185,7 +170,7 @@ object Natives {
*/ */
external fun clearUidScannerEnvironment(): Boolean external fun clearUidScannerEnvironment(): Boolean
external fun getUserName(uid: Int): String?
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$" private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
private const val NOBODY_UID = 9999 private const val NOBODY_UID = 9999
@@ -278,4 +263,4 @@ object Natives {
constructor() : this("") constructor() : this("")
} }
} }

View File

@@ -103,6 +103,9 @@ enum class Groups(val gid: Int, val display: String, val desc: String) {
SDK_SANDBOX(1090, "sdk_sandbox", "SDK sandbox virtual UID"), SDK_SANDBOX(1090, "sdk_sandbox", "SDK sandbox virtual UID"),
SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"), SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"),
PRNG_SEEDER(1092, "prng_seeder", "PRNG seeder daemon"), PRNG_SEEDER(1092, "prng_seeder", "PRNG seeder daemon"),
UPROBESTATS(1093, "uprobestats", "uid for uprobestats"),
CROS_EC(1094, "cros_ec", "uid for accessing ChromeOS EC (cros_ec)"),
MMD(1095, "mmd", "uid for memory management daemon"),
SHELL(2000, "shell", "adb and debug shell user"), SHELL(2000, "shell", "adb and debug shell user"),
CACHE(2001, "cache", "cache access"), CACHE(2001, "cache", "cache access"),
@@ -122,6 +125,7 @@ enum class Groups(val gid: Int, val display: String, val desc: String) {
WAKELOCK(3010, "wakelock", "Allow system wakelock read/write access"), WAKELOCK(3010, "wakelock", "Allow system wakelock read/write access"),
UHID(3011, "uhid", "Allow read/write to /dev/uhid node"), UHID(3011, "uhid", "Allow read/write to /dev/uhid node"),
READTRACEFS(3012, "readtracefs", "Allow tracefs read"), READTRACEFS(3012, "readtracefs", "Allow tracefs read"),
VIRTUALMACHINE(3013, "virtualmachine", "Allows VMs to tune for performance"),
EVERYBODY(9997, "everybody", "Shared external storage read/write"), EVERYBODY(9997, "everybody", "Shared external storage read/write"),
MISC(9998, "misc", "Access to misc storage"), MISC(9998, "misc", "Access to misc storage"),

View File

@@ -1,75 +1,71 @@
package com.sukisu.ultra.ui package com.sukisu.ultra.ui
import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.* import android.content.pm.PackageManager
import android.os.IBinder
import android.os.UserHandle
import android.os.UserManager
import android.util.Log import android.util.Log
import com.topjohnwu.superuser.ipc.RootService import com.topjohnwu.superuser.ipc.RootService
import com.sukisu.zako.IKsuInterface import com.sukisu.zako.IKsuInterface
import rikka.parcelablelist.ParcelableListSlice
/** /**
* @author ShirkNeko * @author weishu
* @date 2025/10/17. * @date 2023/4/18.
*/ */
class KsuService : RootService() { class KsuService : RootService() {
private val TAG = "KsuService" companion object {
private const val TAG = "KsuService"
private val cacheLock = Object()
private var _all: List<PackageInfo>? = null
private val allPackages: List<PackageInfo>
get() = synchronized(cacheLock) {
_all ?: loadAllPackages().also { _all = it }
}
private fun loadAllPackages(): List<PackageInfo> {
val tmp = arrayListOf<PackageInfo>()
for (user in (getSystemService(USER_SERVICE) as UserManager).userProfiles) {
val userId = user.getUserIdCompat()
tmp += getInstalledPackagesAsUser(userId)
}
return tmp
} }
internal inner class Stub : IKsuInterface.Stub() { override fun onBind(intent: Intent): IBinder {
override fun getPackageCount(): Int = allPackages.size return Stub()
override fun getPackages(start: Int, maxCount: Int): List<PackageInfo> {
val list = allPackages
val end = (start + maxCount).coerceAtMost(list.size)
return if (start >= list.size) emptyList()
else list.subList(start, end)
}
} }
override fun onBind(intent: Intent): IBinder = Stub() private fun getUserIds(): List<Int> {
val result = ArrayList<Int>()
val um = getSystemService(USER_SERVICE) as UserManager
val userProfiles = um.userProfiles
for (userProfile: UserHandle in userProfiles) {
result.add(userProfile.hashCode())
}
return result
}
@SuppressLint("PrivateApi") private fun getInstalledPackagesAll(flags: Int): ArrayList<PackageInfo> {
private fun getInstalledPackagesAsUser(userId: Int): List<PackageInfo> { val packages = ArrayList<PackageInfo>()
for (userId in getUserIds()) {
Log.i(TAG, "getInstalledPackagesAll: $userId")
packages.addAll(getInstalledPackagesAsUser(flags, userId))
}
return packages
}
@Suppress("UNCHECKED_CAST")
private fun getInstalledPackagesAsUser(flags: Int, userId: Int): List<PackageInfo> {
return try { return try {
val pm = packageManager val pm: PackageManager = packageManager
val m = pm.javaClass.getDeclaredMethod( val method = pm.javaClass.getDeclaredMethod(
"getInstalledPackagesAsUser", "getInstalledPackagesAsUser",
Int::class.java, Int::class.javaPrimitiveType,
Int::class.java Int::class.javaPrimitiveType
) )
@Suppress("UNCHECKED_CAST") method.invoke(pm, flags, userId) as List<PackageInfo>
m.invoke(pm, 0, userId) as List<PackageInfo>
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "getInstalledPackagesAsUser", e) Log.e(TAG, "err", e)
emptyList() ArrayList()
} }
} }
private fun UserHandle.getUserIdCompat(): Int { private inner class Stub : IKsuInterface.Stub() {
return try { override fun getPackages(flags: Int): ParcelableListSlice<PackageInfo> {
javaClass.getDeclaredField("identifier").apply { isAccessible = true }.getInt(this) val list = getInstalledPackagesAll(flags)
} catch (_: NoSuchFieldException) { Log.i(TAG, "getPackages: ${list.size}")
javaClass.getDeclaredMethod("getIdentifier").invoke(this) as Int return ParcelableListSlice(list)
} catch (e: Throwable) {
Log.e("KsuService", "getUserIdCompat", e)
0
} }
} }
} }

View File

@@ -1,307 +1,217 @@
package com.sukisu.ultra.ui package com.sukisu.ultra.ui
import android.content.Context import android.annotation.SuppressLint
import android.content.Intent import android.content.SharedPreferences
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.* import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.* import androidx.compose.animation.slideInHorizontally
import androidx.compose.material.icons.filled.* import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.* import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.* import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.compose.ui.graphics.Color
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.ramcosta.composedestinations.DestinationsNavHost import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.NavGraphs
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.spec.NavHostGraphSpec import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper import dev.chrisbanes.haze.HazeState
import com.sukisu.ultra.Natives import dev.chrisbanes.haze.HazeStyle
import com.sukisu.ultra.ui.screen.BottomBarDestination import dev.chrisbanes.haze.HazeTint
import com.sukisu.ultra.ui.theme.KernelSUTheme import dev.chrisbanes.haze.hazeSource
import com.sukisu.ultra.ui.util.LocalSnackbarHost
import com.sukisu.ultra.ui.util.install
import com.sukisu.ultra.ui.viewmodel.HomeViewModel
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import com.sukisu.ultra.ui.webui.initPlatform
import com.sukisu.ultra.ui.component.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.sukisu.ultra.ui.activity.component.BottomBar import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.activity.util.* import com.sukisu.ultra.ui.component.BottomBar
import com.sukisu.ultra.ui.screen.HomePager
import com.sukisu.ultra.ui.screen.ModulePager
import com.sukisu.ultra.ui.screen.SettingPager
import com.sukisu.ultra.ui.screen.SuperUserPager
import com.sukisu.ultra.ui.theme.KernelSUTheme
import com.sukisu.ultra.ui.util.install
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.theme.MiuixTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var superUserViewModel: SuperUserViewModel
private lateinit var homeViewModel: HomeViewModel
internal val settingsStateFlow = MutableStateFlow(SettingsState())
data class SettingsState(
val isHideOtherInfo: Boolean = false,
val showKpmInfo: Boolean = false
)
private var showConfirmationDialog = mutableStateOf(false)
private var pendingZipFiles = mutableStateOf<List<ZipFileInfo>>(emptyList())
private lateinit var themeChangeObserver: ThemeChangeContentObserver
private var isInitialized = false
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(newBase?.let { LocaleHelper.applyLanguage(it) })
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
try {
// 应用自定义 DPI
DisplayUtils.applyCustomDpi(this)
// Enable edge to edge super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val isManager = Natives.isManager
window.isNavigationBarContrastEnforced = false if (isManager && !Natives.requireNewKernel()) install()
setContent {
val context = LocalActivity.current ?: this
val prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
var colorMode by remember { mutableIntStateOf(prefs.getInt("color_mode", 0)) }
var keyColorInt by remember { mutableIntStateOf(prefs.getInt("key_color", 0)) }
val keyColor = remember(keyColorInt) { if (keyColorInt == 0) null else Color(keyColorInt) }
val darkMode = when (colorMode) {
2, 5 -> true
0, 3 -> isSystemInDarkTheme()
else -> false
} }
super.onCreate(savedInstanceState) DisposableEffect(prefs, darkMode) {
enableEdgeToEdge(
val isManager = Natives.isManager statusBarStyle = SystemBarStyle.auto(
if (isManager && !Natives.requireNewKernel()) { android.graphics.Color.TRANSPARENT,
install() android.graphics.Color.TRANSPARENT
} ) { darkMode },
navigationBarStyle = SystemBarStyle.auto(
// 使用标记控制初始化流程 android.graphics.Color.TRANSPARENT,
if (!isInitialized) { android.graphics.Color.TRANSPARENT
initializeViewModels() ) { darkMode },
initializeData() )
isInitialized = true if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
} window.isNavigationBarContrastEnforced = false
// Check if launched with a ZIP file
val zipUri: ArrayList<Uri>? = when (intent?.action) {
Intent.ACTION_SEND -> {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let { arrayListOf(it) }
} }
Intent.ACTION_SEND_MULTIPLE -> { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { when (key) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) "color_mode" -> colorMode = prefs.getInt("color_mode", 0)
} else { "key_color" -> keyColorInt = prefs.getInt("key_color", 0)
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
}
else -> when {
intent?.data != null -> arrayListOf(intent.data!!)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
intent.getParcelableArrayListExtra("uris", Uri::class.java)
}
else -> {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra("uris")
} }
} }
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
} }
setContent { KernelSUTheme(colorMode = colorMode, keyColor = keyColor) {
KernelSUTheme { val navController = rememberNavController()
val navController = rememberNavController() val navigator = navController.rememberDestinationsNavigator()
val snackBarHostState = remember { SnackbarHostState() } val initialIntent = remember { intent }
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
val bottomBarRoutes = remember { Scaffold {
BottomBarDestination.entries.map { it.direction.route }.toSet() DestinationsNavHost(
} modifier = Modifier,
navGraph = NavGraphs.root,
navController = navController,
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition =
{
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
}
val navigator = navController.rememberDestinationsNavigator() override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition =
{
slideOutHorizontally(
targetOffsetX = { -it / 5 },
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
}
InstallConfirmationDialog( override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition =
show = showConfirmationDialog.value, {
zipFiles = pendingZipFiles.value, slideInHorizontally(
onConfirm = { confirmedFiles -> initialOffsetX = { -it / 5 },
showConfirmationDialog.value = false animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
UltraActivityUtils.navigateToFlashScreen(this, confirmedFiles, navigator) )
}, }
onDismiss = {
showConfirmationDialog.value = false override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition =
pendingZipFiles.value = emptyList() {
finish() slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
)
}
} }
) )
}
HandleZipFileIntent(initialIntent, navigator)
}
}
}
}
LaunchedEffect(zipUri) {
if (!zipUri.isNullOrEmpty()) {
// 检测 ZIP 文件类型并显示确认对话框
lifecycleScope.launch {
UltraActivityUtils.detectZipTypeAndShowConfirmation(this@MainActivity, zipUri) { infos ->
if (infos.isNotEmpty()) {
pendingZipFiles.value = infos
showConfirmationDialog.value = true
} else {
finish()
}
}
}
}
}
val showBottomBar = when (currentDestination?.route) { val LocalPagerState = compositionLocalOf<PagerState> { error("No pager state") }
ExecuteModuleActionScreenDestination.route -> false val LocalHandlePageChange = compositionLocalOf<(Int) -> Unit> { error("No handle page change") }
else -> true
}
LaunchedEffect(Unit) { @Composable
initPlatform() @Destination<RootGraph>(start = true)
} fun MainScreen(navController: DestinationsNavigator) {
val activity = LocalActivity.current
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 4 })
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = MiuixTheme.colorScheme.surface,
tint = HazeTint(MiuixTheme.colorScheme.surface.copy(0.8f))
)
val handlePageChange: (Int) -> Unit = remember(pagerState, coroutineScope) {
{ page ->
coroutineScope.launch { pagerState.animateScrollToPage(page) }
}
}
CompositionLocalProvider( BackHandler {
LocalSnackbarHost provides snackBarHostState if (pagerState.currentPage != 0) {
) { coroutineScope.launch {
Scaffold( pagerState.animateScrollToPage(0)
bottomBar = { }
AnimatedBottomBar.AnimatedBottomBarWrapper( } else {
showBottomBar = showBottomBar, activity?.finishAndRemoveTask()
content = { BottomBar(navController) } }
) }
},
contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { innerPadding ->
DestinationsNavHost(
modifier = Modifier.padding(innerPadding),
navGraph = NavGraphs.root as NavHostGraphSpec,
navController = navController,
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
// If the target is a detail page (not a bottom navigation page), slide in from the right
if (targetState.destination.route !in bottomBarRoutes) {
slideInHorizontally(initialOffsetX = { it })
} else {
// Otherwise (switching between bottom navigation pages), use fade in
fadeIn(animationSpec = tween(340))
}
}
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = { CompositionLocalProvider(
// If navigating from the home page (bottom navigation page) to a detail page, slide out to the left LocalPagerState provides pagerState,
if (initialState.destination.route in bottomBarRoutes && targetState.destination.route !in bottomBarRoutes) { LocalHandlePageChange provides handlePageChange
slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut() ) {
} else { Scaffold(
// Otherwise (switching between bottom navigation pages), use fade out bottomBar = {
fadeOut(animationSpec = tween(340)) BottomBar(hazeState, hazeStyle)
} },
} ) { innerPadding ->
HorizontalPager(
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = { modifier = Modifier.hazeSource(state = hazeState),
// If returning to the home page (bottom navigation page), slide in from the left state = pagerState,
if (targetState.destination.route in bottomBarRoutes) { beyondViewportPageCount = 2,
slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn() userScrollEnabled = false
} else { ) {
// Otherwise (e.g., returning between multiple detail pages), use default fade in when (it) {
fadeIn(animationSpec = tween(340)) 0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
} 1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
} 2 -> ModulePager(navController, innerPadding.calculateBottomPadding())
3 -> SettingPager(navController, innerPadding.calculateBottomPadding())
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
// If returning from a detail page (not a bottom navigation page), scale down and fade out
if (initialState.destination.route !in bottomBarRoutes) {
scaleOut(targetScale = 0.9f) + fadeOut()
} else {
// Otherwise, use default fade out
fadeOut(animationSpec = tween(340))
}
}
}
)
}
}
} }
} }
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun initializeViewModels() {
superUserViewModel = SuperUserViewModel()
homeViewModel = HomeViewModel()
// 设置主题变化监听器
themeChangeObserver = ThemeUtils.registerThemeChangeObserver(this)
}
private fun initializeData() {
lifecycleScope.launch {
try {
superUserViewModel.fetchAppList()
} catch (e: Exception) {
e.printStackTrace()
}
}
// 数据刷新协程
DataRefreshUtils.startDataRefreshCoroutine(lifecycleScope)
DataRefreshUtils.startSettingsMonitorCoroutine(lifecycleScope, this, settingsStateFlow)
// 初始化主题相关设置
ThemeUtils.initializeThemeSettings(this, settingsStateFlow)
}
override fun onResume() {
try {
super.onResume()
ThemeUtils.onActivityResume()
// 仅在需要时刷新数据
if (isInitialized) {
refreshData()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun refreshData() {
lifecycleScope.launch {
try {
superUserViewModel.fetchAppList()
DataRefreshUtils.refreshData(lifecycleScope)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
override fun onPause() {
try {
super.onPause()
ThemeUtils.onActivityPause(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onDestroy() {
try {
ThemeUtils.unregisterThemeChangeObserver(this, themeChangeObserver)
super.onDestroy()
} catch (e: Exception) {
e.printStackTrace()
} }
} }
} }

View File

@@ -0,0 +1,290 @@
package com.sukisu.ultra.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
import androidx.compose.ui.platform.LocalContext
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.KernelFlashScreenDestination
import com.sukisu.ultra.ui.component.ConfirmResult
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.kernelFlash.KpmPatchOption
import com.sukisu.ultra.ui.kernelFlash.KpmPatchSelectionDialog
import com.sukisu.ultra.ui.kernelFlash.component.SlotSelectionDialog
import com.sukisu.ultra.ui.screen.FlashIt
import com.sukisu.ultra.ui.util.getFileName
import com.sukisu.ultra.ui.util.isAbDevice
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
private sealed class DialogState {
data object None : DialogState()
data class SlotSelection(val kernelUri: Uri) : DialogState()
data class KpmSelection(val kernelUri: Uri, val slot: String?) : DialogState()
}
@SuppressLint("StringFormatInvalid")
@Composable
fun HandleZipFileIntent(
intent: Intent?,
navigator: DestinationsNavigator
) {
val context = LocalContext.current
val confirmDialog = rememberConfirmDialog()
val scope = rememberCoroutineScope()
var processed by remember { mutableStateOf(false) }
var dialogState by remember { mutableStateOf<DialogState>(DialogState.None) }
var selectedSlot by remember { mutableStateOf<String?>(null) }
var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) }
LaunchedEffect(intent) {
if (intent == null || processed) return@LaunchedEffect
val zipUris = mutableSetOf<Uri>()
fun isModuleFile(uri: Uri?): Boolean {
if (uri == null) return false
val uriString = uri.toString()
return uriString.endsWith(".zip", ignoreCase = true) ||
uriString.endsWith(".apk", ignoreCase = true)
}
when (intent.action) {
Intent.ACTION_VIEW, Intent.ACTION_SEND -> {
val data = intent.data
val stream = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
when {
isModuleFile(data) -> {
zipUris.add(data!!)
}
isModuleFile(stream) -> {
zipUris.add(stream!!)
}
}
}
Intent.ACTION_SEND_MULTIPLE -> {
val streamList = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
streamList?.forEach { uri ->
if (isModuleFile(uri)) {
zipUris.add(uri)
}
}
}
}
intent.clipData?.let { clipData ->
for (i in 0 until clipData.itemCount) {
clipData.getItemAt(i)?.uri?.let { uri ->
if (isModuleFile(uri)) {
zipUris.add(uri)
}
}
}
}
if (zipUris.isNotEmpty()) {
processed = true
val zipUrisList = zipUris.toList()
// 检测 zip 文件类型
val zipTypes = withContext(Dispatchers.IO) {
zipUrisList.map { uri -> detectZipType(context, uri) }
}
val moduleUris = zipUrisList.filterIndexed { index, _ -> zipTypes[index] == ZipType.MODULE }
val kernelUris = zipUrisList.filterIndexed { index, _ -> zipTypes[index] == ZipType.KERNEL }
val unknownUris = zipUrisList.filterIndexed { index, _ -> zipTypes[index] == ZipType.UNKNOWN }
val finalModuleUris = moduleUris + unknownUris
val fileNames = zipUrisList.mapIndexed { index, uri ->
val fileName = uri.getFileName(context) ?: context.getString(R.string.zip_file_unknown)
val type = when (zipTypes[index]) {
ZipType.MODULE -> context.getString(R.string.zip_type_module)
ZipType.KERNEL -> context.getString(R.string.zip_type_kernel)
ZipType.UNKNOWN -> context.getString(R.string.zip_type_unknown)
}
"\n${index + 1}. $fileName$type"
}.joinToString("")
val confirmContent = when {
moduleUris.isNotEmpty() && kernelUris.isNotEmpty() -> {
context.getString(R.string.mixed_install_prompt_with_name, fileNames)
}
kernelUris.isNotEmpty() -> {
context.getString(R.string.kernel_install_prompt_with_name, fileNames)
}
else -> {
context.getString(R.string.module_install_prompt_with_name, fileNames)
}
}
val confirmTitle = if (kernelUris.isNotEmpty() && moduleUris.isEmpty()) {
context.getString(R.string.horizon_kernel)
} else {
context.getString(R.string.module)
}
val result = confirmDialog.awaitConfirm(
title = confirmTitle,
content = confirmContent
)
if (result == ConfirmResult.Confirmed) {
if (finalModuleUris.isNotEmpty()) {
navigator.navigate(FlashScreenDestination(FlashIt.FlashModules(finalModuleUris))) {
launchSingleTop = true
}
}
// 处理内核安装
if (kernelUris.isNotEmpty()) {
val kernelUri = kernelUris.first()
val isAbDeviceValue = withContext(Dispatchers.IO) { isAbDevice() }
dialogState = if (isAbDeviceValue) {
// AB设备先选择槽位
DialogState.SlotSelection(kernelUri)
} else {
// 非AB设备直接选择KPM
DialogState.KpmSelection(kernelUri, null)
}
}
}
}
}
// 槽位选择
when (val state = dialogState) {
is DialogState.SlotSelection -> {
SlotSelectionDialog(
show = true,
onDismiss = {
dialogState = DialogState.None
selectedSlot = null
kpmPatchOption = KpmPatchOption.FOLLOW_KERNEL
},
onSlotSelected = { slot ->
selectedSlot = slot
dialogState = DialogState.None
scope.launch {
delay(300)
dialogState = DialogState.KpmSelection(state.kernelUri, slot)
}
}
)
}
is DialogState.KpmSelection -> {
KpmPatchSelectionDialog(
show = true,
currentOption = kpmPatchOption,
onDismiss = {
dialogState = DialogState.None
selectedSlot = null
kpmPatchOption = KpmPatchOption.FOLLOW_KERNEL
},
onOptionSelected = { option ->
kpmPatchOption = option
dialogState = DialogState.None
navigator.navigate(
KernelFlashScreenDestination(
kernelUri = state.kernelUri,
selectedSlot = state.slot,
kpmPatchEnabled = option == KpmPatchOption.PATCH_KPM,
kpmUndoPatch = option == KpmPatchOption.UNDO_PATCH_KPM
)
) {
launchSingleTop = true
}
selectedSlot = null
kpmPatchOption = KpmPatchOption.FOLLOW_KERNEL
}
)
}
is DialogState.None -> {
}
}
}
enum class ZipType {
MODULE,
KERNEL,
UNKNOWN
}
fun detectZipType(context: Context, uri: Uri): ZipType {
// 首先检查文件扩展名APK 文件可能是模块
val uriString = uri.toString().lowercase()
val isApk = uriString.endsWith(".apk", ignoreCase = true)
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
java.util.zip.ZipInputStream(inputStream).use { zipStream ->
var hasModuleProp = false
var hasToolsFolder = false
var hasAnykernelSh = false
var entry = zipStream.nextEntry
while (entry != null) {
val entryName = entry.name.lowercase()
when {
entryName == "module.prop" || entryName.endsWith("/module.prop") -> {
hasModuleProp = true
}
entryName.startsWith("tools/") || entryName == "tools" -> {
hasToolsFolder = true
}
entryName == "anykernel.sh" || entryName.endsWith("/anykernel.sh") -> {
hasAnykernelSh = true
}
}
zipStream.closeEntry()
entry = zipStream.nextEntry
}
when {
hasModuleProp -> ZipType.MODULE
hasToolsFolder && hasAnykernelSh -> ZipType.KERNEL
// APK 文件如果没有检测到其他类型,默认当作模块处理
isApk -> ZipType.MODULE
else -> ZipType.UNKNOWN
}
}
} ?: run {
// 如果无法打开文件流APK 文件默认当作模块处理
if (isApk) ZipType.MODULE else ZipType.UNKNOWN
}
} catch (e: java.io.IOException) {
e.printStackTrace()
// 如果是 APK 文件但读取失败,仍然当作模块处理
if (isApk) ZipType.MODULE else ZipType.UNKNOWN
}
}

View File

@@ -1,219 +0,0 @@
package com.sukisu.ultra.ui.activity.component
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavHostController
import com.ramcosta.composedestinations.generated.NavGraphs
import com.ramcosta.composedestinations.spec.RouteOrDirection
import com.ramcosta.composedestinations.utils.isRouteOnBackStackAsState
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.MainActivity
import com.sukisu.ultra.ui.activity.util.*
import com.sukisu.ultra.ui.activity.util.AppData.getKpmVersionUse
import com.sukisu.ultra.ui.screen.BottomBarDestination
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
import com.sukisu.ultra.ui.util.*
@SuppressLint("ContextCastToActivity")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomBar(navController: NavHostController) {
val navigator = navController.rememberDestinationsNavigator()
val isFullFeatured = AppData.isFullFeatured()
val kpmVersion = getKpmVersionUse()
val cardColor = MaterialTheme.colorScheme.surfaceContainer
val activity = LocalContext.current as MainActivity
val settings by activity.settingsStateFlow.collectAsState()
// 检查是否隐藏红点
val isHideOtherInfo = settings.isHideOtherInfo
val showKpmInfo = settings.showKpmInfo
// 收集计数数据
val superuserCount by AppData.DataRefreshManager.superuserCount.collectAsState()
val moduleCount by AppData.DataRefreshManager.moduleCount.collectAsState()
val kpmModuleCount by AppData.DataRefreshManager.kpmModuleCount.collectAsState()
NavigationBar(
modifier = Modifier.windowInsetsPadding(
WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
),
containerColor = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
).containerColor,
tonalElevation = cardElevation
) {
BottomBarDestination.entries.forEach { destination ->
if (destination == BottomBarDestination.Kpm) {
if (kpmVersion.isNotEmpty() && !kpmVersion.startsWith("Error") && !showKpmInfo && Natives.version >= Natives.MINIMAL_SUPPORTED_KPM) {
if (!isFullFeatured && destination.rootRequired) return@forEach
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
NavigationBarItem(
selected = isCurrentDestOnBackStack,
onClick = {
if (!isCurrentDestOnBackStack) {
navigator.popBackStack(destination.direction, false)
}
navigator.navigate(destination.direction) {
popUpTo(NavGraphs.root as RouteOrDirection) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
BadgedBox(
badge = {
if (kpmModuleCount > 0 && !isHideOtherInfo) {
Badge(
containerColor = MaterialTheme.colorScheme.secondary
) {
Text(
text = kpmModuleCount.toString(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
) {
if (isCurrentDestOnBackStack) {
Icon(destination.iconSelected, stringResource(destination.label))
} else {
Icon(destination.iconNotSelected, stringResource(destination.label))
}
}
},
label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) },
alwaysShowLabel = false
)
}
} else if (destination == BottomBarDestination.SuperUser) {
if (!isFullFeatured && destination.rootRequired) return@forEach
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
NavigationBarItem(
selected = isCurrentDestOnBackStack,
onClick = {
if (isCurrentDestOnBackStack) {
navigator.popBackStack(destination.direction, false)
}
navigator.navigate(destination.direction) {
popUpTo(NavGraphs.root) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
BadgedBox(
badge = {
if (superuserCount > 0 && !isHideOtherInfo) {
Badge(
containerColor = MaterialTheme.colorScheme.secondary
) {
Text(
text = superuserCount.toString(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
) {
if (isCurrentDestOnBackStack) {
Icon(destination.iconSelected, stringResource(destination.label))
} else {
Icon(destination.iconNotSelected, stringResource(destination.label))
}
}
},
label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) },
alwaysShowLabel = false
)
} else if (destination == BottomBarDestination.Module) {
if (!isFullFeatured && destination.rootRequired) return@forEach
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
NavigationBarItem(
selected = isCurrentDestOnBackStack,
onClick = {
if (isCurrentDestOnBackStack) {
navigator.popBackStack(destination.direction, false)
}
navigator.navigate(destination.direction) {
popUpTo(NavGraphs.root) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
BadgedBox(
badge = {
if (moduleCount > 0 && !isHideOtherInfo) {
Badge(
containerColor = MaterialTheme.colorScheme.secondary)
{
Text(
text = moduleCount.toString(),
style = MaterialTheme.typography.labelSmall
)
}
}
}
) {
if (isCurrentDestOnBackStack) {
Icon(destination.iconSelected, stringResource(destination.label))
} else {
Icon(destination.iconNotSelected, stringResource(destination.label))
}
}
},
label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) },
alwaysShowLabel = false
)
} else {
if (!isFullFeatured && destination.rootRequired) return@forEach
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
NavigationBarItem(
selected = isCurrentDestOnBackStack,
onClick = {
if (isCurrentDestOnBackStack) {
navigator.popBackStack(destination.direction, false)
}
navigator.navigate(destination.direction) {
popUpTo(NavGraphs.root) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
if (isCurrentDestOnBackStack) {
Icon(destination.iconSelected, stringResource(destination.label))
} else {
Icon(destination.iconNotSelected, stringResource(destination.label))
}
},
label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) },
alwaysShowLabel = false
)
}
}
}
}

View File

@@ -1,97 +0,0 @@
package com.sukisu.ultra.ui.activity.util
import android.content.Context
import android.database.ContentObserver
import android.os.Handler
import android.provider.Settings
import androidx.core.content.edit
import com.sukisu.ultra.ui.MainActivity
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.theme.ThemeConfig
import kotlinx.coroutines.flow.MutableStateFlow
class ThemeChangeContentObserver(
handler: Handler,
private val onThemeChanged: () -> Unit
) : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) {
super.onChange(selfChange)
onThemeChanged()
}
}
object ThemeUtils {
fun initializeThemeSettings(activity: MainActivity, settingsStateFlow: MutableStateFlow<MainActivity.SettingsState>) {
val prefs = activity.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isFirstRun = prefs.getBoolean("is_first_run", true)
settingsStateFlow.value = MainActivity.SettingsState(
isHideOtherInfo = prefs.getBoolean("is_hide_other_info", false),
showKpmInfo = prefs.getBoolean("show_kpm_info", false)
)
if (isFirstRun) {
ThemeConfig.preventBackgroundRefresh = false
activity.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
putBoolean("prevent_background_refresh", false)
}
prefs.edit { putBoolean("is_first_run", false) }
}
// 加载保存的背景设置
loadThemeMode()
loadThemeColors()
loadDynamicColorState()
CardConfig.load(activity.applicationContext)
}
fun registerThemeChangeObserver(activity: MainActivity): ThemeChangeContentObserver {
val contentObserver = ThemeChangeContentObserver(Handler(activity.mainLooper)) {
activity.runOnUiThread {
if (!ThemeConfig.preventBackgroundRefresh) {
ThemeConfig.backgroundImageLoaded = false
loadCustomBackground()
}
}
}
activity.contentResolver.registerContentObserver(
Settings.System.getUriFor("ui_night_mode"),
false,
contentObserver
)
return contentObserver
}
fun unregisterThemeChangeObserver(activity: MainActivity, observer: ThemeChangeContentObserver) {
activity.contentResolver.unregisterContentObserver(observer)
}
fun onActivityPause(activity: MainActivity) {
CardConfig.save(activity.applicationContext)
activity.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
putBoolean("prevent_background_refresh", true)
}
ThemeConfig.preventBackgroundRefresh = true
}
fun onActivityResume() {
if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) {
loadCustomBackground()
}
}
private fun loadThemeMode() {
}
private fun loadThemeColors() {
}
private fun loadDynamicColorState() {
}
private fun loadCustomBackground() {
}
}

View File

@@ -1,236 +0,0 @@
package com.sukisu.ultra.ui.activity.util
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.runtime.Composable
import androidx.lifecycle.LifecycleCoroutineScope
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.MainActivity
import com.sukisu.ultra.ui.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.*
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.ui.component.ZipFileDetector
import com.sukisu.ultra.ui.component.ZipFileInfo
import com.sukisu.ultra.ui.component.ZipType
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.InstallScreenDestination
import com.sukisu.ultra.ui.screen.FlashIt
import kotlinx.coroutines.withContext
import androidx.core.content.edit
object AnimatedBottomBar {
@Composable
fun AnimatedBottomBarWrapper(
showBottomBar: Boolean,
content: @Composable () -> Unit
) {
AnimatedVisibility(
visible = showBottomBar,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
) {
content()
}
}
}
object UltraActivityUtils {
suspend fun detectZipTypeAndShowConfirmation(
activity: MainActivity,
zipUris: ArrayList<Uri>,
onResult: (List<ZipFileInfo>) -> Unit
) {
val infos = ZipFileDetector.detectAndParseZipFiles(activity, zipUris)
withContext(Dispatchers.Main) { onResult(infos) }
}
fun navigateToFlashScreen(
activity: MainActivity,
zipFiles: List<ZipFileInfo>,
navigator: DestinationsNavigator
) {
activity.lifecycleScope.launch {
val moduleUris = zipFiles.filter { it.type == ZipType.MODULE }.map { it.uri }
val kernelUris = zipFiles.filter { it.type == ZipType.KERNEL }.map { it.uri }
when {
kernelUris.isNotEmpty() && moduleUris.isEmpty() -> {
if (kernelUris.size == 1 && rootAvailable()) {
navigator.navigate(
InstallScreenDestination(
preselectedKernelUri = kernelUris.first().toString()
)
)
}
setAutoExitAfterFlash(activity)
}
moduleUris.isNotEmpty() -> {
navigator.navigate(
FlashScreenDestination(
FlashIt.FlashModules(ArrayList(moduleUris))
)
)
setAutoExitAfterFlash(activity)
}
}
}
}
private fun setAutoExitAfterFlash(activity: Context) {
activity.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
.edit {
putBoolean("auto_exit_after_flash", true)
}
}
}
object AppData {
object DataRefreshManager {
// 私有状态流
private val _superuserCount = MutableStateFlow(0)
private val _moduleCount = MutableStateFlow(0)
private val _kpmModuleCount = MutableStateFlow(0)
// 公开的只读状态流
val superuserCount: StateFlow<Int> = _superuserCount.asStateFlow()
val moduleCount: StateFlow<Int> = _moduleCount.asStateFlow()
val kpmModuleCount: StateFlow<Int> = _kpmModuleCount.asStateFlow()
/**
* 刷新所有数据计数
*/
fun refreshData() {
_superuserCount.value = getSuperuserCountUse()
_moduleCount.value = getModuleCountUse()
_kpmModuleCount.value = getKpmModuleCountUse()
}
}
/**
* 获取超级用户应用计数
*/
fun getSuperuserCountUse(): Int {
return try {
if (!rootAvailable()) return 0
getSuperuserCount()
} catch (_: Exception) {
0
}
}
/**
* 获取模块计数
*/
fun getModuleCountUse(): Int {
return try {
if (!rootAvailable()) return 0
getModuleCount()
} catch (_: Exception) {
0
}
}
/**
* 获取KPM模块计数
*/
fun getKpmModuleCountUse(): Int {
return try {
if (!rootAvailable()) return 0
val kpmVersion = getKpmVersionUse()
if (kpmVersion.isEmpty() || kpmVersion.startsWith("Error")) return 0
getKpmModuleCount()
} catch (_: Exception) {
0
}
}
/**
* 获取KPM版本
*/
fun getKpmVersionUse(): String {
return try {
if (!rootAvailable()) return ""
val version = getKpmVersion()
version.ifEmpty { "" }
} catch (e: Exception) {
"Error: ${e.message}"
}
}
/**
* 检查是否是完整功能模式
*/
fun isFullFeatured(): Boolean {
val isManager = Natives.isManager
return isManager && !Natives.requireNewKernel() && rootAvailable()
}
}
object DataRefreshUtils {
fun startDataRefreshCoroutine(scope: LifecycleCoroutineScope) {
scope.launch(Dispatchers.IO) {
while (isActive) {
AppData.DataRefreshManager.refreshData()
delay(5000)
}
}
}
fun startSettingsMonitorCoroutine(
scope: LifecycleCoroutineScope,
activity: MainActivity,
settingsStateFlow: MutableStateFlow<MainActivity.SettingsState>
) {
scope.launch(Dispatchers.IO) {
while (isActive) {
val prefs = activity.getSharedPreferences("settings", Context.MODE_PRIVATE)
settingsStateFlow.value = MainActivity.SettingsState(
isHideOtherInfo = prefs.getBoolean("is_hide_other_info", false),
showKpmInfo = prefs.getBoolean("show_kpm_info", false)
)
delay(1000)
}
}
}
fun refreshData(scope: LifecycleCoroutineScope) {
scope.launch {
AppData.DataRefreshManager.refreshData()
}
}
}
object DisplayUtils {
fun applyCustomDpi(context: Context) {
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val customDpi = prefs.getInt("app_dpi", 0)
if (customDpi > 0) {
try {
val resources = context.resources
val metrics = resources.displayMetrics
metrics.density = customDpi / 160f
@Suppress("DEPRECATION")
metrics.scaledDensity = customDpi / 160f
metrics.densityDpi = customDpi
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}

View File

@@ -1,117 +0,0 @@
package com.sukisu.ultra.ui.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.sukisu.ultra.BuildConfig
import com.sukisu.ultra.R
@Preview
@Composable
fun AboutCard() {
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
AboutCardContent()
}
}
}
@Composable
fun AboutDialog(dismiss: () -> Unit) {
Dialog(
onDismissRequest = { dismiss() }
) {
AboutCard()
}
}
@Composable
private fun AboutCardContent() {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row {
Surface(
modifier = Modifier.size(40.dp),
color = colorResource(id = R.color.ic_launcher_background),
shape = CircleShape
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_monochrome),
contentDescription = "icon",
modifier = Modifier.scale(1.4f)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
stringResource(id = R.string.app_name),
style = MaterialTheme.typography.titleSmall,
fontSize = 18.sp
)
Text(
BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.bodySmall,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(8.dp))
val annotatedString = AnnotatedString.fromHtml(
htmlString = stringResource(
id = R.string.about_source_code,
"<b><a href=\"https://github.com/ShirkNeko/SukiSU-Ultra\">GitHub</a></b>",
"<b><a href=\"https://t.me/SukiKSU\">Telegram</a></b>",
"<b>怡子曰曰</b>",
"<b>明风 OuO</b>",
"<b><a href=\"https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.txt\">CC BY-NC-SA 4.0</a></b>"
),
linkStyles = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
),
pressedStyle = SpanStyle(
color = MaterialTheme.colorScheme.primary,
background = MaterialTheme.colorScheme.secondaryContainer,
textDecoration = TextDecoration.Underline
)
)
)
Text(
text = annotatedString,
style = TextStyle(
fontSize = 14.sp
)
)
}
}
}
}

View File

@@ -0,0 +1,57 @@
package com.sukisu.ultra.ui.component
import android.content.pm.PackageInfo
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import com.kyant.capsule.ContinuousRoundedRectangle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@Composable
fun AppIconImage(
packageInfo: PackageInfo,
label: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
var icon by remember(packageInfo.packageName) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(packageInfo.packageName) {
withContext(Dispatchers.IO) {
val drawable = packageInfo.applicationInfo?.loadIcon(context.packageManager)
val bitmap = drawable?.toBitmap()?.asImageBitmap()
icon = bitmap
}
}
icon.let { imageBitmap ->
imageBitmap?.let {
Image(
bitmap = it,
contentDescription = label,
modifier = modifier
)
}
} ?: Box(
modifier = modifier
.clip(ContinuousRoundedRectangle(12.dp))
.background(colorScheme.secondaryContainer),
contentAlignment = Alignment.Center
) {}
}

View File

@@ -0,0 +1,69 @@
package com.sukisu.ultra.ui.component
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Cottage
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Security
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.hazeEffect
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.LocalHandlePageChange
import com.sukisu.ultra.ui.LocalPagerState
import com.sukisu.ultra.ui.util.rootAvailable
import top.yukonga.miuix.kmp.basic.NavigationBar
import top.yukonga.miuix.kmp.basic.NavigationItem
@Composable
fun BottomBar(
hazeState: HazeState,
hazeStyle: HazeStyle
) {
val isManager = Natives.isManager
val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable()
val page = LocalPagerState.current.targetPage
val handlePageChange = LocalHandlePageChange.current
if (!fullFeatured) return
val item = BottomBarDestination.entries.map { destination ->
NavigationItem(
label = stringResource(destination.label),
icon = destination.icon,
)
}
NavigationBar(
modifier = Modifier
.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
},
color = Color.Transparent,
items = item,
selected = page,
onClick = handlePageChange
)
}
enum class BottomBarDestination(
@get:StringRes val label: Int,
val icon: ImageVector,
) {
Home(R.string.home, Icons.Rounded.Cottage),
SuperUser(R.string.superuser, Icons.Rounded.Security),
Module(R.string.module, Icons.Rounded.Extension),
Setting(R.string.settings, Icons.Rounded.Settings)
}

View File

@@ -0,0 +1,74 @@
package com.sukisu.ultra.ui.component
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.getSupportedKmis
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@Composable
fun ChooseKmiDialog(
showDialog: MutableState<Boolean>,
onSelected: (String?) -> Unit
) {
val supportedKmi by produceState(initialValue = emptyList()) {
value = getSupportedKmis()
}
val options = supportedKmi.map { it }
SuperDialog(
show = showDialog,
insideMargin = DpSize(0.dp, 0.dp),
onDismissRequest = {
showDialog.value = false
},
content = {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 12.dp),
text = stringResource(R.string.select_kmi),
fontSize = MiuixTheme.textStyles.title4.fontSize,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = colorScheme.onSurface
)
options.forEachIndexed { index, type ->
SuperArrow(
title = type,
onClick = {
onSelected(type)
showDialog.value = false
},
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
)
}
TextButton(
text = stringResource(id = android.R.string.cancel),
onClick = {
showDialog.value = false
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 24.dp)
.padding(horizontal = 24.dp)
)
}
)
}

View File

@@ -1,51 +1,59 @@
package com.sukisu.ultra.ui.component package com.sukisu.ultra.ui.component
import android.graphics.text.LineBreaker
import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import android.text.Layout
import android.text.method.LinkMovementMethod
import android.util.Log import android.util.Log
import android.view.ViewGroup import androidx.compose.foundation.layout.Arrangement
import android.widget.TextView
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable
import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.MutableState
import androidx.compose.material3.* import androidx.compose.runtime.getValue
import androidx.compose.runtime.* import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.Layout
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.CancellableContinuation
import androidx.compose.ui.window.Dialog import kotlinx.coroutines.CoroutineScope
import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.async
import io.noties.markwon.Markwon
import io.noties.markwon.utils.NoCopySpannableFactory
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import com.sukisu.ultra.R
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import kotlin.coroutines.resume import kotlin.coroutines.resume
private const val TAG = "DialogComponent" private const val TAG = "DialogComponent"
interface ConfirmDialogVisuals : Parcelable { interface ConfirmDialogVisuals : Parcelable {
val title: String val title: String
val content: String val content: String?
val isMarkdown: Boolean val isMarkdown: Boolean
val confirm: String? val confirm: String?
val dismiss: String? val dismiss: String?
@@ -54,7 +62,7 @@ interface ConfirmDialogVisuals : Parcelable {
@Parcelize @Parcelize
private data class ConfirmDialogVisualsImpl( private data class ConfirmDialogVisualsImpl(
override val title: String, override val title: String,
override val content: String, override val content: String?,
override val isMarkdown: Boolean, override val isMarkdown: Boolean,
override val confirm: String?, override val confirm: String?,
override val dismiss: String?, override val dismiss: String?,
@@ -86,16 +94,15 @@ interface ConfirmDialogHandle : DialogHandle {
fun showConfirm( fun showConfirm(
title: String, title: String,
content: String, content: String? = null,
markdown: Boolean = false, markdown: Boolean = false,
confirm: String? = null, confirm: String? = null,
dismiss: String? = null dismiss: String? = null
) )
suspend fun awaitConfirm( suspend fun awaitConfirm(
title: String, title: String,
content: String, content: String? = null,
markdown: Boolean = false, markdown: Boolean = false,
confirm: String? = null, confirm: String? = null,
dismiss: String? = null dismiss: String? = null
@@ -159,7 +166,10 @@ interface ConfirmCallback {
val isEmpty: Boolean get() = onConfirm == null && onDismiss == null val isEmpty: Boolean get() = onConfirm == null && onDismiss == null
companion object { companion object {
operator fun invoke(onConfirmProvider: () -> NullableCallback, onDismissProvider: () -> NullableCallback): ConfirmCallback { operator fun invoke(
onConfirmProvider: () -> NullableCallback,
onDismissProvider: () -> NullableCallback
): ConfirmCallback {
return object : ConfirmCallback { return object : ConfirmCallback {
override val onConfirm: NullableCallback override val onConfirm: NullableCallback
get() = onConfirmProvider() get() = onConfirmProvider()
@@ -250,7 +260,7 @@ private class ConfirmDialogHandleImpl(
override fun showConfirm( override fun showConfirm(
title: String, title: String,
content: String, content: String?,
markdown: Boolean, markdown: Boolean,
confirm: String?, confirm: String?,
dismiss: String? dismiss: String?
@@ -263,7 +273,7 @@ private class ConfirmDialogHandleImpl(
override suspend fun awaitConfirm( override suspend fun awaitConfirm(
title: String, title: String,
content: String, content: String?,
markdown: Boolean, markdown: Boolean,
confirm: String?, confirm: String?,
dismiss: String? dismiss: String?
@@ -299,23 +309,12 @@ private class ConfirmDialogHandleImpl(
} }
} }
private class CustomDialogHandleImpl(
visible: MutableState<Boolean>,
coroutineScope: CoroutineScope
) : DialogHandleBase(visible, coroutineScope) {
override val dialogType: String get() = "CustomDialog"
}
@Composable @Composable
fun rememberLoadingDialog(): LoadingDialogHandle { fun rememberLoadingDialog(): LoadingDialogHandle {
val visible = remember { val visible = remember { mutableStateOf(false) }
mutableStateOf(false)
}
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
if (visible.value) { LoadingDialog(visible)
LoadingDialog()
}
return remember { return remember {
LoadingDialogHandleImpl(visible, coroutineScope) LoadingDialogHandleImpl(visible, coroutineScope)
@@ -343,7 +342,8 @@ private fun rememberConfirmDialog(visuals: ConfirmDialogVisuals, callback: Confi
ConfirmDialog( ConfirmDialog(
handle.visuals, handle.visuals,
confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } }, confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } } dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } },
showDialog = visible
) )
} }
@@ -370,99 +370,97 @@ fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle {
} }
@Composable @Composable
fun rememberCustomDialog(composable: @Composable (dismiss: () -> Unit) -> Unit): DialogHandle { private fun LoadingDialog(showDialog: MutableState<Boolean>) {
val visible = rememberSaveable { SuperDialog(
mutableStateOf(false) show = showDialog,
}
val coroutineScope = rememberCoroutineScope()
if (visible.value) {
composable { visible.value = false }
}
return remember {
CustomDialogHandleImpl(visible, coroutineScope)
}
}
@Composable
private fun LoadingDialog() {
Dialog(
onDismissRequest = {}, onDismissRequest = {},
properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false) content = {
) {
Surface(
modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
) {
Box( Box(
contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) { ) {
CircularProgressIndicator() Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
InfiniteProgressIndicator(
color = MiuixTheme.colorScheme.onBackground
)
Text(
modifier = Modifier.padding(start = 12.dp),
text = stringResource(R.string.processing),
fontWeight = FontWeight.Medium
)
}
} }
} }
}
}
@Composable
private fun ConfirmDialog(visuals: ConfirmDialogVisuals, confirm: () -> Unit, dismiss: () -> Unit) {
AlertDialog(
onDismissRequest = {
dismiss()
},
title = {
Text(text = visuals.title)
},
text = {
if (visuals.isMarkdown) {
MarkdownContent(content = visuals.content)
} else {
Text(text = visuals.content)
}
},
confirmButton = {
TextButton(onClick = confirm) {
Text(text = visuals.confirm ?: stringResource(id = android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = dismiss) {
Text(text = visuals.dismiss ?: stringResource(id = android.R.string.cancel))
}
},
) )
} }
@Composable @Composable
private fun MarkdownContent(content: String) { private fun ConfirmDialog(
val contentColor = LocalContentColor.current visuals: ConfirmDialogVisuals,
val scrollState = rememberScrollState() confirm: () -> Unit,
dismiss: () -> Unit,
Column( showDialog: MutableState<Boolean>
modifier = Modifier ) {
.fillMaxWidth() SuperDialog(
.verticalScroll( modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
state = scrollState, show = showDialog,
flingBehavior = ScrollableDefaults.flingBehavior() title = visuals.title,
) onDismissRequest = {
.padding(12.dp) dismiss()
) { showDialog.value = false
AndroidView( },
factory = { context -> content = {
TextView(context).apply { Layout(
movementMethod = LinkMovementMethod.getInstance() content = {
setSpannableFactory(NoCopySpannableFactory.getInstance()) visuals.content?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (visuals.isMarkdown) {
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE MarkdownContent(content = visuals.content!!)
} else {
Text(text = visuals.content!!)
}
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(top = 12.dp)
) {
TextButton(
text = visuals.dismiss ?: stringResource(id = android.R.string.cancel),
onClick = {
dismiss()
showDialog.value = false
},
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(20.dp))
TextButton(
text = visuals.confirm ?: stringResource(id = android.R.string.ok),
onClick = {
confirm()
showDialog.value = false
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
}
) { measurables, constraints ->
if (measurables.size != 2) {
val button = measurables[0].measure(constraints)
layout(constraints.maxWidth, button.height) {
button.place(0, 0)
}
} else {
val button = measurables[1].measure(constraints)
val lazyList = measurables[0].measure(constraints.copy(maxHeight = constraints.maxHeight - button.height))
layout(constraints.maxWidth, lazyList.height + button.height) {
lazyList.place(0, 0)
button.place(0, lazyList.height)
} }
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
} }
},
update = {
Markwon.create(it.context).setMarkdown(it, content)
it.setTextColor(contentColor.toArgb())
} }
) }
} )
} }

View File

@@ -0,0 +1,48 @@
package com.sukisu.ultra.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.extra.DropdownColors
import top.yukonga.miuix.kmp.extra.DropdownDefaults
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun DropdownItem(
text: String,
optionSize: Int,
index: Int,
dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
onSelectedIndexChange: (Int) -> Unit
) {
val currentOnSelectedIndexChange = rememberUpdatedState(onSelectedIndexChange)
val additionalTopPadding = if (index == 0) 20f.dp else 12f.dp
val additionalBottomPadding = if (index == optionSize - 1) 20f.dp else 12f.dp
Row(
modifier = Modifier
.clickable { currentOnSelectedIndexChange.value(index) }
.background(dropdownColors.containerColor)
.padding(horizontal = 20.dp)
.padding(
top = additionalTopPadding,
bottom = additionalBottomPadding
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
fontSize = MiuixTheme.textStyles.body1.fontSize,
fontWeight = FontWeight.Medium,
color = dropdownColors.contentColor,
)
}
}

View File

@@ -0,0 +1,264 @@
package com.sukisu.ultra.ui.component
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Security
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@Composable
fun DynamicManagerCard() {
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
var isDynEnabled by rememberSaveable {
mutableStateOf(
Natives.getDynamicManager()?.isValid() == true
)
}
var dynSize by rememberSaveable {
mutableStateOf(
Natives.getDynamicManager()?.size?.toString() ?: ""
)
}
var dynHash by rememberSaveable {
mutableStateOf(
Natives.getDynamicManager()?.hash ?: ""
)
}
val showDynDialog = rememberSaveable { mutableStateOf(false) }
SuperArrow(
title = stringResource(R.string.dynamic_manager_title),
summary = if (isDynEnabled) {
stringResource(R.string.dynamic_manager_enabled_summary, dynSize)
} else {
stringResource(R.string.dynamic_manager_disabled)
},
leftAction = {
Icon(
Icons.Rounded.Security,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.dynamic_manager_title),
tint = colorScheme.onBackground
)
},
onClick = {
showDynDialog.value = true
}
)
DynamicManagerDialog(
show = showDynDialog,
initialEnabled = isDynEnabled,
initialSize = dynSize,
initialHash = dynHash,
onConfirm = { enabled, size, hash ->
scope.launch(Dispatchers.IO) {
if (enabled) {
val newSize = try {
when {
size.startsWith("0x", true) ->
size.substring(2).toInt(16)
else -> size.toInt()
}
} catch (_: Exception) {
-1
}
if (newSize <= 0 || hash.length != 64) {
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
context,
R.string.invalid_sign_config,
android.widget.Toast.LENGTH_SHORT
).show()
}
return@launch
}
val ok = Natives.setDynamicManager(newSize, hash)
withContext(Dispatchers.Main) {
if (ok) {
dynSize = size
dynHash = hash
isDynEnabled = true
prefs.edit().apply {
putBoolean("dm_enabled", true)
putString("dm_size", dynSize)
putString("dm_hash", dynHash)
apply()
}
android.widget.Toast.makeText(
context,
R.string.dynamic_manager_set_success,
android.widget.Toast.LENGTH_SHORT
).show()
} else {
android.widget.Toast.makeText(
context,
R.string.dynamic_manager_set_failed,
android.widget.Toast.LENGTH_SHORT
).show()
}
}
} else {
val ok = Natives.clearDynamicManager()
withContext(Dispatchers.Main) {
if (ok) {
isDynEnabled = false
prefs.edit().apply {
putBoolean("dm_enabled", false)
apply()
}
android.widget.Toast.makeText(
context,
R.string.dynamic_manager_disabled_success,
android.widget.Toast.LENGTH_SHORT
).show()
} else {
android.widget.Toast.makeText(
context,
R.string.dynamic_manager_clear_failed,
android.widget.Toast.LENGTH_SHORT
).show()
}
}
}
}
}
)
}
}
@Composable
private fun DynamicManagerDialog(
show: MutableState<Boolean>,
initialEnabled: Boolean,
initialSize: String,
initialHash: String,
onConfirm: (enabled: Boolean, size: String, hash: String) -> Unit
) {
var tempDynEnabled by remember { mutableStateOf(initialEnabled) }
var tempDynSize by remember { mutableStateOf(initialSize) }
var tempDynHash by remember { mutableStateOf(initialHash) }
if (show.value) {
tempDynEnabled = initialEnabled
tempDynSize = initialSize
tempDynHash = initialHash
}
SuperDialog(
title = stringResource(R.string.dynamic_manager_title),
show = show,
onDismissRequest = {
show.value = false
}
) {
Column(
modifier = Modifier.padding(bottom = 16.dp)
) {
SuperSwitch(
title = stringResource(R.string.enable_dynamic_manager),
checked = tempDynEnabled,
onCheckedChange = { tempDynEnabled = it }
)
Spacer(Modifier.height(16.dp))
TextField(
value = tempDynSize,
onValueChange = { value ->
// 只允许输入十六进制字符
if (value.all { it in "0123456789xXaAbBcCdDeEfF" }) {
tempDynSize = value
}
},
label = stringResource(R.string.signature_size),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
TextField(
value = tempDynHash,
onValueChange = { value ->
// 只允许输入十六进制字符最多64个
if (value.all { it in "0123456789aAbBcCdDeEfF" } && value.length <= 64) {
tempDynHash = value
}
},
label = stringResource(R.string.signature_hash),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
Text(
text = "${tempDynHash.length} / 64",
modifier = Modifier.padding(start = 12.dp, top = 4.dp),
color = colorScheme.onSurfaceVariantSummary
)
}
Row(
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = {
show.value = false
},
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(20.dp))
TextButton(
text = stringResource(android.R.string.ok),
onClick = {
show.value = false
onConfirm(tempDynEnabled, tempDynSize.trim(), tempDynHash.trim())
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
}
}

View File

@@ -0,0 +1,199 @@
package com.sukisu.ultra.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import kotlin.math.max
@Composable
fun EditText(
title: String,
summary: String? = null,
textValue: MutableState<String>,
onTextValueChange: (String) -> Unit = {},
textHint: String = "",
enabled: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
titleColor: BasicComponentColors = EditTextDefaults.titleColor(),
summaryColor: BasicComponentColors = EditTextDefaults.summaryColor(),
rightActionColor: BasicComponentColors = EditTextDefaults.rightActionColors(),
isError: Boolean = false,
) {
val interactionSource = remember { MutableInteractionSource() }
val coroutineScope = rememberCoroutineScope()
val focused = interactionSource.collectIsFocusedAsState().value
val focusRequester = remember { FocusRequester() }
if (focused) {
focusRequester.requestFocus()
}
Box(
modifier = Modifier
.clickable(
indication = null,
interactionSource = null
) {
if (enabled) {
coroutineScope.launch {
interactionSource.emit(FocusInteraction.Focus())
}
}
}
.heightIn(min = 56.dp)
.fillMaxWidth()
.padding(EditTextDefaults.InsideMargin),
) {
Layout(
content = {
Text(
text = title,
fontSize = MiuixTheme.textStyles.headline1.fontSize,
fontWeight = FontWeight.Medium,
color = titleColor.color(enabled)
)
summary?.let {
Text(
text = it,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = summaryColor.color(enabled)
)
}
BasicTextField(
value = textValue.value,
onValueChange = {
onTextValueChange(it)
},
modifier = Modifier
.focusRequester(focusRequester)
.semantics {
onClick {
focusRequester.requestFocus()
true
}
},
enabled = enabled,
textStyle = MiuixTheme.textStyles.main.copy(
textAlign = TextAlign.End,
color = if (isError) {
Color.Red.copy(alpha = if (isSystemInDarkTheme()) 0.3f else 0.6f)
} else {
rightActionColor.color(enabled)
}
),
keyboardOptions = keyboardOptions,
cursorBrush = SolidColor(colorScheme.primary),
interactionSource = interactionSource,
decorationBox =
@Composable { innerTextField ->
Box(
contentAlignment = Alignment.CenterEnd
) {
Text(
text = if (textValue.value.isEmpty()) textHint else "",
color = rightActionColor.color(enabled),
textAlign = TextAlign.End,
softWrap = false,
maxLines = 1
)
innerTextField()
}
}
)
}
) { measurables, constraints ->
val leftConstraints = constraints.copy(maxWidth = constraints.maxWidth / 2)
val hasSummary = measurables.size > 2
val titleText = measurables[0].measure(leftConstraints)
val summaryText = (if (hasSummary) measurables[1] else null)?.measure(leftConstraints)
val leftWidth = max(titleText.width, (summaryText?.width ?: 0))
val leftHeight = titleText.height + (summaryText?.height ?: 0)
val rightWidth = constraints.maxWidth - leftWidth - 16.dp.roundToPx()
val rightConstraints = constraints.copy(maxWidth = rightWidth)
val inputField = (if (hasSummary) measurables[2] else measurables[1]).measure(rightConstraints)
val totalHeight = max(leftHeight, inputField.height)
layout(constraints.maxWidth, totalHeight) {
val titleY = (totalHeight - leftHeight) / 2
titleText.placeRelative(0, titleY)
summaryText?.placeRelative(0, titleY + titleText.height)
inputField.placeRelative(constraints.maxWidth - inputField.width, (totalHeight - inputField.height) / 2)
}
}
}
}
object EditTextDefaults {
val InsideMargin = PaddingValues(16.dp)
@Composable
fun titleColor(
color: Color = colorScheme.onSurface,
disabledColor: Color = colorScheme.disabledOnSecondaryVariant
): BasicComponentColors {
return BasicComponentColors(
color = color,
disabledColor = disabledColor
)
}
@Composable
fun summaryColor(
color: Color = colorScheme.onSurfaceVariantSummary,
disabledColor: Color = colorScheme.disabledOnSecondaryVariant
): BasicComponentColors {
return BasicComponentColors(
color = color,
disabledColor = disabledColor
)
}
@Composable
fun rightActionColors(
color: Color = colorScheme.onSurfaceVariantActions,
disabledColor: Color = colorScheme.disabledOnSecondaryVariant,
): BasicComponentColors {
return BasicComponentColors(
color = color,
disabledColor = disabledColor
)
}
}
@Immutable
class BasicComponentColors(
private val color: Color,
private val disabledColor: Color
) {
@Stable
fun color(enabled: Boolean): Color = if (enabled) color else disabledColor
}

View File

@@ -1,75 +0,0 @@
package com.sukisu.ultra.ui.component
import android.annotation.SuppressLint
import androidx.compose.animation.*
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
@SuppressLint("AutoboxingStateCreation")
@Composable
fun rememberFabVisibilityState(listState: LazyListState): State<Boolean> {
var previousScrollOffset by remember { mutableStateOf(0) }
var previousIndex by remember { mutableStateOf(0) }
val fabVisible = remember { mutableStateOf(true) }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
if (previousIndex == 0 && previousScrollOffset == 0) {
fabVisible.value = true
} else {
val isScrollingDown = when {
index > previousIndex -> false
index < previousIndex -> true
else -> offset < previousScrollOffset
}
fabVisible.value = isScrollingDown
}
previousIndex = index
previousScrollOffset = offset
}
}
return fabVisible
}
@Composable
fun AnimatedFab(
visible: Boolean,
content: @Composable () -> Unit
) {
val scale by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
AnimatedVisibility(
visible = visible,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(targetScale = 0.8f)
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.scale(scale)
.alpha(scale)
) {
content()
}
}
}

View File

@@ -1,441 +0,0 @@
package com.sukisu.ultra.ui.component
import android.content.Context
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.GetApp
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.util.zip.ZipInputStream
enum class ZipType {
MODULE,
KERNEL,
UNKNOWN
}
data class ZipFileInfo(
val uri: Uri,
val type: ZipType,
val name: String = "",
val version: String = "",
val versionCode: String = "",
val author: String = "",
val description: String = "",
val kernelVersion: String = "",
val supported: String = ""
)
object ZipFileDetector {
fun detectZipType(context: Context, uri: Uri): ZipType {
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
ZipInputStream(inputStream).use { zipStream ->
var hasModuleProp = false
var hasToolsFolder = false
var hasAnykernelSh = false
var entry = zipStream.nextEntry
while (entry != null) {
val entryName = entry.name.lowercase()
when {
entryName == "module.prop" || entryName.endsWith("/module.prop") -> {
hasModuleProp = true
}
entryName.startsWith("tools/") || entryName == "tools" -> {
hasToolsFolder = true
}
entryName == "anykernel.sh" || entryName.endsWith("/anykernel.sh") -> {
hasAnykernelSh = true
}
}
zipStream.closeEntry()
entry = zipStream.nextEntry
}
when {
hasModuleProp -> ZipType.MODULE
hasToolsFolder && hasAnykernelSh -> ZipType.KERNEL
else -> ZipType.UNKNOWN
}
}
} ?: ZipType.UNKNOWN
} catch (e: IOException) {
e.printStackTrace()
ZipType.UNKNOWN
}
}
fun parseModuleInfo(context: Context, uri: Uri): ZipFileInfo {
var zipInfo = ZipFileInfo(uri = uri, type = ZipType.MODULE)
try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
ZipInputStream(inputStream).use { zipStream ->
var entry = zipStream.nextEntry
while (entry != null) {
if (entry.name.lowercase() == "module.prop" || entry.name.endsWith("/module.prop")) {
val reader = BufferedReader(InputStreamReader(zipStream))
val props = mutableMapOf<String, String>()
var line = reader.readLine()
while (line != null) {
if (line.contains("=") && !line.startsWith("#")) {
val parts = line.split("=", limit = 2)
if (parts.size == 2) {
props[parts[0].trim()] = parts[1].trim()
}
}
line = reader.readLine()
}
zipInfo = zipInfo.copy(
name = props["name"] ?: context.getString(R.string.unknown_module),
version = props["version"] ?: "",
versionCode = props["versionCode"] ?: "",
author = props["author"] ?: "",
description = props["description"] ?: ""
)
break
}
zipStream.closeEntry()
entry = zipStream.nextEntry
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return zipInfo
}
fun parseKernelInfo(context: Context, uri: Uri): ZipFileInfo {
var zipInfo = ZipFileInfo(uri = uri, type = ZipType.KERNEL)
try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
ZipInputStream(inputStream).use { zipStream ->
var entry = zipStream.nextEntry
while (entry != null) {
if (entry.name.lowercase() == "anykernel.sh" || entry.name.endsWith("/anykernel.sh")) {
val reader = BufferedReader(InputStreamReader(zipStream))
val props = mutableMapOf<String, String>()
var inPropertiesBlock = false
var line = reader.readLine()
while (line != null) {
if (line.contains("properties()")) {
inPropertiesBlock = true
} else if (inPropertiesBlock && line.contains("'; }")) {
inPropertiesBlock = false
} else if (inPropertiesBlock) {
val propertyLine = line.trim()
if (propertyLine.contains("=") && !propertyLine.startsWith("#")) {
val parts = propertyLine.split("=", limit = 2)
if (parts.size == 2) {
val key = parts[0].trim()
val value = parts[1].trim().removeSurrounding("'").removeSurrounding("\"")
when (key) {
"kernel.string" -> props["name"] = value
"supported.versions" -> props["supported"] = value
}
}
}
}
// 解析普通变量定义
if (line.contains("kernel.string=") && !inPropertiesBlock) {
val value = line.substringAfter("kernel.string=").trim().removeSurrounding("\"")
props["name"] = value
}
if (line.contains("supported.versions=") && !inPropertiesBlock) {
val value = line.substringAfter("supported.versions=").trim().removeSurrounding("\"")
props["supported"] = value
}
if (line.contains("kernel.version=") && !inPropertiesBlock) {
val value = line.substringAfter("kernel.version=").trim().removeSurrounding("\"")
props["version"] = value
}
if (line.contains("kernel.author=") && !inPropertiesBlock) {
val value = line.substringAfter("kernel.author=").trim().removeSurrounding("\"")
props["author"] = value
}
line = reader.readLine()
}
zipInfo = zipInfo.copy(
name = props["name"] ?: context.getString(R.string.unknown_kernel),
version = props["version"] ?: "",
author = props["author"] ?: "",
supported = props["supported"] ?: "",
kernelVersion = props["version"] ?: ""
)
break
}
zipStream.closeEntry()
entry = zipStream.nextEntry
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return zipInfo
}
suspend fun detectAndParseZipFiles(context: Context, zipUris: List<Uri>): List<ZipFileInfo> {
return withContext(Dispatchers.IO) {
val zipFileInfos = mutableListOf<ZipFileInfo>()
for (uri in zipUris) {
val zipType = detectZipType(context, uri)
val zipInfo = when (zipType) {
ZipType.MODULE -> parseModuleInfo(context, uri)
ZipType.KERNEL -> parseKernelInfo(context, uri)
ZipType.UNKNOWN -> ZipFileInfo(
uri = uri,
type = ZipType.UNKNOWN,
name = context.getString(R.string.unknown_file)
)
}
zipFileInfos.add(zipInfo)
}
zipFileInfos.filter { it.type != ZipType.UNKNOWN }
}
}
}
@Composable
fun InstallConfirmationDialog(
show: Boolean,
zipFiles: List<ZipFileInfo>,
onConfirm: (List<ZipFileInfo>) -> Unit,
onDismiss: () -> Unit
) {
if (show && zipFiles.isNotEmpty()) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = if (zipFiles.any { it.type == ZipType.KERNEL })
Icons.Default.Memory else Icons.Default.Extension,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = if (zipFiles.size == 1) {
context.getString(R.string.confirm_installation)
} else {
context.getString(R.string.confirm_multiple_installation, zipFiles.size)
},
style = MaterialTheme.typography.headlineSmall
)
}
},
text = {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(zipFiles.size) { index ->
val zipFile = zipFiles[index]
InstallItemCard(zipFile = zipFile)
}
}
},
confirmButton = {
Button(
onClick = { onConfirm(zipFiles) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.GetApp,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(context.getString(R.string.install_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(
context.getString(android.R.string.cancel),
color = MaterialTheme.colorScheme.onSurface
)
}
},
modifier = Modifier.widthIn(min = 320.dp, max = 560.dp)
)
}
}
@Composable
fun InstallItemCard(zipFile: ZipFileInfo) {
val context = LocalContext.current
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(
containerColor = when (zipFile.type) {
ZipType.MODULE -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
ZipType.KERNEL -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.surfaceVariant
}
),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = when (zipFile.type) {
ZipType.MODULE -> Icons.Default.Extension
ZipType.KERNEL -> Icons.Default.Memory
else -> Icons.AutoMirrored.Filled.Help
},
contentDescription = null,
tint = when (zipFile.type) {
ZipType.MODULE -> MaterialTheme.colorScheme.primary
ZipType.KERNEL -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = zipFile.name.ifEmpty {
when (zipFile.type) {
ZipType.MODULE -> context.getString(R.string.unknown_module)
ZipType.KERNEL -> context.getString(R.string.unknown_kernel)
else -> context.getString(R.string.unknown_file)
}
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = when (zipFile.type) {
ZipType.MODULE -> context.getString(R.string.module_package)
ZipType.KERNEL -> context.getString(R.string.kernel_package)
else -> context.getString(R.string.unknown_package)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// 详细信息
if (zipFile.version.isNotEmpty() || zipFile.author.isNotEmpty() ||
zipFile.description.isNotEmpty() || zipFile.supported.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
thickness = 0.5.dp
)
Spacer(modifier = Modifier.height(8.dp))
// 版本信息
if (zipFile.version.isNotEmpty()) {
InfoRow(
label = context.getString(R.string.version),
value = zipFile.version + if (zipFile.versionCode.isNotEmpty()) " (${zipFile.versionCode})" else ""
)
}
// 作者信息
if (zipFile.author.isNotEmpty()) {
InfoRow(
label = context.getString(R.string.author),
value = zipFile.author
)
}
// 描述信息 (仅模块)
if (zipFile.description.isNotEmpty() && zipFile.type == ZipType.MODULE) {
InfoRow(
label = context.getString(R.string.description),
value = zipFile.description
)
}
// 支持设备 (仅内核)
if (zipFile.supported.isNotEmpty() && zipFile.type == ZipType.KERNEL) {
InfoRow(
label = context.getString(R.string.supported_devices),
value = zipFile.supported
)
}
}
}
}
}
@Composable
fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = "$label:",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.widthIn(min = 60.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
}
}

View File

@@ -25,4 +25,4 @@ fun KeyEventBlocker(predicate: (KeyEvent) -> Boolean) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
requester.requestFocus() requester.requestFocus()
} }
} }

View File

@@ -2,7 +2,6 @@ package com.sukisu.ultra.ui.component
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.sukisu.ultra.Natives import com.sukisu.ultra.Natives
import com.sukisu.ultra.ksuApp
@Composable @Composable
fun KsuIsValid( fun KsuIsValid(
@@ -14,4 +13,4 @@ fun KsuIsValid(
if (ksuVersion != null) { if (ksuVersion != null) {
content() content()
} }
} }

View File

@@ -0,0 +1,59 @@
package com.sukisu.ultra.ui.component
import android.graphics.text.LineBreaker
import android.os.Build
import android.text.Layout
import android.text.method.LinkMovementMethod
import android.view.ViewGroup
import android.widget.ScrollView
import android.widget.TextView
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import io.noties.markwon.Markwon
import io.noties.markwon.ext.tables.TableAwareMovementMethod
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.movement.MovementMethodPlugin
import io.noties.markwon.utils.NoCopySpannableFactory
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun MarkdownContent(content: String) {
val contentColor = MiuixTheme.colorScheme.onBackground.toArgb()
AndroidView(
factory = { context ->
val scrollView = ScrollView(context)
val textView = TextView(context).apply {
movementMethod = LinkMovementMethod.getInstance()
setSpannableFactory(NoCopySpannableFactory.getInstance())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
}
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
}
scrollView.addView(textView)
scrollView
},
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.clipToBounds(),
update = {
val textView = it.getChildAt(0) as TextView
val markwon = Markwon.builder(textView.context)
.usePlugin(TablePlugin.create(textView.context))
.usePlugin(MovementMethodPlugin.create(TableAwareMovementMethod.create()))
.build()
markwon.setMarkdown(textView, content)
textView.setTextColor(contentColor)
}
)
}

View File

@@ -1,154 +0,0 @@
package com.sukisu.ultra.ui.component
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.ui.theme.CardConfig
private const val TAG = "SearchBar"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchAppBar(
title: @Composable () -> Unit,
searchText: String,
onSearchTextChange: (String) -> Unit,
onClearClick: () -> Unit,
onBackClick: (() -> Unit)? = null,
onConfirm: (() -> Unit)? = null,
dropdownContent: @Composable (() -> Unit)? = null,
scrollBehavior: TopAppBarScrollBehavior? = null
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
var onSearch by remember { mutableStateOf(false) }
// 获取卡片颜色和透明度
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
val cardAlpha = CardConfig.cardAlpha
if (onSearch) {
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
DisposableEffect(Unit) {
onDispose {
keyboardController?.hide()
}
}
TopAppBar(
title = {
Box {
AnimatedVisibility(
modifier = Modifier.align(Alignment.CenterStart),
visible = !onSearch,
enter = fadeIn(),
exit = fadeOut(),
content = { title() }
)
AnimatedVisibility(
visible = onSearch,
enter = fadeIn(),
exit = fadeOut()
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp, bottom = 2.dp, end = if (onBackClick != null) 0.dp else 14.dp)
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
if (focusState.isFocused) onSearch = true
Log.d(TAG, "onFocusChanged: $focusState")
},
value = searchText,
onValueChange = onSearchTextChange,
trailingIcon = {
IconButton(
onClick = {
onSearch = false
keyboardController?.hide()
onClearClick()
},
content = { Icon(Icons.Filled.Close, null) }
)
},
maxLines = 1,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
onConfirm?.invoke()
})
)
}
}
},
navigationIcon = {
if (onBackClick != null) {
IconButton(
onClick = onBackClick,
content = { Icon(Icons.AutoMirrored.Outlined.ArrowBack, null) }
)
}
},
actions = {
AnimatedVisibility(
visible = !onSearch
) {
IconButton(
onClick = { onSearch = true },
content = { Icon(Icons.Filled.Search, null) }
)
}
if (dropdownContent != null) {
dropdownContent()
}
},
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
private fun SearchAppBarPreview() {
var searchText by remember { mutableStateOf("") }
SearchAppBar(
title = { Text("Search text") },
searchText = searchText,
onSearchTextChange = { searchText = it },
onClearClick = { searchText = "" }
)
}

View File

@@ -0,0 +1,154 @@
package com.sukisu.ultra.ui.component
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material.icons.rounded.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.sukisu.ultra.BuildConfig
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.getBugreportFile
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Composable
fun SendLogDialog(
showDialog: MutableState<Boolean>,
loadingDialog: LoadingDialogHandle,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val exportBugreportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/gzip")
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
loadingDialog.show()
context.contentResolver.openOutputStream(uri)?.use { output ->
getBugreportFile(context).inputStream().use {
it.copyTo(output)
}
}
loadingDialog.hide()
withContext(Dispatchers.Main) {
Toast.makeText(context, context.getString(R.string.log_saved), Toast.LENGTH_SHORT).show()
}
}
}
SuperDialog(
show = showDialog,
insideMargin = DpSize(0.dp, 0.dp),
onDismissRequest = {
showDialog.value = false
},
content = {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 12.dp),
text = stringResource(R.string.send_log),
fontSize = MiuixTheme.textStyles.title4.fontSize,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = colorScheme.onSurface
)
SuperArrow(
title = stringResource(id = R.string.save_log),
leftAction = {
Icon(
Icons.Rounded.Save,
contentDescription = null,
modifier = Modifier.padding(end = 16.dp),
tint = colorScheme.onSurface
)
},
onClick = {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm")
val current = LocalDateTime.now().format(formatter)
exportBugreportLauncher.launch("KernelSU_bugreport_${current}.tar.gz")
showDialog.value = false
},
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
)
SuperArrow(
title = stringResource(id = R.string.send_log),
leftAction = {
Icon(
Icons.Rounded.Share,
contentDescription = null,
modifier = Modifier.padding(end = 16.dp),
tint = colorScheme.onSurface
)
},
onClick = {
scope.launch {
showDialog.value = false
val bugreport = loadingDialog.withLoading {
withContext(Dispatchers.IO) {
getBugreportFile(context)
}
}
val uri: Uri =
FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
bugreport
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
setDataAndType(uri, "application/gzip")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
context.getString(R.string.send_log)
)
)
}
},
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
)
TextButton(
text = stringResource(id = android.R.string.cancel),
onClick = {
showDialog.value = false
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 24.dp)
.padding(horizontal = 24.dp)
)
}
)
}

View File

@@ -1,106 +0,0 @@
package com.sukisu.ultra.ui.component
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import com.dergoogler.mmrl.ui.component.LabelItem
import com.dergoogler.mmrl.ui.component.text.TextRow
import com.sukisu.ultra.ui.theme.CardConfig
@Composable
fun SwitchItem(
icon: ImageVector? = null,
title: String,
summary: String? = null,
checked: Boolean,
enabled: Boolean = true,
beta: Boolean = false,
onCheckedChange: (Boolean) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val stateAlpha = remember(checked, enabled) { Modifier.alpha(if (enabled) 1f else 0.5f) }
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
ListItem(
modifier = Modifier
.toggleable(
value = checked,
interactionSource = interactionSource,
role = Role.Switch,
enabled = enabled,
indication = LocalIndication.current,
onValueChange = onCheckedChange
),
headlineContent = {
TextRow(
leadingContent = if (beta) {
{
LabelItem(
modifier = Modifier.then(stateAlpha),
text = "Beta"
)
}
} else null
) {
Text(
modifier = Modifier.then(stateAlpha),
text = title,
)
}
},
leadingContent = icon?.let {
{
Icon(
modifier = Modifier.then(stateAlpha),
imageVector = icon,
contentDescription = title
)
}
},
trailingContent = {
Switch(
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
interactionSource = interactionSource
)
},
supportingContent = {
if (summary != null) {
Text(
modifier = Modifier.then(stateAlpha),
text = summary
)
}
}
)
}
}
@Composable
fun RadioItem(
title: String,
selected: Boolean,
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
Text(title)
},
leadingContent = {
RadioButton(selected = selected, onClick = onClick)
}
)
}

View File

@@ -1,250 +0,0 @@
package com.sukisu.ultra.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SuperDropdown(
items: List<String>,
selectedIndex: Int,
title: String,
summary: String? = null,
icon: ImageVector? = null,
enabled: Boolean = true,
showValue: Boolean = true,
maxHeight: Dp? = 400.dp,
colors: SuperDropdownColors = SuperDropdownDefaults.colors(),
leftAction: (@Composable () -> Unit)? = null,
onSelectedIndexChange: (Int) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
val selectedItemText = items.getOrNull(selectedIndex) ?: ""
val itemsNotEmpty = items.isNotEmpty()
val actualEnabled = enabled && itemsNotEmpty
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = actualEnabled) { showDialog = true }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.Top
) {
if (leftAction != null) {
leftAction()
} else if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (actualEnabled) colors.iconColor else colors.disabledIconColor,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = if (actualEnabled) colors.titleColor else colors.disabledTitleColor
)
if (summary != null) {
Spacer(modifier = Modifier.height(3.dp))
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = if (actualEnabled) colors.summaryColor else colors.disabledSummaryColor
)
}
if (showValue && itemsNotEmpty) {
Spacer(modifier = Modifier.height(3.dp))
Text(
text = selectedItemText,
style = MaterialTheme.typography.bodyMedium,
color = if (actualEnabled) colors.valueColor else colors.disabledValueColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = if (actualEnabled) colors.arrowColor else colors.disabledArrowColor,
modifier = Modifier.size(24.dp)
)
}
if (showDialog && itemsNotEmpty) {
AlertDialog(
onDismissRequest = { showDialog = false },
title = {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall
)
},
text = {
val dialogMaxHeight = maxHeight ?: 400.dp
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = dialogMaxHeight),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(items.size) { index ->
DropdownItem(
text = items[index],
isSelected = selectedIndex == index,
colors = colors,
onClick = {
onSelectedIndexChange(index)
showDialog = false
}
)
}
}
},
confirmButton = {
TextButton(onClick = { showDialog = false }) {
Text(text = stringResource(id = android.R.string.cancel))
}
},
containerColor = colors.dialogBackgroundColor,
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 4.dp
)
}
}
@Composable
private fun DropdownItem(
text: String,
isSelected: Boolean,
colors: SuperDropdownColors,
onClick: () -> Unit
) {
val backgroundColor = if (isSelected) {
colors.selectedBackgroundColor
} else {
Color.Transparent
}
val contentColor = if (isSelected) {
colors.selectedContentColor
} else {
colors.contentColor
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(vertical = 12.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = isSelected,
onClick = null,
colors = RadioButtonDefaults.colors(
selectedColor = colors.selectedContentColor,
unselectedColor = colors.contentColor
)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = colors.selectedContentColor,
modifier = Modifier.size(20.dp)
)
}
}
}
@Immutable
data class SuperDropdownColors(
val titleColor: Color,
val summaryColor: Color,
val valueColor: Color,
val iconColor: Color,
val arrowColor: Color,
val disabledTitleColor: Color,
val disabledSummaryColor: Color,
val disabledValueColor: Color,
val disabledIconColor: Color,
val disabledArrowColor: Color,
val dialogBackgroundColor: Color,
val contentColor: Color,
val selectedContentColor: Color,
val selectedBackgroundColor: Color
)
object SuperDropdownDefaults {
@Composable
fun colors(
titleColor: Color = MaterialTheme.colorScheme.onSurface,
summaryColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
valueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
iconColor: Color = MaterialTheme.colorScheme.primary,
arrowColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
disabledTitleColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
disabledSummaryColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
disabledValueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
disabledIconColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
disabledArrowColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
dialogBackgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
selectedContentColor: Color = MaterialTheme.colorScheme.primary,
selectedBackgroundColor: Color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
): SuperDropdownColors {
return SuperDropdownColors(
titleColor = titleColor,
summaryColor = summaryColor,
valueColor = valueColor,
iconColor = iconColor,
arrowColor = arrowColor,
disabledTitleColor = disabledTitleColor,
disabledSummaryColor = disabledSummaryColor,
disabledValueColor = disabledValueColor,
disabledIconColor = disabledIconColor,
disabledArrowColor = disabledArrowColor,
dialogBackgroundColor = dialogBackgroundColor,
contentColor = contentColor,
selectedContentColor = selectedContentColor,
selectedBackgroundColor = selectedBackgroundColor
)
}
}

View File

@@ -0,0 +1,130 @@
package com.sukisu.ultra.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.filter.FilterNumber
import top.yukonga.miuix.kmp.basic.BasicComponentColors
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.RightActionColors
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperArrowDefaults
import top.yukonga.miuix.kmp.extra.SuperDialog
@Composable
fun SuperEditArrow(
modifier: Modifier = Modifier,
title: String,
titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(),
defaultValue: Int = -1,
summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(),
leftAction: @Composable (() -> Unit)? = null,
insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin,
enabled: Boolean = true,
onValueChange: ((Int) -> Unit)? = null
) {
val showDialog = remember { mutableStateOf(false) }
val dialogTextFieldValue = remember { mutableIntStateOf(defaultValue) }
SuperArrow(
title = title,
titleColor = titleColor,
summary = dialogTextFieldValue.intValue.toString(),
summaryColor = summaryColor,
leftAction = leftAction,
modifier = modifier,
insideMargin = insideMargin,
onClick = {
showDialog.value = true
},
holdDownState = showDialog.value,
enabled = enabled
)
EditDialog(
title,
showDialog,
dialogTextFieldValue = dialogTextFieldValue.intValue,
) {
dialogTextFieldValue.intValue = it
onValueChange?.invoke(dialogTextFieldValue.intValue)
}
}
@Composable
private fun EditDialog(
title: String,
showDialog: MutableState<Boolean>,
dialogTextFieldValue: Int,
onValueChange: (Int) -> Unit,
) {
val inputTextFieldValue = remember { mutableIntStateOf(dialogTextFieldValue) }
val filter = remember(key1 = inputTextFieldValue.intValue) { FilterNumber(dialogTextFieldValue) }
SuperDialog(
title = title,
show = showDialog,
onDismissRequest = {
showDialog.value = false
filter.setInputValue(dialogTextFieldValue.toString())
}
) {
TextField(
modifier = Modifier.padding(bottom = 16.dp),
value = filter.getInputValue(),
maxLines = 1,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
onValueChange = filter.onValueChange()
)
Row(
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = {
showDialog.value = false
filter.setInputValue(dialogTextFieldValue.toString())
},
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(20.dp))
TextButton(
text = stringResource(R.string.confirm),
onClick = {
showDialog.value = false
with(filter.getInputValue().text) {
if (isEmpty()) {
onValueChange(0)
filter.setInputValue("0")
} else {
onValueChange(this@with.toInt())
}
}
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
}
}

View File

@@ -0,0 +1,408 @@
package com.sukisu.ultra.ui.component
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.hazeEffect
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.InputField
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.basic.Search
import top.yukonga.miuix.kmp.icon.icons.basic.SearchCleanup
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.overScrollVertical
// Search Status Class
@Stable
class SearchStatus(val label: String) {
var searchText by mutableStateOf("")
var current by mutableStateOf(Status.COLLAPSED)
var offsetY by mutableStateOf(0.dp)
var resultStatus by mutableStateOf(ResultStatus.DEFAULT)
fun isExpand() = current == Status.EXPANDED
fun isCollapsed() = current == Status.COLLAPSED
fun shouldExpand() = current == Status.EXPANDED || current == Status.EXPANDING
fun shouldCollapsed() = current == Status.COLLAPSED || current == Status.COLLAPSING
fun isAnimatingExpand() = current == Status.EXPANDING
// 动画完成回调
fun onAnimationComplete() {
current = when (current) {
Status.EXPANDING -> Status.EXPANDED
Status.COLLAPSING -> {
searchText = ""
Status.COLLAPSED
}
else -> current
}
}
@Composable
fun TopAppBarAnim(
modifier: Modifier = Modifier,
visible: Boolean = shouldCollapsed(),
hazeState: HazeState? = null,
hazeStyle: HazeStyle? = null,
content: @Composable () -> Unit
) {
val topAppBarAlpha = animateFloatAsState(
if (visible) 1f else 0f,
animationSpec = tween(if (visible) 550 else 0, easing = FastOutSlowInEasing),
)
Box(modifier = modifier) {
Box(
modifier = Modifier
.matchParentSize()
.then(
if (hazeState != null && hazeStyle != null) {
Modifier.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
}
} else {
Modifier.background(colorScheme.surface)
}
)
)
Box(
modifier = Modifier
.alpha(topAppBarAlpha.value)
) { content() }
}
}
enum class Status { EXPANDED, EXPANDING, COLLAPSED, COLLAPSING }
enum class ResultStatus { DEFAULT, EMPTY, LOAD, SHOW }
}
// Search Box Composable
@Composable
fun SearchStatus.SearchBox(
collapseBar: @Composable (SearchStatus, Dp, PaddingValues) -> Unit = { searchStatus, topPadding, innerPadding ->
SearchBarFake(searchStatus.label, topPadding, innerPadding)
},
searchBarTopPadding: Dp = 12.dp,
contentPadding: PaddingValues = PaddingValues(0.dp),
hazeState: HazeState,
hazeStyle: HazeStyle,
content: @Composable (MutableState<Dp>) -> Unit
) {
val searchStatus = this
val density = LocalDensity.current
animateFloatAsState(if (searchStatus.shouldCollapsed()) 1f else 0f)
val offsetY = remember { mutableIntStateOf(0) }
val boxHeight = remember { mutableStateOf(0.dp) }
Box(
modifier = Modifier
.fillMaxWidth()
.zIndex(10f)
.alpha(if (searchStatus.isCollapsed()) 1f else 0f)
.offset(y = contentPadding.calculateTopPadding())
.onGloballyPositioned {
it.positionInWindow().y.apply {
offsetY.intValue = (this@apply * 0.9).toInt()
with(density) {
searchStatus.offsetY = this@apply.toDp()
boxHeight.value = it.size.height.toDp()
}
}
}
.pointerInput(Unit) {
detectTapGestures { searchStatus.current = SearchStatus.Status.EXPANDING }
}
.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
}
) {
collapseBar(searchStatus, searchBarTopPadding, contentPadding)
}
Box {
AnimatedVisibility(
visible = searchStatus.shouldCollapsed(),
enter = fadeIn(tween(300, easing = LinearOutSlowInEasing)) + slideInVertically(
tween(
300,
easing = LinearOutSlowInEasing
)
) { -offsetY.intValue },
exit = fadeOut(tween(300, easing = LinearOutSlowInEasing)) + slideOutVertically(
tween(
300,
easing = LinearOutSlowInEasing
)
) { -offsetY.intValue }
) {
content(boxHeight)
}
}
}
// Search Pager Composable
@Composable
fun SearchStatus.SearchPager(
defaultResult: @Composable () -> Unit,
expandBar: @Composable (SearchStatus, Dp) -> Unit = { searchStatus, padding ->
SearchBar(searchStatus, padding)
},
searchBarTopPadding: Dp = 12.dp,
result: LazyListScope.() -> Unit
) {
val searchStatus = this
val systemBarsPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
val topPadding by animateDpAsState(
if (searchStatus.shouldExpand()) systemBarsPadding + 5.dp else searchStatus.offsetY,
animationSpec = tween(300, easing = LinearOutSlowInEasing)
) {
searchStatus.onAnimationComplete()
}
val surfaceAlpha by animateFloatAsState(
if (searchStatus.shouldExpand()) 1f else 0f,
animationSpec = tween(200, easing = FastOutSlowInEasing)
)
Column(
modifier = Modifier
.fillMaxSize()
.zIndex(5f)
.background(colorScheme.surface.copy(alpha = surfaceAlpha))
.semantics { onClick { false } }
.then(
if (!searchStatus.isCollapsed()) Modifier.pointerInput(Unit) { } else Modifier
)
) {
Row(
Modifier
.fillMaxWidth()
.padding(top = topPadding)
.then(
if (!searchStatus.isCollapsed()) Modifier.background(colorScheme.surface)
else Modifier
),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
if (!searchStatus.isCollapsed()) {
Box(
modifier = Modifier
.weight(1f)
.background(colorScheme.surface)
) {
expandBar(searchStatus, searchBarTopPadding)
}
}
AnimatedVisibility(
visible = searchStatus.isExpand() || searchStatus.isAnimatingExpand(),
enter = expandHorizontally() + slideInHorizontally(initialOffsetX = { it }),
exit = shrinkHorizontally() + slideOutHorizontally(targetOffsetX = { it })
) {
Text(
text = stringResource(android.R.string.cancel),
fontWeight = FontWeight.Bold,
color = colorScheme.primary,
modifier = Modifier
.padding(start = 4.dp, end = 16.dp, top = searchBarTopPadding)
.clickable(
interactionSource = null,
enabled = searchStatus.isExpand(),
indication = null
) { searchStatus.current = SearchStatus.Status.COLLAPSING }
)
BackHandler(enabled = true) {
searchStatus.current = SearchStatus.Status.COLLAPSING
}
}
}
AnimatedVisibility(
visible = searchStatus.isExpand(),
modifier = Modifier
.fillMaxSize()
.zIndex(1f),
enter = fadeIn(),
exit = fadeOut()
) {
when (searchStatus.resultStatus) {
SearchStatus.ResultStatus.DEFAULT -> defaultResult()
SearchStatus.ResultStatus.EMPTY -> {}
SearchStatus.ResultStatus.LOAD -> {}
SearchStatus.ResultStatus.SHOW -> LazyColumn(
Modifier
.fillMaxSize()
.overScrollVertical(),
) {
result()
}
}
}
}
}
@Composable
fun SearchBar(
searchStatus: SearchStatus,
searchBarTopPadding: Dp = 12.dp,
) {
val focusRequester = remember { FocusRequester() }
var expanded by rememberSaveable { mutableStateOf(false) }
InputField(
query = searchStatus.searchText,
onQueryChange = { searchStatus.searchText = it },
label = "",
leadingIcon = {
Icon(
imageVector = MiuixIcons.Basic.Search,
contentDescription = "back",
modifier = Modifier
.size(44.dp)
.padding(start = 16.dp, end = 8.dp),
tint = colorScheme.onSurfaceContainerHigh,
)
},
trailingIcon = {
AnimatedVisibility(
searchStatus.searchText.isNotEmpty(),
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
) {
Icon(
imageVector = MiuixIcons.Basic.SearchCleanup,
tint = colorScheme.onSurface,
contentDescription = "Clean",
modifier = Modifier
.size(44.dp)
.padding(start = 8.dp, end = 16.dp)
.clickable(
interactionSource = null,
indication = null
) {
searchStatus.searchText = ""
},
)
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = searchBarTopPadding, bottom = 6.dp)
.focusRequester(focusRequester),
onSearch = { it },
expanded = searchStatus.shouldExpand(),
onExpandedChange = {
searchStatus.current = if (it) SearchStatus.Status.EXPANDED else SearchStatus.Status.COLLAPSED
}
)
LaunchedEffect(Unit) {
if (!expanded && searchStatus.shouldExpand()) {
focusRequester.requestFocus()
expanded = true
}
}
}
@Composable
fun SearchBarFake(
label: String,
searchBarTopPadding: Dp = 12.dp,
innerPadding: PaddingValues = PaddingValues(0.dp)
) {
val layoutDirection = LocalLayoutDirection.current
InputField(
query = "",
onQueryChange = { },
label = label,
leadingIcon = {
Icon(
imageVector = MiuixIcons.Basic.Search,
contentDescription = "Clean",
modifier = Modifier
.size(44.dp)
.padding(start = 16.dp, end = 8.dp),
tint = colorScheme.onSurfaceContainerHigh,
)
},
modifier = Modifier
.padding(horizontal = 12.dp)
.padding(
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateEndPadding(layoutDirection)
)
.padding(top = searchBarTopPadding, bottom = 6.dp),
onSearch = { },
enabled = false,
expanded = false,
onExpandedChange = { }
)
}

View File

@@ -0,0 +1,140 @@
package com.sukisu.ultra.ui.component
import android.widget.Toast
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.screen.FlashIt
import com.sukisu.ultra.ui.screen.UninstallType
import com.sukisu.ultra.ui.screen.UninstallType.NONE
import com.sukisu.ultra.ui.screen.UninstallType.PERMANENT
import com.sukisu.ultra.ui.screen.UninstallType.RESTORE_STOCK_IMAGE
import com.sukisu.ultra.ui.screen.UninstallType.TEMPORARY
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun UninstallDialog(
showDialog: MutableState<Boolean>,
navigator: DestinationsNavigator,
) {
val context = LocalContext.current
val options = listOf(
// TEMPORARY,
PERMANENT,
RESTORE_STOCK_IMAGE
)
val showTodo = {
Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show()
}
val showConfirmDialog = remember(showDialog.value) { mutableStateOf(false) }
val runType = remember(showDialog.value) { mutableStateOf<UninstallType?>(null) }
val run = { type: UninstallType ->
when (type) {
PERMANENT -> navigator.navigate(FlashScreenDestination(FlashIt.FlashUninstall)) {
popUpTo(FlashScreenDestination) {
inclusive = true
}
launchSingleTop = true
}
RESTORE_STOCK_IMAGE -> navigator.navigate(FlashScreenDestination(FlashIt.FlashRestore)) {
popUpTo(FlashScreenDestination) {
inclusive = true
}
launchSingleTop = true
}
TEMPORARY -> showTodo()
NONE -> Unit
}
}
SuperDialog(
show = showDialog,
insideMargin = DpSize(0.dp, 0.dp),
onDismissRequest = {
showDialog.value = false
},
content = {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 12.dp),
text = stringResource(R.string.uninstall),
fontSize = MiuixTheme.textStyles.title4.fontSize,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = MiuixTheme.colorScheme.onSurface
)
options.forEachIndexed { index, type ->
SuperArrow(
onClick = {
showConfirmDialog.value = true
runType.value = type
},
title = stringResource(type.title),
leftAction = {
Icon(
imageVector = type.icon,
contentDescription = null,
modifier = Modifier.padding(end = 16.dp),
tint = MiuixTheme.colorScheme.onSurface
)
},
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
)
}
TextButton(
text = stringResource(id = android.R.string.cancel),
onClick = {
showDialog.value = false
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 24.dp)
.padding(horizontal = 24.dp)
)
}
)
val confirmDialog = rememberConfirmDialog(
onConfirm = {
showConfirmDialog.value = false
showDialog.value = false
runType.value?.let { type ->
run(type)
}
},
onDismiss = {
showConfirmDialog.value = false
}
)
val dialogTitle = runType.value?.let { type ->
options.find { it == type }?.let { stringResource(it.title) }
} ?: ""
val dialogContent = runType.value?.let { type ->
options.find { it == type }?.let { stringResource(it.message) }
}
if (showConfirmDialog.value) {
confirmDialog.showConfirm(title = dialogTitle, content = dialogContent)
}
}

View File

@@ -1,257 +0,0 @@
package com.sukisu.ultra.ui.component
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
data class FabMenuItem(
val icon: ImageVector,
val labelRes: Int,
val color: Color = Color.Unspecified,
val onClick: () -> Unit
)
object FabAnimationConfig {
const val ANIMATION_DURATION = 300
const val STAGGER_DELAY = 50
val BUTTON_SPACING = 72.dp
val BUTTON_SIZE = 56.dp
val SMALL_BUTTON_SIZE = 48.dp
}
@Composable
fun VerticalExpandableFab(
menuItems: List<FabMenuItem>,
modifier: Modifier = Modifier,
buttonSize: Dp = FabAnimationConfig.BUTTON_SIZE,
smallButtonSize: Dp = FabAnimationConfig.SMALL_BUTTON_SIZE,
buttonSpacing: Dp = FabAnimationConfig.BUTTON_SPACING,
animationDurationMs: Int = FabAnimationConfig.ANIMATION_DURATION,
staggerDelayMs: Int = FabAnimationConfig.STAGGER_DELAY,
mainButtonIcon: ImageVector = Icons.Filled.Add,
mainButtonExpandedIcon: ImageVector = Icons.Filled.Close,
onMainButtonClick: (() -> Unit)? = null,
) {
var isExpanded by remember { mutableStateOf(false) }
val rotationAngle by animateFloatAsState(
targetValue = if (isExpanded) 45f else 0f,
animationSpec = tween(animationDurationMs, easing = FastOutSlowInEasing),
label = "mainButtonRotation"
)
val mainButtonScale by animateFloatAsState(
targetValue = if (isExpanded) 1.1f else 1f,
animationSpec = tween(animationDurationMs, easing = FastOutSlowInEasing),
label = "mainButtonScale"
)
Box(
modifier = modifier.wrapContentSize(),
contentAlignment = Alignment.BottomEnd
) {
menuItems.forEachIndexed { index, menuItem ->
val animatedOffsetY by animateFloatAsState(
targetValue = if (isExpanded) -(buttonSpacing.value * (index + 1)) else 0f,
animationSpec = tween(
durationMillis = animationDurationMs,
delayMillis = if (isExpanded) {
index * staggerDelayMs
} else {
(menuItems.size - index - 1) * staggerDelayMs
},
easing = FastOutSlowInEasing
),
label = "fabOffset$index"
)
val animatedScale by animateFloatAsState(
targetValue = if (isExpanded) 1f else 0f,
animationSpec = tween(
durationMillis = animationDurationMs,
delayMillis = if (isExpanded) {
index * staggerDelayMs + 100
} else {
(menuItems.size - index - 1) * staggerDelayMs
},
easing = FastOutSlowInEasing
),
label = "fabScale$index"
)
val animatedAlpha by animateFloatAsState(
targetValue = if (isExpanded) 1f else 0f,
animationSpec = tween(
durationMillis = animationDurationMs,
delayMillis = if (isExpanded) {
index * staggerDelayMs + 150
} else {
(menuItems.size - index - 1) * staggerDelayMs
},
easing = FastOutSlowInEasing
),
label = "fabAlpha$index"
)
Row(
modifier = Modifier
.offset(y = animatedOffsetY.dp)
.scale(animatedScale)
.alpha(animatedAlpha),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
AnimatedVisibility(
visible = isExpanded && animatedScale > 0.5f,
enter = slideInHorizontally(
initialOffsetX = { it / 2 },
animationSpec = tween(200)
) + fadeIn(animationSpec = tween(200)),
exit = slideOutHorizontally(
targetOffsetX = { it / 2 },
animationSpec = tween(150)
) + fadeOut(animationSpec = tween(150))
) {
Surface(
modifier = Modifier.padding(end = 16.dp),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.inverseSurface,
tonalElevation = 6.dp
) {
Text(
text = stringResource(menuItem.labelRes),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
SmallFloatingActionButton(
onClick = {
menuItem.onClick()
isExpanded = false
},
modifier = Modifier.size(smallButtonSize),
containerColor = if (menuItem.color != Color.Unspecified) {
menuItem.color
} else {
MaterialTheme.colorScheme.secondary
},
contentColor = if (menuItem.color != Color.Unspecified) {
if (menuItem.color == Color.Gray) Color.White
else MaterialTheme.colorScheme.onSecondary
} else {
MaterialTheme.colorScheme.onSecondary
},
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 4.dp,
pressedElevation = 6.dp
)
) {
Icon(
imageVector = menuItem.icon,
contentDescription = stringResource(menuItem.labelRes),
modifier = Modifier.size(20.dp)
)
}
}
}
FloatingActionButton(
onClick = {
onMainButtonClick?.invoke()
isExpanded = !isExpanded
},
modifier = Modifier.size(buttonSize).scale(mainButtonScale),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 6.dp,
pressedElevation = 8.dp,
hoveredElevation = 8.dp
)
) {
Icon(
imageVector = if (isExpanded) mainButtonExpandedIcon else mainButtonIcon,
contentDescription = stringResource(
if (isExpanded) R.string.collapse_menu else R.string.expand_menu
),
modifier = Modifier
.size(24.dp)
.rotate(if (mainButtonIcon == Icons.Filled.Add) rotationAngle else 0f)
)
}
}
}
object FabMenuPresets {
fun getScrollMenuItems(
onScrollToTop: () -> Unit,
onScrollToBottom: () -> Unit
) = listOf(
FabMenuItem(
icon = Icons.Filled.KeyboardArrowDown,
labelRes = R.string.scroll_to_bottom,
onClick = onScrollToBottom
),
FabMenuItem(
icon = Icons.Filled.KeyboardArrowUp,
labelRes = R.string.scroll_to_top,
onClick = onScrollToTop
)
)
@Composable
fun getBatchActionMenuItems(
onCancel: () -> Unit,
onDeny: () -> Unit,
onAllow: () -> Unit,
onUnmountModules: () -> Unit,
onDisableUnmount: () -> Unit
) = listOf(
FabMenuItem(
icon = Icons.Filled.Close,
labelRes = R.string.cancel,
color = Color.Gray,
onClick = onCancel
),
FabMenuItem(
icon = Icons.Filled.Block,
labelRes = R.string.deny_authorization,
color = MaterialTheme.colorScheme.error,
onClick = onDeny
),
FabMenuItem(
icon = Icons.Filled.Check,
labelRes = R.string.grant_authorization,
color = MaterialTheme.colorScheme.primary,
onClick = onAllow
),
FabMenuItem(
icon = Icons.Filled.FolderOff,
labelRes = R.string.unmount_modules,
onClick = onUnmountModules
),
FabMenuItem(
icon = Icons.Filled.Folder,
labelRes = R.string.disable_unmount,
onClick = onDisableUnmount
)
)
}

View File

@@ -0,0 +1,51 @@
package com.sukisu.ultra.ui.component.filter
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
open class BaseFieldFilter() {
private var inputValue = mutableStateOf(TextFieldValue())
constructor(value: String) : this() {
inputValue.value = TextFieldValue(value, TextRange(value.lastIndex + 1))
}
protected open fun onFilter(inputTextFieldValue: TextFieldValue, lastTextFieldValue: TextFieldValue): TextFieldValue {
return TextFieldValue()
}
protected open fun computePos(): Int {
// TODO
return 0
}
protected fun getNewTextRange(
lastTextFiled: TextFieldValue,
inputTextFieldValue: TextFieldValue
): TextRange? {
return null
}
protected fun getNewText(
lastTextFiled: TextFieldValue,
inputTextFieldValue: TextFieldValue
): TextRange? {
return null
}
fun setInputValue(value: String) {
inputValue.value = TextFieldValue(value, TextRange(value.lastIndex + 1))
}
fun getInputValue(): TextFieldValue {
return inputValue.value
}
fun onValueChange(): (TextFieldValue) -> Unit {
return {
inputValue.value = onFilter(it, inputValue.value)
}
}
}

View File

@@ -0,0 +1,82 @@
package com.sukisu.ultra.ui.component.filter
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
class FilterNumber(
private val value: Int,
private val minValue: Int = Int.MIN_VALUE,
private val maxValue: Int = Int.MAX_VALUE,
) : BaseFieldFilter(value.toString()) {
override fun onFilter(
inputTextFieldValue: TextFieldValue,
lastTextFieldValue: TextFieldValue
): TextFieldValue {
return filterInputNumber(inputTextFieldValue, lastTextFieldValue, minValue, maxValue)
}
private fun filterInputNumber(
inputTextFieldValue: TextFieldValue,
lastInputTextFieldValue: TextFieldValue,
minValue: Int = Int.MIN_VALUE,
maxValue: Int = Int.MAX_VALUE,
): TextFieldValue {
val inputString = inputTextFieldValue.text
lastInputTextFieldValue.text
val newString = StringBuilder()
val supportNegative = minValue < 0
var isNegative = false
// 只允许负号在首位,并且只允许一个负号
if (supportNegative && inputString.isNotEmpty() && inputString.first() == '-') {
isNegative = true
newString.append('-')
}
for ((i, c) in inputString.withIndex()) {
if (i == 0 && isNegative) continue // 首字符已经处理
when (c) {
in '0'..'9' -> {
newString.append(c)
// 检查是否超出范围
val tempText = newString.toString()
// 只在不是单独 '-' 时做判断(因为 '-' toInt 会异常)
if (tempText != "-" && tempText.isNotEmpty()) {
try {
val tempValue = tempText.toInt()
if (tempValue > maxValue || tempValue < minValue) {
newString.deleteCharAt(newString.lastIndex)
}
} catch (e: NumberFormatException) {
// 超出int范围
newString.deleteCharAt(newString.lastIndex)
}
}
}
// 忽略其他字符(包括点号)
}
}
val textRange: TextRange
if (inputTextFieldValue.selection.collapsed) { // 表示的是光标范围
if (inputTextFieldValue.selection.end != inputTextFieldValue.text.length) { // 光标没有指向末尾
var newPosition = inputTextFieldValue.selection.end + (newString.length - inputString.length)
if (newPosition < 0) {
newPosition = inputTextFieldValue.selection.end
}
textRange = TextRange(newPosition)
} else { // 光标指向了末尾
textRange = TextRange(newString.length)
}
} else {
textRange = TextRange(newString.length)
}
return lastInputTextFieldValue.copy(
text = newString.toString(),
selection = textRange
)
}
}

View File

@@ -1,15 +1,18 @@
package com.sukisu.ultra.ui.component.profile package com.sukisu.ultra.ui.component.profile
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.material3.OutlinedTextField import androidx.compose.runtime.Composable
import androidx.compose.material3.Text import androidx.compose.runtime.getValue
import androidx.compose.runtime.* import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.sukisu.ultra.Natives import com.sukisu.ultra.Natives
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.SwitchItem import com.sukisu.ultra.ui.component.EditText
import top.yukonga.miuix.kmp.extra.SuperSwitch
@Composable @Composable
fun AppProfileConfig( fun AppProfileConfig(
@@ -21,13 +24,15 @@ fun AppProfileConfig(
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
if (!fixedName) { if (!fixedName) {
OutlinedTextField( EditText(
label = { Text(stringResource(R.string.profile_name)) }, title = stringResource(R.string.profile_name),
value = profile.name, textValue = remember { mutableStateOf(profile.name) },
onValueChange = { onProfileChange(profile.copy(name = it)) } onTextValueChange = { onProfileChange(profile.copy(name = it)) },
enabled = enabled,
) )
} }
SwitchItem(
SuperSwitch(
title = stringResource(R.string.profile_umount_modules), title = stringResource(R.string.profile_umount_modules),
summary = stringResource(R.string.profile_umount_modules_summary), summary = stringResource(R.string.profile_umount_modules_summary),
checked = if (enabled) { checked = if (enabled) {

View File

@@ -1,34 +1,46 @@
package com.sukisu.ultra.ui.component.profile package com.sukisu.ultra.ui.component.profile
import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.* import androidx.compose.runtime.Composable
import androidx.compose.runtime.* import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.text.isDigitsOnly
import com.maxkeppeker.sheets.core.models.base.Header
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
import com.maxkeppeler.sheets.input.InputDialog
import com.maxkeppeler.sheets.input.models.*
import com.maxkeppeler.sheets.list.ListDialog
import com.maxkeppeler.sheets.list.models.ListOption
import com.maxkeppeler.sheets.list.models.ListSelection
import com.sukisu.ultra.Natives import com.sukisu.ultra.Natives
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.profile.Capabilities import com.sukisu.ultra.profile.Capabilities
import com.sukisu.ultra.profile.Groups import com.sukisu.ultra.profile.Groups
import com.sukisu.ultra.ui.component.rememberCustomDialog import com.sukisu.ultra.ui.component.SuperEditArrow
import com.sukisu.ultra.ui.util.isSepolicyValid import com.sukisu.ultra.ui.util.isSepolicyValid
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.CheckboxLocation
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperCheckbox
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RootProfileConfig( fun RootProfileConfig(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -36,94 +48,49 @@ fun RootProfileConfig(
profile: Natives.Profile, profile: Natives.Profile,
onProfileChange: (Natives.Profile) -> Unit, onProfileChange: (Natives.Profile) -> Unit,
) { ) {
Column(modifier = modifier) { Column(
modifier = modifier
) {
if (!fixedName) { if (!fixedName) {
OutlinedTextField( TextField(
label = { Text(stringResource(R.string.profile_name)) }, label = stringResource(R.string.profile_name),
value = profile.name, value = profile.name,
onValueChange = { onProfileChange(profile.copy(name = it)) } onValueChange = { onProfileChange(profile.copy(name = it)) }
) )
} }
/* SuperEditArrow(
var expanded by remember { mutableStateOf(false) } title = "UID",
val currentNamespace = when (profile.namespace) { defaultValue = profile.uid,
Natives.Profile.Namespace.INHERITED.ordinal -> stringResource(R.string.profile_namespace_inherited) ) {
Natives.Profile.Namespace.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global)
Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual)
else -> stringResource(R.string.profile_namespace_inherited)
}
ListItem(headlineContent = {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
readOnly = true,
label = { Text(stringResource(R.string.profile_namespace)) },
value = currentNamespace,
onValueChange = {},
trailingIcon = {
if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
else Icon(Icons.Filled.ArrowDropDown, null)
},
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_inherited)) },
onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal))
expanded = false
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_global)) },
onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal))
expanded = false
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_individual)) },
onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal))
expanded = false
},
)
}
}
})
*/
UidPanel(uid = profile.uid, label = "uid", onUidChange = {
onProfileChange( onProfileChange(
profile.copy( profile.copy(
uid = it, uid = it,
rootUseDefault = false rootUseDefault = false
) )
) )
})
UidPanel(uid = profile.gid, label = "gid", onUidChange = { }
SuperEditArrow(
title = "GID",
defaultValue = profile.gid,
) {
onProfileChange( onProfileChange(
profile.copy( profile.copy(
gid = it, gid = it,
rootUseDefault = false rootUseDefault = false
) )
) )
})
}
val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e -> val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e ->
e.mapNotNull { g -> e.mapNotNull { g ->
Groups.entries.find { it.gid == g } Groups.entries.find { it.gid == g }
} }
} }
GroupsPanel(selectedGroups) { GroupsPanel(selectedGroups) {
onProfileChange( onProfileChange(
profile.copy( profile.copy(
@@ -155,15 +122,15 @@ fun RootProfileConfig(
) )
) )
}) })
} }
} }
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) { fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) {
val selectGroupsDialog = rememberCustomDialog { dismiss: () -> Unit -> val showDialog = remember { mutableStateOf(false) }
val groups = Groups.entries.toTypedArray().sortedWith(
val groups = remember {
Groups.entries.toTypedArray().sortedWith(
compareBy<Groups> { if (selected.contains(it)) 0 else 1 } compareBy<Groups> { if (selected.contains(it)) 0 else 1 }
.then(compareBy { .then(compareBy {
when (it) { when (it) {
@@ -174,308 +141,255 @@ fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>)
} }
}) })
.then(compareBy { it.name }) .then(compareBy { it.name })
) )
val options = groups.map { value ->
ListOption(
titleText = value.display,
subtitleText = value.desc,
selected = selected.contains(value),
)
}
val selection = HashSet(selected)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
ListDialog(
state = rememberUseCaseState(visible = true, onFinishedRequest = {
closeSelection(selection)
}, onCloseRequest = {
dismiss()
}),
header = Header.Default(
title = stringResource(R.string.profile_groups),
),
selection = ListSelection.Multiple(
showCheckBoxes = true,
options = options,
maxChoices = 32, // Kernel only supports 32 groups at most
) { indecies, _ ->
// Handle selection
selection.clear()
indecies.forEach { index ->
val group = groups[index]
selection.add(group)
}
}
)
}
} }
OutlinedCard( val currentSelection = remember { mutableStateOf(selected.toSet()) }
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column( SuperDialog(
modifier = Modifier show = showDialog,
.fillMaxSize() title = stringResource(R.string.profile_groups),
.clickable { summary = "${currentSelection.value.size} / 32",
selectGroupsDialog.show() insideMargin = DpSize(0.dp, 24.dp),
} onDismissRequest = { showDialog.value = false }
.padding(16.dp) ) {
) { Column(modifier = Modifier.heightIn(max = 500.dp)) {
Text(stringResource(R.string.profile_groups)) LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
FlowRow { items(groups) { group ->
selected.forEach { group -> SuperCheckbox(
AssistChip( title = group.display,
modifier = Modifier.padding(3.dp), summary = group.desc,
onClick = { /*TODO*/ }, insideMargin = PaddingValues(horizontal = 30.dp, vertical = 16.dp),
label = { Text(group.display) }) checkboxLocation = CheckboxLocation.Right,
checked = currentSelection.value.contains(group),
holdDownState = currentSelection.value.contains(group),
onCheckedChange = { isChecked ->
val newSelection = currentSelection.value.toMutableSet()
if (isChecked) {
if (newSelection.size < 32) newSelection.add(group)
} else {
newSelection.remove(group)
}
currentSelection.value = newSelection
}
)
} }
} }
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = {
currentSelection.value = selected.toSet()
showDialog.value = false
},
text = stringResource(android.R.string.cancel),
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(20.dp))
TextButton(
onClick = {
closeSelection(currentSelection.value)
showDialog.value = false
},
text = stringResource(R.string.confirm),
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
} }
} }
val tag = if (selected.isEmpty()) {
"None"
} else {
selected.joinToString(separator = ",", transform = { it.display })
}
SuperArrow(
title = stringResource(R.string.profile_groups),
summary = tag,
onClick = {
showDialog.value = true
},
)
} }
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun CapsPanel( fun CapsPanel(
selected: Collection<Capabilities>, selected: Collection<Capabilities>,
closeSelection: (selection: Set<Capabilities>) -> Unit closeSelection: (selection: Set<Capabilities>) -> Unit
) { ) {
val selectCapabilitiesDialog = rememberCustomDialog { dismiss -> val showDialog = remember { mutableStateOf(false) }
val caps = Capabilities.entries.toTypedArray().sortedWith(
val caps = remember {
Capabilities.entries.toTypedArray().sortedWith(
compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 } compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 }
.then(compareBy { it.name }) .then(compareBy { it.name })
) )
val options = caps.map { value -> }
ListOption(
titleText = value.display,
subtitleText = value.desc,
selected = selected.contains(value),
)
}
val selection = HashSet(selected) val currentSelection = remember { mutableStateOf(selected.toSet()) }
MaterialTheme( SuperDialog(
colorScheme = MaterialTheme.colorScheme.copy( show = showDialog,
surface = MaterialTheme.colorScheme.surfaceContainerHigh title = stringResource(R.string.profile_capabilities),
) insideMargin = DpSize(0.dp, 24.dp),
) { onDismissRequest = { showDialog.value = false },
ListDialog( content = {
state = rememberUseCaseState(visible = true, onFinishedRequest = { Column(modifier = Modifier.heightIn(max = 500.dp)) {
closeSelection(selection) LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
}, onCloseRequest = { items(caps) { cap ->
dismiss() SuperCheckbox(
}), title = cap.display,
header = Header.Default( summary = cap.desc,
title = stringResource(R.string.profile_capabilities), insideMargin = PaddingValues(horizontal = 30.dp, vertical = 16.dp),
), checkboxLocation = CheckboxLocation.Right,
selection = ListSelection.Multiple( checked = currentSelection.value.contains(cap),
showCheckBoxes = true, holdDownState = currentSelection.value.contains(cap),
options = options onCheckedChange = { isChecked ->
) { indecies, _ -> val newSelection = currentSelection.value.toMutableSet()
// Handle selection if (isChecked) {
selection.clear() newSelection.add(cap)
indecies.forEach { index -> } else {
val group = caps[index] newSelection.remove(cap)
selection.add(group) }
currentSelection.value = newSelection
}
)
} }
} }
) Spacer(Modifier.height(12.dp))
} Row(
} modifier = Modifier.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween
OutlinedCard( ) {
modifier = Modifier TextButton(
.fillMaxWidth() onClick = {
.padding(16.dp) showDialog.value = false
) { currentSelection.value = selected.toSet()
},
Column( text = stringResource(android.R.string.cancel),
modifier = Modifier modifier = Modifier.weight(1f)
.fillMaxSize() )
.clickable { Spacer(modifier = Modifier.width(20.dp))
selectCapabilitiesDialog.show() TextButton(
} onClick = {
.padding(16.dp) closeSelection(currentSelection.value)
) { showDialog.value = false
Text(stringResource(R.string.profile_capabilities)) },
FlowRow { text = stringResource(R.string.confirm),
selected.forEach { group -> modifier = Modifier.weight(1f),
AssistChip( colors = ButtonDefaults.textButtonColorsPrimary()
modifier = Modifier.padding(3.dp), )
onClick = { /*TODO*/ },
label = { Text(group.display) })
} }
} }
} }
)
val tag = if (selected.isEmpty()) {
"None"
} else {
selected.joinToString(separator = ",", transform = { it.display })
} }
SuperArrow(
title = stringResource(R.string.profile_capabilities),
summary = tag,
onClick = {
showDialog.value = true
}
)
} }
@Composable
private fun UidPanel(uid: Int, label: String, onUidChange: (Int) -> Unit) {
ListItem(headlineContent = {
var isError by remember {
mutableStateOf(false)
}
var lastValidUid by remember {
mutableIntStateOf(uid)
}
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
label = { Text(label) },
value = uid.toString(),
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
onValueChange = {
if (it.isEmpty()) {
onUidChange(0)
return@OutlinedTextField
}
val valid = isTextValidUid(it)
val targetUid = if (valid) it.toInt() else lastValidUid
if (valid) {
lastValidUid = it.toInt()
}
onUidChange(targetUid)
isError = !valid
}
)
})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SELinuxPanel( private fun SELinuxPanel(
profile: Natives.Profile, profile: Natives.Profile,
onSELinuxChange: (domain: String, rules: String) -> Unit onSELinuxChange: (domain: String, rules: String) -> Unit
) { ) {
val editSELinuxDialog = rememberCustomDialog { dismiss -> val showDialog = remember { mutableStateOf(false) }
var domain by remember { mutableStateOf(profile.context) }
var rules by remember { mutableStateOf(profile.rules) }
val inputOptions = listOf( var domain by remember { mutableStateOf(profile.context) }
InputTextField( var rules by remember { mutableStateOf(profile.rules) }
text = domain,
header = InputHeader(
title = stringResource(id = R.string.profile_selinux_domain),
),
type = InputTextFieldType.OUTLINED,
required = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next
),
resultListener = {
domain = it ?: ""
},
validationListener = { value ->
// value can be a-zA-Z0-9_
val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$")
if (value?.matches(regex) == true) ValidationResult.Valid
else ValidationResult.Invalid("Domain must be in the format of \"user:role:type:level\"")
}
),
InputTextField(
text = rules,
header = InputHeader(
title = stringResource(id = R.string.profile_selinux_rules),
),
type = InputTextFieldType.OUTLINED,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
),
singleLine = false,
resultListener = {
rules = it ?: ""
},
validationListener = { value ->
if (isSepolicyValid(value)) ValidationResult.Valid
else ValidationResult.Invalid("SELinux rules is invalid!")
}
)
)
val isDomainValid = remember(domain) {
val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$")
domain.matches(regex)
}
val isRulesValid = remember(rules) { isSepolicyValid(rules) }
MaterialTheme( SuperDialog(
colorScheme = MaterialTheme.colorScheme.copy( show = showDialog,
surface = MaterialTheme.colorScheme.surfaceContainerHigh title = stringResource(R.string.profile_selinux_context),
) onDismissRequest = { showDialog.value = false }
) { ) {
InputDialog( Column(modifier = Modifier.heightIn(max = 500.dp)) {
state = rememberUseCaseState( Column(modifier = Modifier.weight(1f, fill = false)) {
visible = true, TextField(
onFinishedRequest = { value = domain,
onSELinuxChange(domain, rules) onValueChange = { domain = it },
}, modifier = Modifier
onCloseRequest = { .fillMaxWidth()
dismiss() .padding(vertical = 8.dp),
}), label = stringResource(id = R.string.profile_selinux_domain),
header = Header.Default( borderColor = if (isDomainValid) {
title = stringResource(R.string.profile_selinux_context), colorScheme.primary
), } else {
selection = InputSelection( Color.Red.copy(alpha = if (isSystemInDarkTheme()) 0.3f else 0.6f)
input = inputOptions,
onPositiveClick = { result ->
// Handle selection
}, },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next
),
singleLine = true
) )
) TextField(
value = rules,
onValueChange = { rules = it },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
label = stringResource(id = R.string.profile_selinux_rules),
borderColor = if (isRulesValid) {
colorScheme.primary
} else {
Color.Red.copy(alpha = if (isSystemInDarkTheme()) 0.3f else 0.6f)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
),
singleLine = false
)
}
Spacer(Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = { showDialog.value = false },
text = stringResource(android.R.string.cancel),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(20.dp))
TextButton(
onClick = {
onSELinuxChange(domain, rules)
showDialog.value = false
},
text = stringResource(R.string.confirm),
enabled = isDomainValid && isRulesValid,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
} }
} }
ListItem(headlineContent = { SuperArrow(
OutlinedTextField( title = stringResource(R.string.profile_selinux_context),
modifier = Modifier summary = profile.context,
.fillMaxWidth() onClick = { showDialog.value = true }
.clickable { )
editSELinuxDialog.show()
},
enabled = false,
colors = OutlinedTextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledBorderColor = MaterialTheme.colorScheme.outline,
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
),
label = { Text(text = stringResource(R.string.profile_selinux_context)) },
value = profile.context,
onValueChange = { }
)
})
}
@Preview
@Composable
private fun RootProfileConfigPreview() {
var profile by remember { mutableStateOf(Natives.Profile("")) }
RootProfileConfig(fixedName = true, profile = profile) {
profile = it
}
}
private fun isTextValidUid(text: String): Boolean {
return text.isNotEmpty() && text.isDigitsOnly() && text.toInt() >= 0
} }

View File

@@ -1,105 +1,94 @@
package com.sukisu.ultra.ui.component.profile package com.sukisu.ultra.ui.component.profile
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ReadMore import androidx.compose.material.icons.rounded.Create
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.runtime.Composable
import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.runtime.getValue
import androidx.compose.material.icons.filled.Create import androidx.compose.runtime.mutableStateOf
import androidx.compose.material3.* import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.Natives import com.sukisu.ultra.Natives
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.listAppProfileTemplates import com.sukisu.ultra.ui.util.listAppProfileTemplates
import com.sukisu.ultra.ui.util.setSepolicy import com.sukisu.ultra.ui.util.setSepolicy
import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.theme.MiuixTheme
/** /**
* @author weishu * @author weishu
* @date 2023/10/21. * @date 2023/10/21.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TemplateConfig( fun TemplateConfig(
modifier: Modifier = Modifier,
profile: Natives.Profile, profile: Natives.Profile,
onViewTemplate: (id: String) -> Unit = {}, onViewTemplate: (id: String) -> Unit = {},
onManageTemplate: () -> Unit = {}, onManageTemplate: () -> Unit = {},
onProfileChange: (Natives.Profile) -> Unit onProfileChange: (Natives.Profile) -> Unit
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var template by rememberSaveable {
mutableStateOf(profile.rootTemplate ?: "")
}
val profileTemplates = listAppProfileTemplates() val profileTemplates = listAppProfileTemplates()
val noTemplates = profileTemplates.isEmpty() val noTemplates = profileTemplates.isEmpty()
ListItem(headlineContent = { if (noTemplates) {
ExposedDropdownMenuBox( SuperArrow(
expanded = expanded, modifier = modifier,
onExpandedChange = { expanded = it }, title = stringResource(R.string.app_profile_template_create),
) { leftAction = {
OutlinedTextField( Icon(
modifier = Modifier Icons.Rounded.Create,
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) null,
.fillMaxWidth(), modifier = Modifier.padding(end = 16.dp),
readOnly = true, tint = MiuixTheme.colorScheme.onBackground
label = { Text(stringResource(R.string.profile_template)) }, )
value = template.ifEmpty { "None" }, },
onValueChange = {}, onClick = onManageTemplate,
trailingIcon = { )
if (noTemplates) { } else {
IconButton( var template by rememberSaveable { mutableStateOf(profile.rootTemplate ?: profileTemplates[0]) }
onClick = onManageTemplate
) { Column(modifier = modifier) {
Icon(Icons.Filled.Create, null) SuperDropdown(
} title = stringResource(R.string.profile_template),
} else if (expanded) Icon(Icons.Filled.ArrowDropUp, null) items = profileTemplates,
else Icon(Icons.Filled.ArrowDropDown, null) selectedIndex = profileTemplates.indexOf(template).takeIf { it >= 0 } ?: 0,
onSelectedIndexChange = { index ->
if (index < 0 || index >= profileTemplates.size) return@SuperDropdown
template = profileTemplates[index]
val templateInfo = getTemplateInfoById(template)
if (templateInfo != null && setSepolicy(template, templateInfo.rules.joinToString("\n"))) {
onProfileChange(
profile.copy(
rootTemplate = template,
rootUseDefault = false,
uid = templateInfo.uid,
gid = templateInfo.gid,
groups = templateInfo.groups,
capabilities = templateInfo.capabilities,
context = templateInfo.context,
namespace = templateInfo.namespace,
)
)
}
}, },
onClick = {
expanded = !expanded
},
maxHeight = 280.dp
)
SuperArrow(
title = stringResource(R.string.app_profile_template_view),
onClick = { onViewTemplate(template) }
) )
if (profileTemplates.isEmpty()) {
return@ExposedDropdownMenuBox
}
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
profileTemplates.forEach { tid ->
val templateInfo =
getTemplateInfoById(tid) ?: return@forEach
DropdownMenuItem(
text = { Text(tid) },
onClick = {
template = tid
if (setSepolicy(tid, templateInfo.rules.joinToString("\n"))) {
onProfileChange(
profile.copy(
rootTemplate = tid,
rootUseDefault = false,
uid = templateInfo.uid,
gid = templateInfo.gid,
groups = templateInfo.groups,
capabilities = templateInfo.capabilities,
context = templateInfo.context,
namespace = templateInfo.namespace,
)
)
}
expanded = false
},
trailingIcon = {
IconButton(onClick = {
onViewTemplate(tid)
}) {
Icon(Icons.AutoMirrored.Filled.ReadMore, null)
}
}
)
}
}
} }
}) }
} }

View File

@@ -0,0 +1,79 @@
package com.sukisu.ultra.ui.component
import android.content.Context
import android.os.Build
import android.os.PowerManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.screen.RebootDropdownItem
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ListPopup
import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Reboot
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@Composable
fun RebootListPopup(
modifier: Modifier = Modifier,
alignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopRight
) {
val showTopPopup = remember { mutableStateOf(false) }
KsuIsValid {
IconButton(
modifier = modifier,
onClick = { showTopPopup.value = true },
holdDownState = showTopPopup.value
) {
Icon(
imageVector = MiuixIcons.Useful.Reboot,
contentDescription = stringResource(id = R.string.reboot),
tint = colorScheme.onBackground
)
}
ListPopup(
show = showTopPopup,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
alignment = alignment,
onDismissRequest = {
showTopPopup.value = false
}
) {
val pm = LocalContext.current.getSystemService(Context.POWER_SERVICE) as PowerManager?
@Suppress("DEPRECATION")
val isRebootingUserspaceSupported =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && pm?.isRebootingUserspaceSupported == true
ListPopupColumn {
val rebootOptions = mutableListOf(
Pair(R.string.reboot, ""),
Pair(R.string.reboot_recovery, "recovery"),
Pair(R.string.reboot_bootloader, "bootloader"),
Pair(R.string.reboot_download, "download"),
Pair(R.string.reboot_edl, "edl")
)
if (isRebootingUserspaceSupported) {
rebootOptions.add(1, Pair(R.string.reboot_userspace, "userspace"))
}
rebootOptions.forEachIndexed { idx, (id, reason) ->
RebootDropdownItem(
id = id,
reason = reason,
showTopPopup = showTopPopup,
optionSize = rebootOptions.size,
index = idx
)
}
}
}
}
}

View File

@@ -0,0 +1,268 @@
package com.sukisu.ultra.ui.kernelFlash
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Security
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.screen.InstallMethod
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
enum class KpmPatchOption {
FOLLOW_KERNEL,
PATCH_KPM,
UNDO_PATCH_KPM
}
@Stable
data class AnyKernel3State(
val kpmPatchOption: KpmPatchOption,
val showSlotSelectionDialog: Boolean,
val showKpmPatchDialog: Boolean,
val onHorizonKernelSelected: (InstallMethod.HorizonKernel) -> Unit,
val onSlotSelected: (String) -> Unit,
val onDismissSlotDialog: () -> Unit,
val onOptionSelected: (KpmPatchOption) -> Unit,
val onDismissPatchDialog: () -> Unit,
val onReopenSlotDialog: (InstallMethod.HorizonKernel) -> Unit,
val onReopenKpmDialog: (InstallMethod.HorizonKernel) -> Unit
)
@Composable
fun rememberAnyKernel3State(
installMethodState: MutableState<InstallMethod?>,
preselectedKernelUri: String?,
horizonKernelSummary: String,
isAbDevice: Boolean
): AnyKernel3State {
var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) }
var showSlotSelectionDialog by remember { mutableStateOf(false) }
var showKpmPatchDialog by remember { mutableStateOf(false) }
var tempKernelUri by remember { mutableStateOf<Uri?>(null) }
val onHorizonKernelSelected: (InstallMethod.HorizonKernel) -> Unit = { method ->
val uri = method.uri
if (uri != null) {
if (isAbDevice && method.slot == null) {
tempKernelUri = uri
showSlotSelectionDialog = true
} else {
installMethodState.value = method
showKpmPatchDialog = true
}
}
}
val onReopenSlotDialog: (InstallMethod.HorizonKernel) -> Unit = { method ->
val uri = method.uri
if (uri != null && isAbDevice) {
tempKernelUri = uri
showSlotSelectionDialog = true
}
}
val onReopenKpmDialog: (InstallMethod.HorizonKernel) -> Unit = { method ->
installMethodState.value = method
showKpmPatchDialog = true
}
val onSlotSelected: (String) -> Unit = { slot ->
val uri = tempKernelUri ?: (installMethodState.value as? InstallMethod.HorizonKernel)?.uri
if (uri != null) {
installMethodState.value = InstallMethod.HorizonKernel(
uri = uri,
slot = slot,
summary = horizonKernelSummary
)
tempKernelUri = null
showSlotSelectionDialog = false
showKpmPatchDialog = true
}
}
val onDismissSlotDialog = {
showSlotSelectionDialog = false
}
val onOptionSelected: (KpmPatchOption) -> Unit = { option ->
kpmPatchOption = option
showKpmPatchDialog = false
}
val onDismissPatchDialog = {
showKpmPatchDialog = false
}
LaunchedEffect(preselectedKernelUri, isAbDevice, horizonKernelSummary) {
preselectedKernelUri?.let { uriString ->
runCatching { uriString.toUri() }
.getOrNull()
?.let { preselectedUri ->
val method = InstallMethod.HorizonKernel(
uri = preselectedUri,
summary = horizonKernelSummary
)
if (isAbDevice) {
tempKernelUri = preselectedUri
showSlotSelectionDialog = true
} else {
installMethodState.value = method
showKpmPatchDialog = true
}
}
}
}
return AnyKernel3State(
kpmPatchOption = kpmPatchOption,
showSlotSelectionDialog = showSlotSelectionDialog,
showKpmPatchDialog = showKpmPatchDialog,
onHorizonKernelSelected = onHorizonKernelSelected,
onSlotSelected = onSlotSelected,
onDismissSlotDialog = onDismissSlotDialog,
onOptionSelected = onOptionSelected,
onDismissPatchDialog = onDismissPatchDialog,
onReopenSlotDialog = onReopenSlotDialog,
onReopenKpmDialog = onReopenKpmDialog
)
}
@Composable
fun KpmPatchSelectionDialog(
show: Boolean,
currentOption: KpmPatchOption,
onDismiss: () -> Unit,
onOptionSelected: (KpmPatchOption) -> Unit
) {
var selectedOption by remember { mutableStateOf(currentOption) }
val showDialog = remember { mutableStateOf(show) }
LaunchedEffect(show) {
showDialog.value = show
if (show) {
selectedOption = currentOption
}
}
SuperDialog(
show = showDialog,
insideMargin = DpSize(0.dp, 0.dp),
onDismissRequest = {
showDialog.value = false
onDismiss()
},
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
text = stringResource(id = R.string.kpm_patch_options),
fontSize = MiuixTheme.textStyles.title4.fontSize,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = colorScheme.onSurface
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
text = stringResource(id = R.string.kpm_patch_description),
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
val options = listOf(
KpmPatchOption.FOLLOW_KERNEL to stringResource(R.string.kpm_follow_kernel_file),
KpmPatchOption.PATCH_KPM to stringResource(R.string.enable_kpm_patch),
KpmPatchOption.UNDO_PATCH_KPM to stringResource(R.string.enable_kpm_undo_patch)
)
options.forEach { (option, title) ->
SuperArrow(
title = title,
onClick = {
selectedOption = option
},
leftAction = {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = null,
tint = if (selectedOption == option) {
colorScheme.primary
} else {
colorScheme.onSurfaceVariantSummary
}
)
},
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = {
showDialog.value = false
onDismiss()
},
modifier = Modifier.weight(1f)
)
TextButton(
text = stringResource(android.R.string.ok),
onClick = {
onOptionSelected(selectedOption)
showDialog.value = false
onDismiss()
},
modifier = Modifier.weight(1f)
)
}
}
}
)
}

View File

@@ -1,50 +1,55 @@
package zako.zako.zako.zakoui.screen.kernelFlash package com.sukisu.ultra.ui.kernelFlash
import android.content.Context import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.KeyEventBlocker import com.sukisu.ultra.ui.component.KeyEventBlocker
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.util.LocalSnackbarHost
import com.sukisu.ultra.ui.util.reboot import com.sukisu.ultra.ui.util.reboot
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import zako.zako.zako.zakoui.screen.kernelFlash.state.FlashState import com.sukisu.ultra.ui.kernelFlash.state.*
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelState import top.yukonga.miuix.kmp.basic.Card
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelWorker import top.yukonga.miuix.kmp.basic.FloatingActionButton
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.LinearProgressIndicator
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Save
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -60,12 +65,17 @@ private object KernelFlashStateHolder {
var currentKpmPatchEnabled: Boolean = false var currentKpmPatchEnabled: Boolean = false
var currentKpmUndoPatch: Boolean = false var currentKpmUndoPatch: Boolean = false
var isFlashing = false var isFlashing = false
fun clear() {
currentState = null
currentUri = null
currentSlot = null
currentKpmPatchEnabled = false
currentKpmUndoPatch = false
isFlashing = false
}
} }
/**
* Kernel刷写界面
*/
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph> @Destination<RootGraph>
@Composable @Composable
fun KernelFlashScreen( fun KernelFlashScreen(
@@ -76,15 +86,7 @@ fun KernelFlashScreen(
kpmUndoPatch: Boolean = false kpmUndoPatch: Boolean = false
) { ) {
val context = LocalContext.current val context = LocalContext.current
val shouldAutoExit = remember {
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.getBoolean("auto_exit_after_flash", false)
}
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val snackBarHost = LocalSnackbarHost.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var logText by rememberSaveable { mutableStateOf("") } var logText by rememberSaveable { mutableStateOf("") }
var showFloatAction by rememberSaveable { mutableStateOf(false) } var showFloatAction by rememberSaveable { mutableStateOf(false) }
@@ -109,19 +111,27 @@ fun KernelFlashScreen(
} }
val flashState by horizonKernelState.state.collectAsState() val flashState by horizonKernelState.state.collectAsState()
val logSavedString = stringResource(R.string.log_saved) val activity = LocalActivity.current
val onFlashComplete = { val onFlashComplete = {
showFloatAction = true showFloatAction = true
KernelFlashStateHolder.isFlashing = false KernelFlashStateHolder.isFlashing = false
}
// 如果是从外部打开的内核刷写延迟1.5秒后自动退出
LaunchedEffect(flashState.isCompleted, flashState.error) {
if (flashState.isCompleted && flashState.error.isEmpty()) {
val intent = activity?.intent
val isFromExternalIntent = intent?.action?.let { action ->
action == Intent.ACTION_VIEW ||
action == Intent.ACTION_SEND ||
action == Intent.ACTION_SEND_MULTIPLE
} ?: false
// 如果需要自动退出延迟1.5秒后退出 if (isFromExternalIntent) {
if (shouldAutoExit) {
scope.launch {
delay(1500) delay(1500)
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE) KernelFlashStateHolder.clear()
sharedPref.edit { remove("auto_exit_after_flash") } activity.finish()
(context as? ComponentActivity)?.finish()
} }
} }
} }
@@ -171,36 +181,30 @@ fun KernelFlashScreen(
val onBack: () -> Unit = { val onBack: () -> Unit = {
if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) { if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) {
// 清理全局状态
if (flashState.isCompleted || flashState.error.isNotEmpty()) { if (flashState.isCompleted || flashState.error.isNotEmpty()) {
KernelFlashStateHolder.currentState = null KernelFlashStateHolder.clear()
KernelFlashStateHolder.currentUri = null
KernelFlashStateHolder.currentSlot = null
KernelFlashStateHolder.currentKpmPatchEnabled = false
KernelFlashStateHolder.currentKpmUndoPatch = false
KernelFlashStateHolder.isFlashing = false
} }
navigator.popBackStack() navigator.popBackStack()
} }
} }
DisposableEffect(shouldAutoExit) { // 清理状态
DisposableEffect(Unit) {
onDispose { onDispose {
if (shouldAutoExit) { if (flashState.isCompleted || flashState.error.isNotEmpty()) {
KernelFlashStateHolder.currentState = null KernelFlashStateHolder.clear()
KernelFlashStateHolder.currentUri = null
KernelFlashStateHolder.currentSlot = null
KernelFlashStateHolder.currentKpmPatchEnabled = false
KernelFlashStateHolder.currentKpmUndoPatch = false
KernelFlashStateHolder.isFlashing = false
} }
} }
} }
BackHandler(enabled = true) { BackHandler {
onBack() onBack()
} }
KeyEventBlocker {
it.key == Key.VolumeDown || it.key == Key.VolumeUp
}
Scaffold( Scaffold(
topBar = { topBar = {
TopBar( TopBar(
@@ -215,15 +219,13 @@ fun KernelFlashScreen(
"KernelSU_kernel_flash_log_${date}.log" "KernelSU_kernel_flash_log_${date}.log"
) )
file.writeText(logContent.toString()) file.writeText(logContent.toString())
snackBarHost.showSnackbar(logSavedString.format(file.absolutePath))
} }
}, }
scrollBehavior = scrollBehavior
) )
}, },
floatingActionButton = { floatingActionButton = {
if (showFloatAction) { if (showFloatAction) {
ExtendedFloatingActionButton( FloatingActionButton(
onClick = { onClick = {
scope.launch { scope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -231,34 +233,22 @@ fun KernelFlashScreen(
} }
} }
}, },
icon = { modifier = Modifier.padding(bottom = 20.dp, end = 20.dp)
Icon( ) {
Icons.Filled.Refresh, Icon(
contentDescription = stringResource(id = R.string.reboot) Icons.Rounded.Refresh,
) contentDescription = stringResource(id = R.string.reboot)
}, )
text = { }
Text(text = stringResource(id = R.string.reboot))
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
expanded = true
)
} }
}, },
snackbarHost = { SnackbarHost(hostState = snackBarHost) }, popupHost = { }
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), ) {
containerColor = MaterialTheme.colorScheme.background
) { innerPadding ->
KeyEventBlocker {
it.key == Key.VolumeDown || it.key == Key.VolumeUp
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(it)
.nestedScroll(scrollBehavior.nestedScrollConnection), .scrollEndHaptic(),
) { ) {
FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch) FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch)
Box( Box(
@@ -273,9 +263,8 @@ fun KernelFlashScreen(
Text( Text(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
text = logText, text = logText,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface color = colorScheme.onSurface
) )
} }
} }
@@ -288,24 +277,21 @@ private fun FlashProgressIndicator(
kpmPatchEnabled: Boolean = false, kpmPatchEnabled: Boolean = false,
kpmUndoPatch: Boolean = false kpmUndoPatch: Boolean = false
) { ) {
val progressColor = when { val statusColor = when {
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error flashState.error.isNotEmpty() -> colorScheme.error
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary flashState.isCompleted -> colorScheme.primary
else -> MaterialTheme.colorScheme.primary else -> colorScheme.primary
} }
val progress = animateFloatAsState( val progress = animateFloatAsState(
targetValue = flashState.progress, targetValue = flashState.progress.coerceIn(0f, 1f),
label = "FlashProgress" label = "FlashProgress"
) )
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(horizontal = 16.dp, vertical = 12.dp)
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -323,9 +309,9 @@ private fun FlashProgressIndicator(
flashState.isCompleted -> stringResource(R.string.flash_success) flashState.isCompleted -> stringResource(R.string.flash_success)
else -> stringResource(R.string.flashing) else -> stringResource(R.string.flashing)
}, },
style = MaterialTheme.typography.titleMedium, fontSize = MiuixTheme.textStyles.title4.fontSize,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Medium,
color = progressColor color = statusColor
) )
when { when {
@@ -333,14 +319,14 @@ private fun FlashProgressIndicator(
Icon( Icon(
imageVector = Icons.Default.Error, imageVector = Icons.Default.Error,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.error tint = colorScheme.error
) )
} }
flashState.isCompleted -> { flashState.isCompleted -> {
Icon( Icon(
imageVector = Icons.Default.CheckCircle, imageVector = Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary tint = colorScheme.primary
) )
} }
} }
@@ -348,128 +334,87 @@ private fun FlashProgressIndicator(
// KPM状态显示 // KPM状态显示
if (kpmPatchEnabled || kpmUndoPatch) { if (kpmPatchEnabled || kpmUndoPatch) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode) text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode)
else stringResource(R.string.kpm_patch_mode), else stringResource(R.string.kpm_patch_mode),
style = MaterialTheme.typography.bodySmall, fontSize = MiuixTheme.textStyles.body2.fontSize,
color = MaterialTheme.colorScheme.tertiary color = colorScheme.onSurfaceVariantSummary
) )
} }
Spacer(modifier = Modifier.height(8.dp))
if (flashState.currentStep.isNotEmpty()) { if (flashState.currentStep.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = flashState.currentStep, text = flashState.currentStep,
style = MaterialTheme.typography.bodyMedium, fontSize = MiuixTheme.textStyles.body2.fontSize,
color = MaterialTheme.colorScheme.onSurfaceVariant color = colorScheme.onSurfaceVariantSummary
) )
Spacer(modifier = Modifier.height(8.dp))
} }
Spacer(modifier = Modifier.height(12.dp))
LinearProgressIndicator( LinearProgressIndicator(
progress = { progress.value }, progress = progress.value,
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.height(8.dp),
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
) )
if (flashState.error.isNotEmpty()) { if (flashState.error.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = flashState.error, text = flashState.error,
style = MaterialTheme.typography.bodySmall, fontSize = MiuixTheme.textStyles.body2.fontSize,
color = MaterialTheme.colorScheme.error, color = colorScheme.onErrorContainer,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp)
.background( .background(
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), colorScheme.errorContainer
shape = MaterialTheme.shapes.small
) )
.padding(8.dp) .padding(12.dp)
) )
} }
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TopBar( private fun TopBar(
flashState: FlashState, flashState: FlashState,
onBack: () -> Unit, onBack: () -> Unit,
onSave: () -> Unit = {}, onSave: () -> Unit = {}
scrollBehavior: TopAppBarScrollBehavior? = null
) { ) {
val statusColor = when { SmallTopAppBar(
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error title = stringResource(
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary when {
else -> MaterialTheme.colorScheme.primary flashState.error.isNotEmpty() -> R.string.flash_failed
} flashState.isCompleted -> R.string.flash_success
else -> R.string.kernel_flashing
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
val cardAlpha = CardConfig.cardAlpha
TopAppBar(
title = {
Text(
text = stringResource(
when {
flashState.error.isNotEmpty() -> R.string.flash_failed
flashState.isCompleted -> R.string.flash_success
else -> R.string.kernel_flashing
}
),
style = MaterialTheme.typography.titleLarge,
color = statusColor
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
} }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
), ),
actions = { navigationIcon = {
IconButton(onClick = onSave) { IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack
) {
Icon( Icon(
imageVector = Icons.Filled.Save, MiuixIcons.Useful.Back,
contentDescription = stringResource(id = R.string.save_log), contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = colorScheme.onBackground
) )
} }
}, },
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), actions = {
scrollBehavior = scrollBehavior IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = onSave
) {
Icon(
imageVector = MiuixIcons.Useful.Save,
contentDescription = stringResource(id = R.string.save_log),
tint = colorScheme.onBackground
)
}
}
) )
} }

View File

@@ -0,0 +1,216 @@
package com.sukisu.ultra.ui.kernelFlash.component
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SdStorage
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.getRootShell
import com.topjohnwu.superuser.ShellUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
/**
* 槽位选择对话框组件
* 用于Kernel刷写时选择目标槽位
*/
@Composable
fun SlotSelectionDialog(
show: Boolean,
onDismiss: () -> Unit,
onSlotSelected: (String) -> Unit
) {
var currentSlot by remember { mutableStateOf<String?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var selectedSlot by remember { mutableStateOf<String?>(null) }
val showDialog = remember { mutableStateOf(show) }
val context = LocalContext.current
LaunchedEffect(show) {
showDialog.value = show
if (show) {
try {
currentSlot = withContext(Dispatchers.IO) { getCurrentSlot() }
// 设置默认选择为当前槽位
selectedSlot = when (currentSlot) {
"a" -> "a"
"b" -> "b"
else -> null
}
errorMessage = null
} catch (e: Exception) {
errorMessage = context.getString(R.string.operation_failed)
currentSlot = null
}
}
}
SuperDialog(
show = showDialog,
insideMargin = DpSize(0.dp, 0.dp),
onDismissRequest = {
showDialog.value = false
onDismiss()
},
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
// 标题
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
text = stringResource(id = R.string.select_slot_title),
fontSize = MiuixTheme.textStyles.title4.fontSize,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
color = colorScheme.onSurface
)
// 当前槽位或错误信息
if (errorMessage != null) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
text = errorMessage ?: context.getString(R.string.operation_failed),
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.error,
textAlign = TextAlign.Center
)
} else {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
text = stringResource(
id = R.string.current_slot,
currentSlot?.uppercase() ?: context.getString(R.string.not_supported)
),
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center
)
}
// 描述文本
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
text = stringResource(id = R.string.select_slot_description),
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
// 槽位选项
val slotOptions = listOf(
SlotOption(
slot = "a",
titleText = stringResource(id = R.string.slot_a),
icon = Icons.Filled.SdStorage
),
SlotOption(
slot = "b",
titleText = stringResource(id = R.string.slot_b),
icon = Icons.Filled.SdStorage
)
)
slotOptions.forEach { option ->
SuperArrow(
title = option.titleText,
leftAction = {
Icon(
imageVector = option.icon,
contentDescription = null,
tint = if (selectedSlot == option.slot) {
colorScheme.primary
} else {
colorScheme.onSurfaceVariantSummary
}
)
},
onClick = {
selectedSlot = option.slot
},
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
// 按钮行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = {
showDialog.value = false
onDismiss()
},
modifier = Modifier.weight(1f)
)
TextButton(
text = stringResource(android.R.string.ok),
onClick = {
selectedSlot?.let { onSlotSelected(it) }
showDialog.value = false
onDismiss()
},
enabled = selectedSlot != null,
modifier = Modifier.weight(1f)
)
}
}
}
)
}
// Data class for slot options
data class SlotOption(
val slot: String,
val titleText: String,
val icon: ImageVector
)
// Utility function to get current slot
private suspend fun getCurrentSlot(): String? {
return try {
val shell = getRootShell()
val result = ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim()
if (result.startsWith("_")) {
result.substring(1)
} else {
result
}.takeIf { it.isNotEmpty() }
} catch (e: Exception) {
null
}
}

View File

@@ -1,4 +1,4 @@
package zako.zako.zako.zakoui.screen.kernelFlash.state package com.sukisu.ultra.ui.kernelFlash.state
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
@@ -6,11 +6,12 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.network.RemoteToolsDownloader import com.sukisu.ultra.ui.kernelFlash.util.AssetsUtil
import com.sukisu.ultra.ui.kernelFlash.util.RemoteToolsDownloader
import com.sukisu.ultra.ui.util.getRootShell
import com.sukisu.ultra.ui.util.install import com.sukisu.ultra.ui.util.install
import com.sukisu.ultra.ui.util.rootAvailable import com.sukisu.ultra.ui.util.rootAvailable
import com.sukisu.ultra.utils.AssetsUtil import com.topjohnwu.superuser.ShellUtils
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -74,10 +75,6 @@ class HorizonKernelState {
fun completeFlashing() { fun completeFlashing() {
_state.update { it.copy(isCompleted = true, progress = 1f) } _state.update { it.copy(isCompleted = true, progress = 1f) }
} }
fun reset() {
_state.value = FlashState()
}
} }
class HorizonKernelWorker( class HorizonKernelWorker(
@@ -157,7 +154,12 @@ class HorizonKernelWorker(
if (isAbDevice && slot != null) { if (isAbDevice && slot != null) {
state.updateStep(context.getString(R.string.horizon_getting_original_slot)) state.updateStep(context.getString(R.string.horizon_getting_original_slot))
state.updateProgress(0.72f) state.updateProgress(0.72f)
originalSlot = runCommandGetOutput("getprop ro.boot.slot_suffix") originalSlot = try {
val shell = getRootShell()
ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim()
} catch (_: Exception) {
null
}
state.updateStep(context.getString(R.string.horizon_setting_target_slot)) state.updateStep(context.getString(R.string.horizon_setting_target_slot))
state.updateProgress(0.74f) state.updateProgress(0.74f)
@@ -308,7 +310,12 @@ class HorizonKernelWorker(
} }
// 查找Image文件 // 查找Image文件
val findImageResult = runCommandGetOutput("find $extractDir -name '*Image*' -type f") val findImageResult = try {
val shell = getRootShell()
ShellUtils.fastCmd(shell, "find $extractDir -name '*Image*' -type f").trim()
} catch (_: Exception) {
throw IOException(context.getString(R.string.kpm_image_file_not_found))
}
if (findImageResult.isBlank()) { if (findImageResult.isBlank()) {
throw IOException(context.getString(R.string.kpm_image_file_not_found)) throw IOException(context.getString(R.string.kpm_image_file_not_found))
} }
@@ -398,11 +405,16 @@ class HorizonKernelWorker(
// 检查设备是否为AB分区设备 // 检查设备是否为AB分区设备
private fun isAbDevice(): Boolean { private fun isAbDevice(): Boolean {
val abUpdate = runCommandGetOutput("getprop ro.build.ab_update") return try {
if (!abUpdate.toBoolean()) return false val shell = getRootShell()
val abUpdate = ShellUtils.fastCmd(shell, "getprop ro.build.ab_update").trim()
if (!abUpdate.toBoolean()) return false
val slotSuffix = runCommandGetOutput("getprop ro.boot.slot_suffix") val slotSuffix = ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim()
return slotSuffix.isNotEmpty() slotSuffix.isNotEmpty()
} catch (_: Exception) {
false
}
} }
private fun cleanup() { private fun cleanup() {
@@ -429,7 +441,12 @@ class HorizonKernelWorker(
@SuppressLint("StringFormatInvalid") @SuppressLint("StringFormatInvalid")
private fun patch() { private fun patch() {
val kernelVersion = runCommandGetOutput("cat /proc/version") val kernelVersion = try {
val shell = getRootShell()
ShellUtils.fastCmd(shell, "cat /proc/version")
} catch (_: Exception) {
""
}
val versionRegex = """\d+\.\d+\.\d+""".toRegex() val versionRegex = """\d+\.\d+\.\d+""".toRegex()
val version = kernelVersion.let { versionRegex.find(it) }?.value ?: "" val version = kernelVersion.let { versionRegex.find(it) }?.value ?: ""
val toolName = if (version.isNotEmpty()) { val toolName = if (version.isNotEmpty()) {
@@ -447,7 +464,9 @@ class HorizonKernelWorker(
val toolPath = "${context.filesDir.absolutePath}/mkbootfs" val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath) AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}") state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}")
runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $toolPath \$AKHOME/tools;' $binaryPath") runCommand(false,
$$"sed -i '/chmod -R 755 tools bin;/i cp -f $$toolPath $AKHOME/tools;' $$binaryPath"
)
} }
private fun flash() { private fun flash() {
@@ -517,8 +536,4 @@ class HorizonKernelWorker(
process.destroy() process.destroy()
} }
} }
private fun runCommandGetOutput(cmd: String): String {
return Shell.cmd(cmd).exec().out.joinToString("\n").trim()
}
} }

View File

@@ -1,4 +1,4 @@
package com.sukisu.ultra.utils package com.sukisu.ultra.ui.kernelFlash.util
import android.content.Context import android.content.Context
import java.io.File import java.io.File

View File

@@ -1,7 +1,9 @@
package com.sukisu.ultra.network package com.sukisu.ultra.ui.kernelFlash.util
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.sukisu.ultra.ui.util.getRootShell
import com.topjohnwu.superuser.ShellUtils
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -23,8 +25,8 @@ class RemoteToolsDownloader(
private const val KPIMG_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kpimg" private const val KPIMG_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kpimg"
// 网络超时配置(毫秒) // 网络超时配置(毫秒)
private const val CONNECTION_TIMEOUT = 15000 // 15秒连接超时 private const val CONNECTION_TIMEOUT = 10000
private const val READ_TIMEOUT = 30000 // 30秒读取超时 private const val READ_TIMEOUT = 20000
// 最大重试次数 // 最大重试次数
private const val MAX_RETRY_COUNT = 3 private const val MAX_RETRY_COUNT = 3
@@ -48,47 +50,26 @@ class RemoteToolsDownloader(
suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) { suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) {
val results = mutableMapOf<String, DownloadResult>()
listener?.onLog("Starting to prepare KPM tool files...") listener?.onLog("Starting to prepare KPM tool files...")
File(workDir).mkdirs()
try { // 并行下载两个工具文件
// 确保工作目录存在 val results = mapOf(
File(workDir).mkdirs() "kptools" to async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) },
"kpimg" to async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
).mapValues { it.value.await() }
// 并行下载两个工具文件 // 设置 kptools 执行权限
val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) } File(workDir, "kptools").takeIf { it.exists() }?.let { file ->
val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) } setExecutablePermission(file.absolutePath)
listener?.onLog("Set kptools execution permission")
// 等待所有下载完成
results["kptools"] = kptoolsDeferred.await()
results["kpimg"] = kpimgDeferred.await()
// 检查kptools执行权限
val kptoolsFile = File(workDir, "kptools")
if (kptoolsFile.exists()) {
setExecutablePermission(kptoolsFile.absolutePath)
listener?.onLog("Set kptools execution permission")
}
val successCount = results.values.count { it.success }
val remoteCount = results.values.count { it.success && it.isRemoteSource }
listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount")
} catch (e: Exception) {
Log.e(TAG, "Exception occurred while downloading tools", e)
listener?.onLog("Exception occurred during tool download: ${e.message}")
if (!results.containsKey("kptools")) {
results["kptools"] = downloadSingleTool("kptools", null, listener)
}
if (!results.containsKey("kpimg")) {
results["kpimg"] = downloadSingleTool("kpimg", null, listener)
}
} }
results.toMap() val successCount = results.values.count { it.success }
val remoteCount = results.values.count { it.success && it.isRemoteSource }
listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount")
results
} }
private suspend fun downloadSingleTool( private suspend fun downloadSingleTool(
@@ -96,43 +77,38 @@ class RemoteToolsDownloader(
remoteUrl: String?, remoteUrl: String?,
listener: DownloadProgressListener? listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) { ): DownloadResult = withContext(Dispatchers.IO) {
val targetFile = File(workDir, fileName) val targetFile = File(workDir, fileName)
if (remoteUrl == null) { if (remoteUrl == null) {
return@withContext useLocalVersion(fileName, targetFile, listener) return@withContext useLocalVersion(fileName, targetFile, listener)
} }
// 尝试从远程下载
listener?.onLog("Downloading $fileName from remote repository...") listener?.onLog("Downloading $fileName from remote repository...")
var lastError = ""
// 重试机制 // 重试机制
var lastError = ""
repeat(MAX_RETRY_COUNT) { attempt -> repeat(MAX_RETRY_COUNT) { attempt ->
try { try {
val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener) val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener)
if (result.success) { if (result.success) {
listener?.onSuccess(fileName, true) listener?.onSuccess(fileName, true)
return@withContext result return@withContext result
} else {
lastError = result.errorMessage ?: "Unknown error"
} }
lastError = result.errorMessage ?: "Unknown error"
} catch (e: Exception) { } catch (e: Exception) {
lastError = e.message ?: "Network exception" lastError = "Network exception"
Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e) Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e)
}
if (attempt < MAX_RETRY_COUNT - 1) { if (attempt < MAX_RETRY_COUNT - 1) {
listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...") listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...")
delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L)) delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L))
}
} }
} }
// 所有重试都失败,回退到本地版本
listener?.onError(fileName, "Remote download failed: $lastError") listener?.onError(fileName, "Remote download failed: $lastError")
listener?.onLog("$fileName remote download failed, falling back to local version...") listener?.onLog("$fileName remote download failed, falling back to local version...")
useLocalVersion(fileName, targetFile, listener) useLocalVersion(fileName, targetFile, listener)
} }
@@ -142,15 +118,10 @@ class RemoteToolsDownloader(
targetFile: File, targetFile: File,
listener: DownloadProgressListener? listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) { ): DownloadResult = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null var connection: HttpURLConnection? = null
try { try {
val url = URL(remoteUrl) connection = (URL(remoteUrl).openConnection() as HttpURLConnection).apply {
connection = url.openConnection() as HttpURLConnection
// 设置连接参数
connection.apply {
connectTimeout = CONNECTION_TIMEOUT connectTimeout = CONNECTION_TIMEOUT
readTimeout = READ_TIMEOUT readTimeout = READ_TIMEOUT
requestMethod = "GET" requestMethod = "GET"
@@ -159,22 +130,17 @@ class RemoteToolsDownloader(
setRequestProperty("Connection", "close") setRequestProperty("Connection", "close")
} }
// 建立连接
connection.connect() connection.connect()
val responseCode = connection.responseCode if (connection.responseCode != HttpURLConnection.HTTP_OK) {
if (responseCode != HttpURLConnection.HTTP_OK) {
return@withContext DownloadResult( return@withContext DownloadResult(
false, false,
isRemoteSource = false, isRemoteSource = false,
errorMessage = "HTTP error code: $responseCode" errorMessage = "HTTP error code: ${connection.responseCode}"
) )
} }
val fileLength = connection.contentLength val fileLength = connection.contentLength
Log.d(TAG, "$fileName remote file size: $fileLength bytes")
// 创建临时文件
val tempFile = File(targetFile.absolutePath + ".tmp") val tempFile = File(targetFile.absolutePath + ".tmp")
// 下载文件 // 下载文件
@@ -182,40 +148,34 @@ class RemoteToolsDownloader(
FileOutputStream(tempFile).use { output -> FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(8192) val buffer = ByteArray(8192)
var totalBytes = 0 var totalBytes = 0
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) { while (true) {
// 检查协程是否被取消
ensureActive() ensureActive()
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead) output.write(buffer, 0, bytesRead)
totalBytes += bytesRead totalBytes += bytesRead
// 更新下载进度
if (fileLength > 0) { if (fileLength > 0) {
listener?.onProgress(fileName, totalBytes, fileLength) listener?.onProgress(fileName, totalBytes, fileLength)
} }
} }
output.flush() output.flush()
} }
} }
// 验证下载的文件 // 验证并移动文件
if (!validateDownloadedFile(tempFile, fileName)) { if (!validateDownloadedFile(tempFile, fileName)) {
tempFile.delete() tempFile.delete()
return@withContext DownloadResult( return@withContext DownloadResult(
success = false, false,
isRemoteSource = false, isRemoteSource = false,
errorMessage = "File verification failed" errorMessage = "File verification failed"
) )
} }
// 移动临时文件到目标位置 targetFile.delete()
if (targetFile.exists()) {
targetFile.delete()
}
if (!tempFile.renameTo(targetFile)) { if (!tempFile.renameTo(targetFile)) {
tempFile.delete() tempFile.delete()
return@withContext DownloadResult( return@withContext DownloadResult(
@@ -227,7 +187,6 @@ class RemoteToolsDownloader(
Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes") Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes")
listener?.onLog("$fileName remote download successful") listener?.onLog("$fileName remote download successful")
DownloadResult(true, isRemoteSource = true) DownloadResult(true, isRemoteSource = true)
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
@@ -235,16 +194,10 @@ class RemoteToolsDownloader(
DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout") DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout")
} catch (e: IOException) { } catch (e: IOException) {
Log.w(TAG, "$fileName network IO exception", e) Log.w(TAG, "$fileName network IO exception", e)
DownloadResult(false, DownloadResult(false, isRemoteSource = false, errorMessage = "Network exception: ${e.message}")
isRemoteSource = false,
errorMessage = "Network connection exception: ${e.message}"
)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "$fileName exception occurred during download", e) Log.e(TAG, "$fileName exception occurred during download", e)
DownloadResult(false, DownloadResult(false, isRemoteSource = false, errorMessage = "Download exception: ${e.message}")
isRemoteSource = false,
errorMessage = "Download exception: ${e.message}"
)
} finally { } finally {
connection?.disconnect() connection?.disconnect()
} }
@@ -255,61 +208,42 @@ class RemoteToolsDownloader(
targetFile: File, targetFile: File,
listener: DownloadProgressListener? listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) { ): DownloadResult = withContext(Dispatchers.IO) {
try { try {
com.sukisu.ultra.utils.AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath) AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath)
if (!targetFile.exists()) { if (!targetFile.exists() || !validateDownloadedFile(targetFile, fileName)) {
val errorMsg = "Local $fileName file extraction failed" val errorMsg = if (!targetFile.exists()) {
"Local $fileName file extraction failed"
} else {
"Local $fileName file verification failed"
}
listener?.onError(fileName, errorMsg) listener?.onError(fileName, errorMsg)
return@withContext DownloadResult(false, return@withContext DownloadResult(false, isRemoteSource = false, errorMessage = errorMsg)
isRemoteSource = false,
errorMessage = errorMsg
)
}
if (!validateDownloadedFile(targetFile, fileName)) {
val errorMsg = "Local $fileName file verification failed"
listener?.onError(fileName, errorMsg)
return@withContext DownloadResult(
success = false,
isRemoteSource = false,
errorMessage = errorMsg
)
} }
Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes") Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes")
listener?.onLog("$fileName local version loaded successfully") listener?.onLog("$fileName local version loaded successfully")
listener?.onSuccess(fileName, false) listener?.onSuccess(fileName, false)
DownloadResult(true, isRemoteSource = false) DownloadResult(true, isRemoteSource = false)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "$fileName local version loading failed", e) Log.e(TAG, "$fileName local version loading failed", e)
val errorMsg = "Local version loading failed: ${e.message}" val errorMsg = "Local version loading failed: ${e.message}"
listener?.onError(fileName, errorMsg) listener?.onError(fileName, errorMsg)
DownloadResult(success = false, isRemoteSource = false, errorMessage = errorMsg) DownloadResult(false, isRemoteSource = false, errorMessage = errorMsg)
} }
} }
private fun validateDownloadedFile(file: File, fileName: String): Boolean { private fun validateDownloadedFile(file: File, fileName: String): Boolean {
if (!file.exists()) { if (!file.exists() || file.length() < MIN_FILE_SIZE) {
Log.w(TAG, "$fileName file does not exist") Log.w(TAG, "$fileName file validation failed: exists=${file.exists()}, size=${file.length()}")
return false return false
} }
val fileSize = file.length() return try {
if (fileSize < MIN_FILE_SIZE) {
Log.w(TAG, "$fileName file is too small: $fileSize bytes")
return false
}
try {
file.inputStream().use { input -> file.inputStream().use { input ->
val header = ByteArray(4) val header = ByteArray(4)
val bytesRead = input.read(header) if (input.read(header) < 4) {
if (bytesRead < 4) {
Log.w(TAG, "$fileName file header read incomplete") Log.w(TAG, "$fileName file header read incomplete")
return false return false
} }
@@ -324,20 +258,24 @@ class RemoteToolsDownloader(
return false return false
} }
Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF") Log.d(TAG, "$fileName file verification passed, size: ${file.length()} bytes, ELF: $isELF")
return true true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "$fileName file verification exception", e) Log.w(TAG, "$fileName file verification exception", e)
return false false
} }
} }
private fun setExecutablePermission(filePath: String) { private fun setExecutablePermission(filePath: String) {
try { try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "chmod a+rx $filePath")) val shell = getRootShell()
process.waitFor() if (ShellUtils.fastCmdResult(shell, "chmod a+rx $filePath")) {
Log.d(TAG, "Set execution permission for $filePath") Log.d(TAG, "Set execution permission for $filePath")
} else {
File(filePath).setExecutable(true, false)
Log.d(TAG, "Set execution permission using Java method for $filePath")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to set execution permission: $filePath", e) Log.w(TAG, "Failed to set execution permission: $filePath", e)
try { try {
@@ -351,11 +289,9 @@ class RemoteToolsDownloader(
fun cleanup() { fun cleanup() {
try { try {
File(workDir).listFiles()?.forEach { file -> File(workDir).listFiles()?.filter { it.name.endsWith(".tmp") }?.forEach { file ->
if (file.name.endsWith(".tmp")) { file.delete()
file.delete() Log.d(TAG, "Cleaned temporary file: ${file.name}")
Log.d(TAG, "Cleaned temporary file: ${file.name}")
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to clean temporary files", e) Log.w(TAG, "Failed to clean temporary files", e)

View File

@@ -0,0 +1,207 @@
package com.sukisu.ultra.ui.screen
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.captionBar
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.FixedScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.dropUnlessResumed
import com.kyant.capsule.ContinuousRoundedRectangle
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import com.sukisu.ultra.BuildConfig
import com.sukisu.ultra.R
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.getWindowSize
import top.yukonga.miuix.kmp.utils.overScrollVertical
@Composable
@Destination<RootGraph>
fun AboutScreen(navigator: DestinationsNavigator) {
val uriHandler = LocalUriHandler.current
val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
val htmlString = stringResource(
id = R.string.about_source_code,
"<b><a href=\"https://github.com/ShirkNeko/SukiSU-Ultra\">GitHub</a></b>",
"<b><a href=\"https://t.me/SukiKSU\">Telegram</a></b>",
"<b>怡子曰曰</b>",
"<b>明风 OuO</b>",
"<b><a href=\"https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.txt\">CC BY-NC-SA 4.0</a></b>"
)
val result = extractLinks(htmlString)
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
},
color = Color.Transparent,
title = stringResource(R.string.about),
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = dropUnlessResumed { navigator.popBackStack() }
) {
Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onBackground
)
}
},
scrollBehavior = scrollBehavior
)
},
popupHost = { },
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
) { innerPadding ->
LazyColumn(
modifier = Modifier
.height(getWindowSize().height.dp)
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.hazeSource(state = hazeState)
.padding(horizontal = 12.dp),
contentPadding = innerPadding,
overscrollEffect = null,
) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(vertical = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(80.dp)
.clip(ContinuousRoundedRectangle(16.dp))
.background(Color.White)
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "icon",
contentScale = FixedScale(1f)
)
}
Text(
modifier = Modifier.padding(top = 12.dp),
text = stringResource(id = R.string.app_name),
fontWeight = FontWeight.Medium,
fontSize = 26.sp
)
Text(
text = BuildConfig.VERSION_NAME,
fontSize = 14.sp
)
}
}
item {
Card(
modifier = Modifier.padding(bottom = 12.dp)
) {
result.forEach {
SuperArrow(
title = it.fullText,
onClick = {
uriHandler.openUri(it.url)
}
)
}
}
Spacer(
Modifier.height(
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
)
)
}
}
}
}
data class LinkInfo(
val fullText: String,
val url: String
)
fun extractLinks(html: String): List<LinkInfo> {
val regex = Regex(
"""([^<>\n\r]+?)\s*<b>\s*<a\b[^>]*\bhref\s*=\s*(['"]?)([^'"\s>]+)\2[^>]*>([^<]+)</a>\s*</b>\s*(.*?)\s*(?=<br|\n|$)""",
RegexOption.MULTILINE
)
Log.d("ggc", "extractLinks: $html")
return regex.findAll(html).mapNotNull { match ->
try {
val before = match.groupValues[1].trim()
val url = match.groupValues[3].trim()
val title = match.groupValues[4].trim()
val after = match.groupValues[5].trim()
val fullText = "$before $title $after"
Log.d("ggc", "extractLinks: $fullText -> $url")
LinkInfo(fullText, url)
} catch (e: Exception) {
Log.e("ggc", "匹配失败: ${e.message}")
null
}
}.toList()
}

View File

@@ -1,24 +0,0 @@
package com.sukisu.ultra.ui.screen
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.ui.graphics.vector.ImageVector
import com.ramcosta.composedestinations.generated.destinations.*
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
import com.sukisu.ultra.R
enum class BottomBarDestination(
val direction: DirectionDestinationSpec,
@param:StringRes val label: Int,
val iconSelected: ImageVector,
val iconNotSelected: ImageVector,
val rootRequired: Boolean,
) {
Home(HomeScreenDestination, R.string.home, Icons.Filled.Home, Icons.Outlined.Home, false),
Kpm(KpmScreenDestination, R.string.kpm_title, Icons.Filled.Archive, Icons.Outlined.Archive, true),
SuperUser(SuperUserScreenDestination, R.string.superuser, Icons.Filled.AdminPanelSettings, Icons.Outlined.AdminPanelSettings, true),
Module(ModuleScreenDestination, R.string.module, Icons.Filled.Extension, Icons.Outlined.Extension, true),
Settings(SettingScreenDestination, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings, false),
}

View File

@@ -1,50 +1,86 @@
package com.sukisu.ultra.ui.screen package com.sukisu.ultra.ui.screen
import android.os.Environment import android.os.Environment
import androidx.activity.compose.BackHandler import android.widget.Toast
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.captionBar
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.runtime.Composable
import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material.icons.filled.Save import androidx.compose.runtime.getValue
import androidx.compose.material3.* import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.* import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R import dev.chrisbanes.haze.HazeState
import com.sukisu.ultra.ui.component.KeyEventBlocker import dev.chrisbanes.haze.HazeStyle
import com.sukisu.ultra.ui.util.LocalSnackbarHost import dev.chrisbanes.haze.HazeTint
import com.sukisu.ultra.ui.util.runModuleAction import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.KeyEventBlocker
import com.sukisu.ultra.ui.util.runModuleAction
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Save
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
@Composable @Composable
@Destination<RootGraph> @Destination<RootGraph>
fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) { fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) {
var text by rememberSaveable { mutableStateOf("") } var text by rememberSaveable { mutableStateOf("") }
var tempText : String var tempText: String
val logContent = rememberSaveable { StringBuilder() } val logContent = rememberSaveable { StringBuilder() }
val snackBarHost = LocalSnackbarHost.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var isActionRunning by rememberSaveable { mutableStateOf(true) } var actionResult: Boolean
val hazeState = remember { HazeState() }
BackHandler(enabled = isActionRunning) { val hazeStyle = HazeStyle(
// Disable back button if action is running backgroundColor = colorScheme.surface,
} tint = HazeTint(colorScheme.surface.copy(0.8f))
)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (text.isNotEmpty()) { if (text.isNotEmpty()) {
@@ -65,83 +101,110 @@ fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String
onStderr = { onStderr = {
logContent.append(it).append("\n") logContent.append(it).append("\n")
} }
) ).let {
actionResult = it
}
} }
isActionRunning = false if (actionResult) navigator.popBackStack()
} }
Scaffold( Scaffold(
topBar = { topBar = {
TopBar( TopBar(
isActionRunning = isActionRunning, onBack = dropUnlessResumed {
navigator.popBackStack()
},
onSave = { onSave = {
if (!isActionRunning) { scope.launch {
scope.launch { val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault()) val date = format.format(Date())
val date = format.format(Date()) val file = File(
val file = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "KernelSU_module_action_log_${date}.log"
"KernelSU_module_action_log_${date}.log" )
) file.writeText(logContent.toString())
file.writeText(logContent.toString()) Toast.makeText(context, "Log saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
}
} }
} },
hazeState = hazeState,
hazeStyle = hazeStyle,
) )
}, },
floatingActionButton = { popupHost = { },
if (!isActionRunning) { contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
ExtendedFloatingActionButton(
text = { Text(text = stringResource(R.string.close)) },
icon = { Icon(Icons.Filled.Close, contentDescription = null) },
onClick = {
navigator.popBackStack()
}
)
}
},
contentWindowInsets = WindowInsets.safeDrawing,
snackbarHost = { SnackbarHost(snackBarHost) }
) { innerPadding -> ) { innerPadding ->
val layoutDirection = LocalLayoutDirection.current
KeyEventBlocker { KeyEventBlocker {
it.key == Key.VolumeDown || it.key == Key.VolumeUp it.key == Key.VolumeDown || it.key == Key.VolumeUp
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize(1f) .fillMaxSize(1f)
.padding(innerPadding) .scrollEndHaptic()
.hazeSource(state = hazeState)
.padding(
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateStartPadding(layoutDirection),
)
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {
LaunchedEffect(text) { LaunchedEffect(text) {
scrollState.animateScrollTo(scrollState.maxValue) scrollState.animateScrollTo(scrollState.maxValue)
} }
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
Text( Text(
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(8.dp),
text = text, text = text,
fontSize = MaterialTheme.typography.bodySmall.fontSize, fontSize = 12.sp,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
lineHeight = MaterialTheme.typography.bodySmall.lineHeight, )
Spacer(
Modifier.height(
12.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
)
) )
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TopBar(isActionRunning: Boolean, onSave: () -> Unit = {}) { private fun TopBar(
TopAppBar( onBack: () -> Unit = {},
title = { Text(stringResource(R.string.action)) }, onSave: () -> Unit = {},
actions = { hazeState: HazeState,
hazeStyle: HazeStyle,
) {
SmallTopAppBar(
modifier = Modifier.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
},
title = stringResource(R.string.action),
navigationIcon = {
IconButton( IconButton(
onClick = onSave, modifier = Modifier.padding(start = 16.dp),
enabled = !isActionRunning onClick = onBack
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Save, imageVector = MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onBackground
)
}
},
actions = {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = onSave
) {
Icon(
imageVector = MiuixIcons.Useful.Save,
contentDescription = stringResource(id = R.string.save_log), contentDescription = stringResource(id = R.string.save_log),
tint = colorScheme.onBackground
) )
} }
} }
) )
} }

View File

@@ -1,257 +1,136 @@
package com.sukisu.ultra.ui.screen package com.sukisu.ultra.ui.screen
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import android.os.Parcelable import android.os.Parcelable
import androidx.activity.compose.BackHandler import android.widget.Toast
import androidx.compose.animation.* import androidx.compose.foundation.border
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.captionBar
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.filled.Error import androidx.compose.runtime.Composable
import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material.icons.filled.Save import androidx.compose.runtime.getValue
import androidx.compose.material3.* import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.* import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.activity.compose.LocalActivity
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.ui.unit.sp
import androidx.activity.ComponentActivity import androidx.lifecycle.compose.dropUnlessResumed
import kotlinx.coroutines.delay
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.ModuleScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.KeyEventBlocker
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.util.*
import com.sukisu.ultra.ui.viewmodel.ModuleViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.KeyEventBlocker
import com.sukisu.ultra.ui.util.FlashResult
import com.sukisu.ultra.ui.util.LkmSelection
import com.sukisu.ultra.ui.util.flashModule
import com.sukisu.ultra.ui.util.installBoot
import com.sukisu.ultra.ui.util.reboot
import com.sukisu.ultra.ui.util.restoreBoot
import com.sukisu.ultra.ui.util.uninstallPermanently
import top.yukonga.miuix.kmp.basic.FloatingActionButton
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Move
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import androidx.core.content.edit import java.util.Locale
import com.sukisu.ultra.ui.util.module.ModuleOperationUtils
import com.sukisu.ultra.ui.util.module.ModuleUtils
/** /**
* @author ShirkNeko * @author weishu
* @date 2025/5/31. * @date 2023/1/1.
*/ */
enum class FlashingStatus { enum class FlashingStatus {
FLASHING, FLASHING,
SUCCESS, SUCCESS,
FAILED FAILED
} }
private var currentFlashingStatus = mutableStateOf(FlashingStatus.FLASHING) // Lets you flash modules sequentially when mutiple zipUris are selected
fun flashModulesSequentially(
// 添加模块安装状态跟踪 uris: List<Uri>,
data class ModuleInstallStatus( onStdout: (String) -> Unit,
val totalModules: Int = 0, onStderr: (String) -> Unit
val currentModule: Int = 0, ): FlashResult {
val currentModuleName: String = "", for (uri in uris) {
val failedModules: MutableList<String> = mutableListOf(), flashModule(uri, onStdout, onStderr).apply {
val verifiedModules: MutableList<String> = mutableListOf() // 添加已验证模块列表 if (code != 0) {
) return FlashResult(code, err, showReboot)
private var moduleInstallStatus = mutableStateOf(ModuleInstallStatus())
// 存储模块URI和验证状态的映射
private var moduleVerificationMap = mutableMapOf<Uri, Boolean>()
fun setFlashingStatus(status: FlashingStatus) {
currentFlashingStatus.value = status
}
fun updateModuleInstallStatus(
totalModules: Int? = null,
currentModule: Int? = null,
currentModuleName: String? = null,
failedModule: String? = null,
verifiedModule: String? = null
) {
val current = moduleInstallStatus.value
moduleInstallStatus.value = current.copy(
totalModules = totalModules ?: current.totalModules,
currentModule = currentModule ?: current.currentModule,
currentModuleName = currentModuleName ?: current.currentModuleName
)
if (failedModule != null) {
val updatedFailedModules = current.failedModules.toMutableList()
updatedFailedModules.add(failedModule)
moduleInstallStatus.value = moduleInstallStatus.value.copy(
failedModules = updatedFailedModules
)
}
if (verifiedModule != null) {
val updatedVerifiedModules = current.verifiedModules.toMutableList()
updatedVerifiedModules.add(verifiedModule)
moduleInstallStatus.value = moduleInstallStatus.value.copy(
verifiedModules = updatedVerifiedModules
)
}
}
fun setModuleVerificationStatus(uri: Uri, isVerified: Boolean) {
moduleVerificationMap[uri] = isVerified
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Destination<RootGraph>
fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
val context = LocalContext.current
val shouldAutoExit = remember {
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.getBoolean("auto_exit_after_flash", false)
}
// 是否通过从外部启动的模块安装
val isExternalInstall = remember {
when (flashIt) {
is FlashIt.FlashModule -> {
(context as? ComponentActivity)?.intent?.let { intent ->
intent.action == Intent.ACTION_VIEW || intent.action == Intent.ACTION_SEND
} ?: false
} }
is FlashIt.FlashModules -> {
(context as? ComponentActivity)?.intent?.let { intent ->
intent.action == Intent.ACTION_VIEW || intent.action == Intent.ACTION_SEND
} ?: false
}
else -> false
} }
} }
return FlashResult(0, "", true)
}
@Composable
@Destination<RootGraph>
fun FlashScreen(
navigator: DestinationsNavigator,
flashIt: FlashIt
) {
var text by rememberSaveable { mutableStateOf("") } var text by rememberSaveable { mutableStateOf("") }
var tempText: String var tempText: String
val logContent = rememberSaveable { StringBuilder() } val logContent = rememberSaveable { StringBuilder() }
var showFloatAction by rememberSaveable { mutableStateOf(false) } var showFloatAction by rememberSaveable { mutableStateOf(false) }
// 添加状态跟踪是否已经完成刷写
var hasFlashCompleted by rememberSaveable { mutableStateOf(false) }
var hasExecuted by rememberSaveable { mutableStateOf(false) }
// 更新模块状态管理
var hasUpdateExecuted by rememberSaveable { mutableStateOf(false) }
var hasUpdateCompleted by rememberSaveable { mutableStateOf(false) }
val snackBarHost = LocalSnackbarHost.current val context = LocalContext.current
val activity = LocalActivity.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) var flashing by rememberSaveable {
val viewModel: ModuleViewModel = viewModel() mutableStateOf(FlashingStatus.FLASHING)
val errorCodeString = stringResource(R.string.error_code)
val checkLogString = stringResource(R.string.check_log)
val logSavedString = stringResource(R.string.log_saved)
val installingModuleString = stringResource(R.string.installing_module)
// 当前模块安装状态
val currentStatus = moduleInstallStatus.value
// 重置状态
LaunchedEffect(flashIt) {
when (flashIt) {
is FlashIt.FlashModules -> {
if (flashIt.currentIndex == 0) {
moduleInstallStatus.value = ModuleInstallStatus(
totalModules = flashIt.uris.size,
currentModule = 1
)
hasFlashCompleted = false
hasExecuted = false
moduleVerificationMap.clear()
}
}
is FlashIt.FlashModuleUpdate -> {
hasUpdateCompleted = false
hasUpdateExecuted = false
}
else -> {
hasFlashCompleted = false
hasExecuted = false
}
}
} }
// 处理更新模块安装 LaunchedEffect(Unit) {
LaunchedEffect(flashIt) { if (text.isNotEmpty()) {
if (flashIt !is FlashIt.FlashModuleUpdate) return@LaunchedEffect
if (hasUpdateExecuted || hasUpdateCompleted || text.isNotEmpty()) {
return@LaunchedEffect return@LaunchedEffect
} }
hasUpdateExecuted = true
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
setFlashingStatus(FlashingStatus.FLASHING) flashIt(flashIt, onStdout = {
try {
logContent.append(text).append("\n")
} catch (_: Exception) {
logContent.append(text).append("\n")
}
flashModuleUpdate(flashIt.uri, onFinish = { showReboot, code ->
if (code != 0) {
text += "$errorCodeString $code.\n$checkLogString\n"
setFlashingStatus(FlashingStatus.FAILED)
} else {
setFlashingStatus(FlashingStatus.SUCCESS)
// 处理模块更新成功后的验证标志
val isVerified = moduleVerificationMap[flashIt.uri] ?: false
ModuleOperationUtils.handleModuleUpdate(context, flashIt.uri, isVerified)
viewModel.markNeedRefresh()
}
if (showReboot) {
text += "\n\n\n"
showFloatAction = true
// 如果是内部安装,显示重启按钮后不自动返回
if (isExternalInstall) {
return@flashModuleUpdate
}
}
hasUpdateCompleted = true
// 如果是外部安装或需要自动退出的模块更新且不需要重启,延迟后自动返回
if (isExternalInstall || shouldAutoExit) {
scope.launch {
kotlinx.coroutines.delay(1000)
if (shouldAutoExit) {
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.edit { remove("auto_exit_after_flash") }
}
(context as? ComponentActivity)?.finish()
}
}
}, onStdout = {
tempText = "$it\n" tempText = "$it\n"
if (tempText.startsWith("")) { // clear command if (tempText.startsWith("")) { // clear command
text = tempText.substring(6) text = tempText.substring(6)
@@ -261,156 +140,41 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
logContent.append(it).append("\n") logContent.append(it).append("\n")
}, onStderr = { }, onStderr = {
logContent.append(it).append("\n") logContent.append(it).append("\n")
}) }).apply {
}
}
// 安装但排除更新模块
LaunchedEffect(flashIt) {
if (flashIt is FlashIt.FlashModuleUpdate) return@LaunchedEffect
if (hasExecuted || hasFlashCompleted || text.isNotEmpty()) {
return@LaunchedEffect
}
hasExecuted = true
withContext(Dispatchers.IO) {
setFlashingStatus(FlashingStatus.FLASHING)
if (flashIt is FlashIt.FlashModules) {
try {
val currentUri = flashIt.uris[flashIt.currentIndex]
val moduleName = getModuleNameFromUri(context, currentUri)
updateModuleInstallStatus(
currentModuleName = moduleName
)
text = installingModuleString.format(flashIt.currentIndex + 1, flashIt.uris.size, moduleName)
logContent.append(text).append("\n")
} catch (_: Exception) {
text = installingModuleString.format(flashIt.currentIndex + 1, flashIt.uris.size, "Module")
logContent.append(text).append("\n")
}
}
flashIt(flashIt, onFinish = { showReboot, code ->
if (code != 0) { if (code != 0) {
text += "$errorCodeString $code.\n$checkLogString\n" text += "Error code: $code.\n $err Please save and check the log.\n"
setFlashingStatus(FlashingStatus.FAILED)
if (flashIt is FlashIt.FlashModules) {
updateModuleInstallStatus(
failedModule = moduleInstallStatus.value.currentModuleName
)
}
} else {
setFlashingStatus(FlashingStatus.SUCCESS)
// 处理模块安装成功后的验证标志
when (flashIt) {
is FlashIt.FlashModule -> {
val isVerified = moduleVerificationMap[flashIt.uri] ?: false
ModuleOperationUtils.handleModuleInstallSuccess(context, flashIt.uri, isVerified)
if (isVerified) {
updateModuleInstallStatus(verifiedModule = moduleInstallStatus.value.currentModuleName)
}
}
is FlashIt.FlashModules -> {
val currentUri = flashIt.uris[flashIt.currentIndex]
val isVerified = moduleVerificationMap[currentUri] ?: false
ModuleOperationUtils.handleModuleInstallSuccess(context, currentUri, isVerified)
if (isVerified) {
updateModuleInstallStatus(verifiedModule = moduleInstallStatus.value.currentModuleName)
}
}
else -> {}
}
viewModel.markNeedRefresh()
} }
if (showReboot) { if (showReboot) {
text += "\n\n\n" text += "\n\n\n"
showFloatAction = true showFloatAction = true
} }
flashing = if (code == 0) FlashingStatus.SUCCESS else FlashingStatus.FAILED
hasFlashCompleted = true
if (flashIt is FlashIt.FlashModules && flashIt.currentIndex < flashIt.uris.size - 1) {
val nextFlashIt = flashIt.copy(
currentIndex = flashIt.currentIndex + 1
)
scope.launch {
kotlinx.coroutines.delay(500)
navigator.navigate(FlashScreenDestination(nextFlashIt))
}
} else if ((isExternalInstall || shouldAutoExit) && flashIt is FlashIt.FlashModules && flashIt.currentIndex >= flashIt.uris.size - 1) {
// 如果是外部安装或需要自动退出且是最后一个模块,安装完成后自动返回
scope.launch {
kotlinx.coroutines.delay(1000)
if (shouldAutoExit) {
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.edit { remove("auto_exit_after_flash") }
}
(context as? ComponentActivity)?.finish()
}
} else if ((isExternalInstall || shouldAutoExit) && flashIt is FlashIt.FlashModule) {
// 如果是外部安装或需要自动退出的单个模块,安装完成后自动返回
scope.launch {
kotlinx.coroutines.delay(1000)
if (shouldAutoExit) {
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.edit { remove("auto_exit_after_flash") }
}
(context as? ComponentActivity)?.finish()
}
}
}, onStdout = {
tempText = "$it\n"
if (tempText.startsWith("")) { // clear command
text = tempText.substring(6)
} else {
text += tempText
}
logContent.append(it).append("\n")
}, onStderr = {
logContent.append(it).append("\n")
})
}
}
val onBack: () -> Unit = {
val canGoBack = when (flashIt) {
is FlashIt.FlashModuleUpdate -> currentFlashingStatus.value != FlashingStatus.FLASHING
else -> currentFlashingStatus.value != FlashingStatus.FLASHING
}
if (canGoBack) {
if (isExternalInstall) {
(context as? ComponentActivity)?.finish()
} else {
if (flashIt is FlashIt.FlashModules || flashIt is FlashIt.FlashModuleUpdate) {
viewModel.markNeedRefresh()
viewModel.fetchModuleList()
navigator.navigate(ModuleScreenDestination)
} else {
viewModel.markNeedRefresh()
viewModel.fetchModuleList()
navigator.popBackStack()
}
} }
} }
} }
BackHandler(enabled = true) { // 如果是从外部打开的模块安装延迟1秒后自动退出
onBack() LaunchedEffect(flashing, flashIt) {
if (flashing == FlashingStatus.SUCCESS && flashIt is FlashIt.FlashModules) {
val intent = activity?.intent
val isFromExternalIntent = intent?.action?.let { action ->
action == Intent.ACTION_VIEW ||
action == Intent.ACTION_SEND ||
action == Intent.ACTION_SEND_MULTIPLE
} ?: false
if (isFromExternalIntent) {
delay(1000)
activity.finish()
}
}
} }
Scaffold( Scaffold(
topBar = { topBar = {
TopBar( TopBar(
currentFlashingStatus.value, flashing,
currentStatus, onBack = dropUnlessResumed { navigator.popBackStack() },
onBack = onBack,
onSave = { onSave = {
scope.launch { scope.launch {
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault()) val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
@@ -420,15 +184,22 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
"KernelSU_install_log_${date}.log" "KernelSU_install_log_${date}.log"
) )
file.writeText(logContent.toString()) file.writeText(logContent.toString())
snackBarHost.showSnackbar(logSavedString.format(file.absolutePath)) Toast.makeText(context, "Log saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
} }
}, },
scrollBehavior = scrollBehavior
) )
}, },
floatingActionButton = { floatingActionButton = {
if (showFloatAction) { if (showFloatAction) {
ExtendedFloatingActionButton( val reboot = stringResource(id = R.string.reboot)
FloatingActionButton(
modifier = Modifier
.padding(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding() + 20.dp,
end = 20.dp
)
.border(0.05.dp, colorScheme.outline.copy(alpha = 0.5f), CircleShape),
onClick = { onClick = {
scope.launch { scope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -436,25 +207,22 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
} }
} }
}, },
icon = { shadowElevation = 0.dp,
content = {
Icon( Icon(
Icons.Filled.Refresh, Icons.Rounded.Refresh,
contentDescription = stringResource(id = R.string.reboot) reboot,
Modifier.size(40.dp),
tint = Color.White
) )
}, },
text = {
Text(text = stringResource(id = R.string.reboot))
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
expanded = true
) )
} }
}, },
snackbarHost = { SnackbarHost(hostState = snackBarHost) }, popupHost = { },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
containerColor = MaterialTheme.colorScheme.background
) { innerPadding -> ) { innerPadding ->
val layoutDirection = LocalLayoutDirection.current
KeyEventBlocker { KeyEventBlocker {
it.key == Key.VolumeDown || it.key == Key.VolumeUp it.key == Key.VolumeDown || it.key == Key.VolumeUp
} }
@@ -462,307 +230,107 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize(1f) .fillMaxSize(1f)
.padding(innerPadding) .scrollEndHaptic()
.nestedScroll(scrollBehavior.nestedScrollConnection), .padding(
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateStartPadding(layoutDirection),
)
.verticalScroll(scrollState),
) { ) {
if (flashIt is FlashIt.FlashModules) { LaunchedEffect(text) {
ModuleInstallProgressBar( scrollState.animateScrollTo(scrollState.maxValue)
currentIndex = flashIt.currentIndex + 1,
totalCount = flashIt.uris.size,
currentModuleName = currentStatus.currentModuleName,
status = currentFlashingStatus.value,
failedModules = currentStatus.failedModules
)
Spacer(modifier = Modifier.height(8.dp))
} }
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
Box( Text(
modifier = Modifier modifier = Modifier.padding(8.dp),
.fillMaxWidth() text = text,
.weight(1f) fontSize = 12.sp,
.verticalScroll(scrollState) fontFamily = FontFamily.Monospace,
) {
LaunchedEffect(text) {
scrollState.animateScrollTo(scrollState.maxValue)
}
Text(
modifier = Modifier.padding(16.dp),
text = text,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
// 显示模块安装进度条和状态
@Composable
fun ModuleInstallProgressBar(
currentIndex: Int,
totalCount: Int,
currentModuleName: String,
status: FlashingStatus,
failedModules: List<String>
) {
val progressColor = when(status) {
FlashingStatus.FLASHING -> MaterialTheme.colorScheme.primary
FlashingStatus.SUCCESS -> MaterialTheme.colorScheme.tertiary
FlashingStatus.FAILED -> MaterialTheme.colorScheme.error
}
val progress = animateFloatAsState(
targetValue = currentIndex.toFloat() / totalCount.toFloat(),
label = "InstallProgress"
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// 模块名称和进度
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = currentModuleName.ifEmpty { stringResource(R.string.module) },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "$currentIndex/$totalCount",
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.height(8.dp))
// 进度条
LinearProgressIndicator(
progress = { progress.value },
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
) )
Spacer(
Spacer(modifier = Modifier.height(8.dp)) Modifier.height(
12.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
// 失败模块列表 WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
AnimatedVisibility(
visible = failedModules.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.module_failed_count, failedModules.size),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(4.dp))
// 失败模块列表
Column(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
shape = MaterialTheme.shapes.small
)
.padding(8.dp)
) {
failedModules.forEach { moduleName ->
Text(
text = "$moduleName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
status: FlashingStatus,
moduleStatus: ModuleInstallStatus = ModuleInstallStatus(),
onBack: () -> Unit,
onSave: () -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null
) {
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
val cardAlpha = CardConfig.cardAlpha
val statusColor = when(status) {
FlashingStatus.FLASHING -> MaterialTheme.colorScheme.primary
FlashingStatus.SUCCESS -> MaterialTheme.colorScheme.tertiary
FlashingStatus.FAILED -> MaterialTheme.colorScheme.error
}
TopAppBar(
title = {
Column {
Text(
text = stringResource(
when (status) {
FlashingStatus.FLASHING -> R.string.flashing
FlashingStatus.SUCCESS -> R.string.flash_success
FlashingStatus.FAILED -> R.string.flash_failed
}
),
style = MaterialTheme.typography.titleLarge,
color = statusColor
) )
)
if (moduleStatus.failedModules.isNotEmpty()) {
Text(
text = stringResource(R.string.module_failed_count, moduleStatus.failedModules.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
),
actions = {
IconButton(onClick = onSave) {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = stringResource(id = R.string.save_log),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior
)
}
suspend fun getModuleNameFromUri(context: Context, uri: Uri): String {
return withContext(Dispatchers.IO) {
try {
if (uri == Uri.EMPTY) {
return@withContext context.getString(R.string.unknown_module)
}
if (!ModuleUtils.isUriAccessible(context, uri)) {
return@withContext context.getString(R.string.unknown_module)
}
ModuleUtils.extractModuleName(context, uri)
} catch (_: Exception) {
context.getString(R.string.unknown_module)
} }
} }
} }
@Parcelize @Parcelize
sealed class FlashIt : Parcelable { sealed class FlashIt : Parcelable {
data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) : FlashIt() data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) :
data class FlashModule(val uri: Uri) : FlashIt() FlashIt()
data class FlashModules(val uris: List<Uri>, val currentIndex: Int = 0) : FlashIt()
data class FlashModuleUpdate(val uri: Uri) : FlashIt() // 模块更新
data object FlashRestore : FlashIt()
data object FlashUninstall : FlashIt()
}
// 模块更新刷写 data class FlashModules(val uris: List<Uri>) : FlashIt()
fun flashModuleUpdate(
uri: Uri, data object FlashRestore : FlashIt()
onFinish: (Boolean, Int) -> Unit,
onStdout: (String) -> Unit, data object FlashUninstall : FlashIt()
onStderr: (String) -> Unit
) {
flashModule(uri, onFinish, onStdout, onStderr)
} }
fun flashIt( fun flashIt(
flashIt: FlashIt, flashIt: FlashIt,
onFinish: (Boolean, Int) -> Unit,
onStdout: (String) -> Unit, onStdout: (String) -> Unit,
onStderr: (String) -> Unit onStderr: (String) -> Unit
) { ): FlashResult {
when (flashIt) { return when (flashIt) {
is FlashIt.FlashBoot -> installBoot( is FlashIt.FlashBoot -> installBoot(
flashIt.boot, flashIt.boot,
flashIt.lkm, flashIt.lkm,
flashIt.ota, flashIt.ota,
flashIt.partition, flashIt.partition,
onFinish,
onStdout, onStdout,
onStderr onStderr
) )
is FlashIt.FlashModule -> flashModule(flashIt.uri, onFinish, onStdout, onStderr)
is FlashIt.FlashModules -> { is FlashIt.FlashModules -> {
if (flashIt.uris.isEmpty() || flashIt.currentIndex >= flashIt.uris.size) { flashModulesSequentially(flashIt.uris, onStdout, onStderr)
onFinish(false, 0)
return
}
val currentUri = flashIt.uris[flashIt.currentIndex]
onStdout("\n")
flashModule(currentUri, onFinish, onStdout, onStderr)
} }
is FlashIt.FlashModuleUpdate -> {
onFinish(false, 0) FlashIt.FlashRestore -> restoreBoot(onStdout, onStderr)
}
FlashIt.FlashRestore -> restoreBoot(onFinish, onStdout, onStderr) FlashIt.FlashUninstall -> uninstallPermanently(onStdout, onStderr)
FlashIt.FlashUninstall -> uninstallPermanently(onFinish, onStdout, onStderr)
} }
} }
@Preview
@Composable @Composable
fun FlashScreenPreview() { private fun TopBar(
FlashScreen(EmptyDestinationsNavigator, FlashIt.FlashUninstall) status: FlashingStatus,
} onBack: () -> Unit = {},
onSave: () -> Unit = {},
) {
SmallTopAppBar(
title = stringResource(
when (status) {
FlashingStatus.FLASHING -> R.string.flashing
FlashingStatus.SUCCESS -> R.string.flash_success
FlashingStatus.FAILED -> R.string.flash_failed
}
),
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack
) {
Icon(
MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onBackground
)
}
},
actions = {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = onSave
) {
Icon(
imageVector = MiuixIcons.Useful.Move,
contentDescription = stringResource(id = R.string.save_log),
tint = colorScheme.onBackground
)
}
},
)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +1,47 @@
package com.sukisu.ultra.ui.screen package com.sukisu.ultra.ui.screen
import android.content.Context import android.content.Context
import android.os.Process.myUid
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.*
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
import com.sukisu.ultra.ui.theme.getCardColors
import com.sukisu.ultra.ui.theme.getCardElevation
import com.sukisu.ultra.ui.util.* import com.sukisu.ultra.ui.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.*
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
import top.yukonga.miuix.kmp.icon.icons.useful.Refresh
import top.yukonga.miuix.kmp.icon.icons.basic.Search
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.time.* import java.time.*
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import android.os.Process.myUid
import androidx.core.content.edit
private val SPACING_SMALL = 4.dp private val SPACING_SMALL = 4.dp
private val SPACING_MEDIUM = 8.dp private val SPACING_MEDIUM = 8.dp
@@ -104,12 +102,9 @@ private fun loadExcludedSubTypes(context: Context): Set<LogExclType> {
}.toSet() }.toSet()
} }
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph> @Destination<RootGraph>
@Composable @Composable
fun LogViewerScreen(navigator: DestinationsNavigator) { fun LogViewer(navigator: DestinationsNavigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val snackBarHost = LocalSnackbarHost.current
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -141,10 +136,8 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
entry.details.contains(searchQuery, ignoreCase = true) || entry.details.contains(searchQuery, ignoreCase = true) ||
entry.uid.contains(searchQuery, ignoreCase = true) entry.uid.contains(searchQuery, ignoreCase = true)
// 排除本应用
if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false
// 排除 SYSCALL 子类型
if (entry.type == LogType.SYSCALL) { if (entry.type == LogType.SYSCALL) {
val detail = entry.details val detail = entry.details
if (LogExclType.PRCTL_STAR in excludedSubTypes && detail.startsWith("Syscall: prctl") && !detail.startsWith("Syscall: prctl_unknown")) return@filter false if (LogExclType.PRCTL_STAR in excludedSubTypes && detail.startsWith("Syscall: prctl") && !detail.startsWith("Syscall: prctl_unknown")) return@filter false
@@ -152,14 +145,12 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
if (LogExclType.SETUID in excludedSubTypes && detail.startsWith("Syscall: setuid")) return@filter false if (LogExclType.SETUID in excludedSubTypes && detail.startsWith("Syscall: setuid")) return@filter false
} }
// 普通类型筛选
val matchesFilter = filterType == null || entry.type == filterType val matchesFilter = filterType == null || entry.type == filterType
matchesFilter && matchesSearch matchesFilter && matchesSearch
} }
} }
val loadingDialog = rememberLoadingDialog() var showClearDialog by remember { mutableStateOf(false) }
val confirmDialog = rememberConfirmDialog()
val loadPage: (Int, Boolean) -> Unit = { page, forceRefresh -> val loadPage: (Int, Boolean) -> Unit = { page, forceRefresh ->
scope.launch { scope.launch {
@@ -216,39 +207,65 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
Scaffold( Scaffold(
topBar = { topBar = {
LogViewerTopBar( TopAppBar(
scrollBehavior = scrollBehavior, title = stringResource(R.string.log_viewer_title),
onBackClick = { navigator.navigateUp() }, navigationIcon = {
showSearchBar = showSearchBar, IconButton(
searchQuery = searchQuery, onClick = { navigator.navigateUp() },
onSearchQueryChange = { searchQuery = it }, modifier = Modifier.padding(start = 12.dp)
onSearchToggle = { showSearchBar = !showSearchBar }, ) {
onRefresh = onManualRefresh, Icon(
onClearLogs = { imageVector = MiuixIcons.Useful.Back,
scope.launch { contentDescription = stringResource(R.string.log_viewer_back)
val result = confirmDialog.awaitConfirm( )
title = context.getString(R.string.log_viewer_clear_logs), }
content = context.getString(R.string.log_viewer_clear_logs_confirm) },
actions = {
IconButton(onClick = { showSearchBar = !showSearchBar }) {
Icon(
imageVector = MiuixIcons.Basic.Search,
contentDescription = stringResource(R.string.log_viewer_search)
)
}
IconButton(onClick = onManualRefresh) {
Icon(
imageVector = MiuixIcons.Useful.Refresh,
contentDescription = stringResource(R.string.log_viewer_refresh)
)
}
IconButton(
onClick = { showClearDialog = true },
modifier = Modifier.padding(end = 12.dp)
) {
Icon(
imageVector = MiuixIcons.Useful.Delete,
contentDescription = stringResource(R.string.log_viewer_clear_logs)
) )
if (result == ConfirmResult.Confirmed) {
loadingDialog.withLoading {
clearLogs()
loadPage(0, true)
}
snackBarHost.showSnackbar(context.getString(R.string.log_viewer_logs_cleared))
}
} }
} }
) )
}, }
snackbarHost = { SnackbarHost(snackBarHost) },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection)
) { ) {
AnimatedVisibility(
visible = showSearchBar,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
label = stringResource(R.string.log_viewer_search_placeholder)
)
}
LogControlPanel( LogControlPanel(
filterType = filterType, filterType = filterType,
onFilterTypeSelected = { filterType = it }, onFilterTypeSelected = { filterType = it },
@@ -264,7 +281,6 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
} }
) )
// 日志列表
if (isLoading && logEntries.isEmpty()) { if (isLoading && logEntries.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -288,8 +304,71 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
} }
} }
} }
val showClearDialogState = remember { mutableStateOf(showClearDialog) }
LaunchedEffect(showClearDialog) {
showClearDialogState.value = showClearDialog
}
LaunchedEffect(showClearDialogState.value) {
showClearDialog = showClearDialogState.value
}
SuperDialog(
show = showClearDialogState,
onDismissRequest = { showClearDialog = false },
content = {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 12.dp),
text = stringResource(R.string.log_viewer_clear_logs),
style = MiuixTheme.textStyles.title4,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
color = MiuixTheme.colorScheme.onSurface
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
text = stringResource(R.string.log_viewer_clear_logs_confirm),
style = MiuixTheme.textStyles.body2,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
text = stringResource(android.R.string.cancel),
onClick = { showClearDialog = false },
modifier = Modifier.weight(1f)
)
TextButton(
text = stringResource(android.R.string.ok),
onClick = {
showClearDialog = false
scope.launch {
clearLogs()
loadPage(0, true)
}
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary()
)
}
}
)
} }
private val CONTROL_PANEL_SPACING_SMALL = 4.dp
private val CONTROL_PANEL_SPACING_MEDIUM = 8.dp
private val CONTROL_PANEL_SPACING_LARGE = 12.dp
@Composable @Composable
private fun LogControlPanel( private fun LogControlPanel(
filterType: LogType?, filterType: LogType?,
@@ -305,31 +384,17 @@ private fun LogControlPanel(
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), .padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM)
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
elevation = getCardElevation()
) { ) {
Column { Column {
// 标题栏(点击展开/收起) SuperArrow(
Row( title = stringResource(R.string.log_viewer_settings),
modifier = Modifier onClick = { isExpanded = !isExpanded },
.fillMaxWidth() summary = if (isExpanded)
.clickable { isExpanded = !isExpanded } stringResource(R.string.log_viewer_collapse)
.padding(SPACING_LARGE), else
verticalAlignment = Alignment.CenterVertically, stringResource(R.string.log_viewer_expand)
horizontalArrangement = Arrangement.SpaceBetween )
) {
Text(
text = stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
AnimatedVisibility( AnimatedVisibility(
visible = isExpanded, visible = isExpanded,
@@ -337,77 +402,80 @@ private fun LogControlPanel(
exit = shrinkVertically() + fadeOut() exit = shrinkVertically() + fadeOut()
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = SPACING_LARGE) modifier = Modifier.padding(horizontal = SPACING_MEDIUM)
) { ) {
// 类型过滤
Text( Text(
text = stringResource(R.string.log_viewer_filter_type), text = stringResource(R.string.log_viewer_filter_type),
style = MaterialTheme.typography.titleSmall, style = MiuixTheme.textStyles.subtitle,
color = MaterialTheme.colorScheme.primary color = MiuixTheme.colorScheme.onSurfaceVariantActions
) )
Spacer(modifier = Modifier.height(SPACING_MEDIUM)) Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_MEDIUM))
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_MEDIUM)) {
item { item {
FilterChip( FilterChip(
onClick = { onFilterTypeSelected(null) }, text = stringResource(R.string.log_viewer_all_types),
label = { Text(stringResource(R.string.log_viewer_all_types)) }, selected = filterType == null,
selected = filterType == null onClick = { onFilterTypeSelected(null) }
) )
} }
items(LogType.entries.toTypedArray()) { type -> items(LogType.entries.toTypedArray()) { type ->
FilterChip( Row(
onClick = { onFilterTypeSelected(if (filterType == type) null else type) }, verticalAlignment = Alignment.CenterVertically,
label = { Text(type.displayName) }, horizontalArrangement = Arrangement.spacedBy(4.dp)
selected = filterType == type, ) {
leadingIcon = { Box(
Box( modifier = Modifier
modifier = Modifier .size(6.dp)
.size(8.dp) .background(type.color, RoundedCornerShape(3.dp))
.background(type.color, RoundedCornerShape(4.dp)) )
) FilterChip(
} text = type.displayName,
) selected = filterType == type,
onClick = { onFilterTypeSelected(if (filterType == type) null else type) }
)
}
} }
} }
Spacer(modifier = Modifier.height(SPACING_MEDIUM)) Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_LARGE))
// 排除子类型
Text( Text(
text = stringResource(R.string.log_viewer_exclude_subtypes), text = stringResource(R.string.log_viewer_exclude_subtypes),
style = MaterialTheme.typography.titleSmall, style = MiuixTheme.textStyles.subtitle,
color = MaterialTheme.colorScheme.primary color = MiuixTheme.colorScheme.onSurfaceVariantActions
) )
Spacer(modifier = Modifier.height(SPACING_MEDIUM)) Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_MEDIUM))
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_MEDIUM)) {
items(LogExclType.entries.toTypedArray()) { excl -> items(LogExclType.entries.toTypedArray()) { excl ->
val label = if (excl == LogExclType.CURRENT_APP) val label = if (excl == LogExclType.CURRENT_APP)
stringResource(R.string.log_viewer_exclude_current_app) stringResource(R.string.log_viewer_exclude_current_app)
else excl.displayName else excl.displayName
FilterChip( Row(
onClick = { onExcludeToggle(excl) }, verticalAlignment = Alignment.CenterVertically,
label = { Text(label) }, horizontalArrangement = Arrangement.spacedBy(4.dp)
selected = excl in excludedSubTypes, ) {
leadingIcon = { Box(
Box( modifier = Modifier
modifier = Modifier .size(6.dp)
.size(8.dp) .background(excl.color, RoundedCornerShape(3.dp))
.background(excl.color, RoundedCornerShape(4.dp)) )
) FilterChip(
} text = label,
) selected = excl in excludedSubTypes,
onClick = { onExcludeToggle(excl) }
)
}
} }
} }
Spacer(modifier = Modifier.height(SPACING_MEDIUM)) Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_LARGE))
// 统计信息 Column(verticalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_SMALL)) {
Column(verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)) {
Text( Text(
text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount), text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount),
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MiuixTheme.colorScheme.onSurfaceVariantSummary
) )
if (pageInfo.totalPages > 0) { if (pageInfo.totalPages > 0) {
Text( Text(
@@ -417,20 +485,20 @@ private fun LogControlPanel(
pageInfo.totalPages, pageInfo.totalPages,
pageInfo.totalLogs pageInfo.totalLogs
), ),
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MiuixTheme.colorScheme.onSurfaceVariantSummary
) )
} }
if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) { if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) {
Text( Text(
text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS), text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS),
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
color = MaterialTheme.colorScheme.error color = Color(0xFFE53935)
) )
} }
} }
Spacer(modifier = Modifier.height(SPACING_LARGE)) Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_LARGE))
} }
} }
} }
@@ -451,13 +519,12 @@ private fun LogList(
state = listState, state = listState,
modifier = modifier, modifier = modifier,
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
verticalArrangement = Arrangement.spacedBy(SPACING_SMALL) verticalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
) { ) {
items(entries) { entry -> items(entries) { entry ->
LogEntryCard(entry = entry) LogEntryCard(entry = entry)
} }
// 加载更多按钮或加载指示器
if (pageInfo.hasMore) { if (pageInfo.hasMore) {
item { item {
Box( Box(
@@ -475,12 +542,6 @@ private fun LogList(
onClick = onLoadMore, onClick = onLoadMore,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Icon(
imageVector = Icons.Filled.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text(stringResource(R.string.log_viewer_load_more)) Text(stringResource(R.string.log_viewer_load_more))
} }
} }
@@ -496,8 +557,8 @@ private fun LogList(
) { ) {
Text( Text(
text = stringResource(R.string.log_viewer_all_logs_loaded), text = stringResource(R.string.log_viewer_all_logs_loaded),
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MiuixTheme.colorScheme.onSurfaceVariantSummary
) )
} }
} }
@@ -510,14 +571,11 @@ private fun LogEntryCard(entry: LogEntry) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Card( Card(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() onClick = { expanded = !expanded }
.clickable { expanded = !expanded },
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) { ) {
Column( Column(
modifier = Modifier.padding(SPACING_MEDIUM) modifier = Modifier.padding(SPACING_LARGE)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -535,14 +593,14 @@ private fun LogEntryCard(entry: LogEntry) {
) )
Text( Text(
text = entry.type.displayName, text = entry.type.displayName,
style = MaterialTheme.typography.labelMedium, style = MiuixTheme.textStyles.subtitle,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
Text( Text(
text = entry.timestamp, text = entry.timestamp,
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MiuixTheme.colorScheme.onSurfaceVariantSummary
) )
} }
@@ -554,19 +612,19 @@ private fun LogEntryCard(entry: LogEntry) {
) { ) {
Text( Text(
text = "UID: ${entry.uid}", text = "UID: ${entry.uid}",
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MiuixTheme.colorScheme.onSurfaceVariantSummary
) )
Text( Text(
text = "PID: ${entry.pid}", text = "PID: ${entry.pid}",
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MiuixTheme.colorScheme.onSurfaceVariantSummary
) )
} }
Text( Text(
text = entry.comm, text = entry.comm,
style = MaterialTheme.typography.bodyMedium, style = MiuixTheme.textStyles.body1,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = if (expanded) Int.MAX_VALUE else 1, maxLines = if (expanded) Int.MAX_VALUE else 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -576,8 +634,8 @@ private fun LogEntryCard(entry: LogEntry) {
Spacer(modifier = Modifier.height(SPACING_SMALL)) Spacer(modifier = Modifier.height(SPACING_SMALL))
Text( Text(
text = entry.details, text = entry.details,
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = if (expanded) Int.MAX_VALUE else 2, maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -590,19 +648,19 @@ private fun LogEntryCard(entry: LogEntry) {
) { ) {
Column { Column {
Spacer(modifier = Modifier.height(SPACING_MEDIUM)) Spacer(modifier = Modifier.height(SPACING_MEDIUM))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) HorizontalDivider()
Spacer(modifier = Modifier.height(SPACING_MEDIUM)) Spacer(modifier = Modifier.height(SPACING_MEDIUM))
Text( Text(
text = stringResource(R.string.log_viewer_raw_log), text = stringResource(R.string.log_viewer_raw_log),
style = MaterialTheme.typography.labelMedium, style = MiuixTheme.textStyles.subtitle,
color = MaterialTheme.colorScheme.primary color = MiuixTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(SPACING_SMALL)) Spacer(modifier = Modifier.height(SPACING_SMALL))
Text( Text(
text = entry.rawLine, text = entry.rawLine,
style = MaterialTheme.typography.bodySmall, style = MiuixTheme.textStyles.body2,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MiuixTheme.colorScheme.onSurfaceVariantSummary
) )
} }
} }
@@ -623,141 +681,30 @@ private fun EmptyLogState(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(SPACING_LARGE) verticalArrangement = Arrangement.spacedBy(SPACING_LARGE)
) { ) {
Icon(
imageVector = if (hasLogs) Icons.Filled.FilterList else Icons.Filled.Description,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text( Text(
text = stringResource( text = stringResource(
if (hasLogs) R.string.log_viewer_no_matching_logs if (hasLogs) R.string.log_viewer_no_matching_logs
else R.string.log_viewer_no_logs else R.string.log_viewer_no_logs
), ),
style = MaterialTheme.typography.titleMedium, style = MiuixTheme.textStyles.headline2,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MiuixTheme.colorScheme.onSurfaceVariantSummary
) )
Button(onClick = onRefresh) { Button(
Icon( onClick = onRefresh
imageVector = Icons.Filled.Refresh, ) {
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text(stringResource(R.string.log_viewer_refresh)) Text(stringResource(R.string.log_viewer_refresh))
} }
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class) private suspend fun checkForNewLogs(lastHash: String): Boolean {
@Composable
private fun LogViewerTopBar(
scrollBehavior: TopAppBarScrollBehavior? = null,
onBackClick: () -> Unit,
showSearchBar: Boolean,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
onSearchToggle: () -> Unit,
onRefresh: () -> Unit,
onClearLogs: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
Column {
TopAppBar(
title = {
Text(
text = stringResource(R.string.log_viewer_title),
style = MaterialTheme.typography.titleLarge
)
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.log_viewer_back)
)
}
},
actions = {
IconButton(onClick = onSearchToggle) {
Icon(
imageVector = if (showSearchBar) Icons.Filled.SearchOff else Icons.Filled.Search,
contentDescription = stringResource(R.string.log_viewer_search)
)
}
IconButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.log_viewer_refresh)
)
}
IconButton(onClick = onClearLogs) {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = stringResource(R.string.log_viewer_clear_logs)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
),
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior
)
AnimatedVisibility(
visible = showSearchBar,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
placeholder = { Text(stringResource(R.string.log_viewer_search_placeholder)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null
)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchQueryChange("") }) {
Icon(
imageVector = Icons.Filled.Clear,
contentDescription = stringResource(R.string.log_viewer_clear_search)
)
}
}
},
singleLine = true
)
}
}
}
private suspend fun checkForNewLogs(
lastHash: String
): Boolean {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
val shell = getRootShell() val shell = getRootShell()
val logPath = "/data/adb/ksu/log/sulog.log" val logPath = "/data/adb/ksu/log/sulog.log"
val result = runCmd(shell, "stat -c '%Y %s' $logPath 2>/dev/null || echo '0 0'") val result = runCmd(shell, "stat -c '%Y %s' $logPath 2>/dev/null || echo '0 0'")
val currentHash = result.trim() val currentHash = result.trim()
currentHash != lastHash && currentHash != "0 0" currentHash != lastHash && currentHash != "0 0"
} catch (_: Exception) { } catch (_: Exception) {
false false
@@ -774,8 +721,6 @@ private suspend fun loadLogsWithPagination(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val shell = getRootShell() val shell = getRootShell()
// 获取文件信息
val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'") val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'")
val currentHash = statResult.trim() val currentHash = statResult.trim()
@@ -786,7 +731,6 @@ private suspend fun loadLogsWithPagination(
return@withContext return@withContext
} }
// 获取总行数
val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'") val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'")
val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0 val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0
@@ -797,11 +741,9 @@ private suspend fun loadLogsWithPagination(
return@withContext return@withContext
} }
// 限制最大日志数量
val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS) val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS)
val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE
// 计算要读取的行数范围
val startLine = if (page == 0) { val startLine = if (page == 0) {
maxOf(1, totalLines - effectiveTotal + 1) maxOf(1, totalLines - effectiveTotal + 1)
} else { } else {
@@ -860,6 +802,7 @@ private fun parseLogEntries(logContent: String): List<LogEntry> {
return entries.reversed() return entries.reversed()
} }
private fun utcToLocal(utc: String): String { private fun utcToLocal(utc: String): String {
return try { return try {
val instant = LocalDateTime.parse(utc, utcFormatter).atOffset(ZoneOffset.UTC).toInstant() val instant = LocalDateTime.parse(utc, utcFormatter).atOffset(ZoneOffset.UTC).toInstant()
@@ -871,7 +814,6 @@ private fun utcToLocal(utc: String): String {
} }
private fun parseLogLine(line: String): LogEntry? { private fun parseLogLine(line: String): LogEntry? {
// 解析格式: [timestamp] TYPE: UID=xxx COMM=xxx ...
val timestampRegex = """\[(.*?)]""".toRegex() val timestampRegex = """\[(.*?)]""".toRegex()
val timestampMatch = timestampRegex.find(line) ?: return null val timestampMatch = timestampRegex.find(line) ?: return null
val timestamp = utcToLocal(timestampMatch.groupValues[1]) val timestamp = utcToLocal(timestampMatch.groupValues[1])
@@ -895,7 +837,6 @@ private fun parseLogLine(line: String): LogEntry? {
val comm: String = extractValue(details, "COMM") ?: "" val comm: String = extractValue(details, "COMM") ?: ""
val pid: String = extractValue(details, "PID") ?: "" val pid: String = extractValue(details, "PID") ?: ""
// 构建详细信息字符串
val detailsStr = when (type) { val detailsStr = when (type) {
LogType.SU_GRANT -> { LogType.SU_GRANT -> {
val method: String = extractValue(details, "METHOD") ?: "" val method: String = extractValue(details, "METHOD") ?: ""
@@ -938,4 +879,23 @@ private fun parseLogLine(line: String): LogEntry? {
private fun extractValue(text: String, key: String): String? { private fun extractValue(text: String, key: String): String? {
val regex = """$key=(\S+)""".toRegex() val regex = """$key=(\S+)""".toRegex()
return regex.find(text)?.groupValues?.get(1) return regex.find(text)?.groupValues?.get(1)
} }
@Composable
private fun FilterChip(
text: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
TextButton(
text = text,
onClick = onClick,
modifier = modifier,
colors = if (selected) {
ButtonDefaults.textButtonColorsPrimary()
} else {
ButtonDefaults.textButtonColors()
}
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,843 @@
package com.sukisu.ultra.ui.screen
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.os.Parcelable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Link
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.ModuleRepoDetailScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import com.sukisu.ultra.R
import com.sukisu.ultra.ksuApp
import com.sukisu.ultra.ui.component.MarkdownContent
import com.sukisu.ultra.ui.component.SearchBox
import com.sukisu.ultra.ui.component.SearchPager
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.theme.isInDarkTheme
import com.sukisu.ultra.ui.util.DownloadListener
import com.sukisu.ultra.ui.util.download
import com.sukisu.ultra.ui.viewmodel.ModuleRepoViewModel
import okhttp3.Request
import org.json.JSONObject
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.HorizontalDivider
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.PullToRefresh
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTitle
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.NavigatorSwitch
import top.yukonga.miuix.kmp.icon.icons.useful.Save
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import top.yukonga.miuix.kmp.utils.getWindowSize
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
@Parcelize
data class ReleaseAssetArg(
val name: String,
val downloadUrl: String,
val size: Long
) : Parcelable
@Parcelize
data class ReleaseArg(
val tagName: String,
val name: String,
val publishedAt: String,
val assets: List<ReleaseAssetArg>
) : Parcelable
@Parcelize
data class AuthorArg(
val name: String,
val link: String,
) : Parcelable
@Parcelize
data class RepoModuleArg(
val moduleId: String,
val moduleName: String,
val authors: String,
val authorsList: List<AuthorArg>,
val homepageUrl: String,
val sourceUrl: String,
val latestRelease: String,
val latestReleaseTime: String,
val releases: List<ReleaseArg>
) : Parcelable
@SuppressLint("StringFormatInvalid")
@Composable
@Destination<RootGraph>
fun ModuleRepoScreen(
navigator: DestinationsNavigator
) {
val viewModel = viewModel<ModuleRepoViewModel>()
val searchStatus by viewModel.searchStatus
val context = LocalContext.current
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDark = isInDarkTheme(prefs.getInt("color_mode", 0))
val actionIconTint = colorScheme.onSurface.copy(alpha = if (isDark) 0.7f else 0.9f)
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
if (viewModel.modules.value.isEmpty()) {
viewModel.refresh()
}
}
val scrollBehavior = MiuixScrollBehavior()
val dynamicTopPadding by remember {
derivedStateOf { 12.dp * (1f - scrollBehavior.state.collapsedFraction) }
}
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
val onInstallModule: (Uri) -> Unit = { uri ->
navigator.navigate(FlashScreenDestination(FlashIt.FlashModules(listOf(uri)))) {
launchSingleTop = true
}
}
val confirmTitle = stringResource(R.string.module)
var pendingDownload by remember { mutableStateOf<(() -> Unit)?>(null) }
val confirmDialog = rememberConfirmDialog(onConfirm = { pendingDownload?.invoke() })
Scaffold(
topBar = {
searchStatus.TopAppBarAnim(hazeState = hazeState, hazeStyle = hazeStyle) {
TopAppBar(
color = Color.Transparent,
title = stringResource(R.string.module_repo),
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = { navigator.popBackStack() }
) {
Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onSurface
)
}
}
)
}
},
popupHost = {
searchStatus.SearchPager(defaultResult = {}) {
item {
Spacer(Modifier.height(6.dp))
}
items(viewModel.searchResults.value, key = { it.moduleId }) { module ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(bottom = 12.dp),
insideMargin = PaddingValues(16.dp),
showIndication = true,
pressFeedbackType = PressFeedbackType.Sink,
onClick = {
val args = RepoModuleArg(
moduleId = module.moduleId,
moduleName = module.moduleName,
authors = module.authors,
authorsList = module.authorList.map { AuthorArg(it.name, it.link) },
homepageUrl = module.homepageUrl,
sourceUrl = module.sourceUrl,
latestRelease = module.latestRelease,
latestReleaseTime = module.latestReleaseTime,
releases = module.releases.map { r ->
ReleaseArg(
tagName = r.tagName,
name = r.name,
publishedAt = r.publishedAt,
assets = r.assets.map { a ->
ReleaseAssetArg(name = a.name, downloadUrl = a.downloadUrl, size = a.size)
}
)
}
)
navigator.navigate(ModuleRepoDetailScreenDestination(args)) { launchSingleTop = true }
}
) {
Column {
if (module.moduleName.isNotBlank()) {
Text(
text = module.moduleName,
fontSize = 17.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurface
)
}
if (module.moduleId.isNotBlank()) {
Text(
text = "ID: ${module.moduleId}",
fontSize = 12.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
}
Text(
text = "${stringResource(id = R.string.module_author)}: ${module.authors}",
fontSize = 12.sp,
modifier = Modifier.padding(bottom = 1.dp),
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
if (module.summary.isNotBlank()) {
Text(
text = module.summary,
fontSize = 14.sp,
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(top = 2.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 4,
)
}
}
}
}
}
},
) { innerPadding ->
val layoutDirection = LocalLayoutDirection.current
val isLoading = viewModel.modules.value.isEmpty()
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
InfiniteProgressIndicator()
}
} else {
LaunchedEffect(searchStatus.searchText) { viewModel.updateSearchText(searchStatus.searchText) }
searchStatus.SearchBox(
searchBarTopPadding = dynamicTopPadding,
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding(),
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateEndPadding(layoutDirection)
),
hazeState = hazeState,
hazeStyle = hazeStyle
) { boxHeight ->
var isRefreshing by rememberSaveable { mutableStateOf(false) }
val pullToRefreshState = rememberPullToRefreshState()
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
delay(350)
viewModel.refresh()
isRefreshing = false
}
}
val refreshTexts = listOf(
stringResource(R.string.refresh_pulling),
stringResource(R.string.refresh_release),
stringResource(R.string.refresh_refresh),
stringResource(R.string.refresh_complete),
)
PullToRefresh(
isRefreshing = isRefreshing,
pullToRefreshState = pullToRefreshState,
onRefresh = { if (!isRefreshing) isRefreshing = true },
refreshTexts = refreshTexts,
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding() + boxHeight.value + 6.dp,
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateEndPadding(layoutDirection)
),
) {
LazyColumn(
modifier = Modifier
.height(getWindowSize().height.dp)
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.hazeSource(state = hazeState),
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding() + boxHeight.value + 6.dp,
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateEndPadding(layoutDirection)
),
overscrollEffect = null,
) {
items(
items = viewModel.modules.value,
key = { it.moduleId },
contentType = { "module" }
) { module ->
val latestTag = module.latestRelease
val latestRel = remember(module.releases, latestTag) {
module.releases.find { it.tagName == latestTag } ?: module.releases.firstOrNull()
}
val latestAsset = remember(latestRel) { latestRel?.assets?.firstOrNull() }
val moduleAuthor = stringResource(id = R.string.module_author)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(bottom = 12.dp),
insideMargin = PaddingValues(16.dp),
showIndication = true,
pressFeedbackType = PressFeedbackType.Sink,
onClick = {
val args = RepoModuleArg(
moduleId = module.moduleId,
moduleName = module.moduleName,
authors = module.authors,
authorsList = module.authorList.map { AuthorArg(it.name, it.link) },
homepageUrl = module.homepageUrl,
sourceUrl = module.sourceUrl,
latestRelease = module.latestRelease,
latestReleaseTime = module.latestReleaseTime,
releases = module.releases.map { r ->
ReleaseArg(
tagName = r.tagName,
name = r.name,
publishedAt = r.publishedAt,
assets = r.assets.map { a ->
ReleaseAssetArg(name = a.name, downloadUrl = a.downloadUrl, size = a.size)
}
)
}
)
navigator.navigate(ModuleRepoDetailScreenDestination(args)) {
launchSingleTop = true
}
}
) {
Column {
if (module.moduleName.isNotBlank()) {
Text(
text = module.moduleName,
fontSize = 17.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurface
)
}
if (module.moduleId.isNotBlank()) {
Text(
text = "ID: ${module.moduleId}",
fontSize = 12.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
}
Text(
text = "$moduleAuthor: ${module.authors}",
fontSize = 12.sp,
modifier = Modifier.padding(bottom = 1.dp),
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
if (module.summary.isNotBlank()) {
Text(
text = module.summary,
fontSize = 14.sp,
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(top = 2.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 4,
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Column {
Text(
text = latestTag,
fontSize = 12.sp,
modifier = Modifier.padding(top = 2.dp),
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
if (module.latestReleaseTime.isNotBlank()) {
Text(
text = module.latestReleaseTime,
fontSize = 12.sp,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.End
)
}
}
Spacer(Modifier.weight(1f))
if (latestAsset != null) {
val fileName = latestAsset.name
val downloadingText = stringResource(R.string.module_downloading)
IconButton(
backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.8f),
minHeight = 35.dp,
minWidth = 35.dp,
onClick = {
pendingDownload = {
scope.launch(Dispatchers.IO) {
download(
context,
latestAsset.downloadUrl,
fileName,
downloadingText.format(module.moduleId)
)
}
}
val confirmContent =
context.getString(R.string.module_install_prompt_with_name, fileName)
confirmDialog.showConfirm(
title = confirmTitle,
content = confirmContent
)
},
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = MiuixIcons.Useful.Save,
tint = actionIconTint,
contentDescription = stringResource(R.string.install)
)
Text(
modifier = Modifier.padding(start = 4.dp, end = 2.dp),
text = stringResource(R.string.install),
color = actionIconTint,
fontWeight = FontWeight.Medium,
fontSize = 15.sp
)
}
}
}
}
}
}
}
item { Spacer(Modifier.height(12.dp)) }
}
}
}
DownloadListener(context, onInstallModule)
}
}
}
@SuppressLint("StringFormatInvalid", "DefaultLocale")
@Composable
@Destination<RootGraph>
fun ModuleRepoDetailScreen(
navigator: DestinationsNavigator,
module: RepoModuleArg
) {
val context = LocalContext.current
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDark = isInDarkTheme(prefs.getInt("color_mode", 0))
val actionIconTint = colorScheme.onSurface.copy(alpha = if (isDark) 0.7f else 0.9f)
val secondaryContainer = colorScheme.secondaryContainer.copy(alpha = 0.8f)
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
val confirmTitle = stringResource(R.string.module)
var pendingDownload by remember { mutableStateOf<(() -> Unit)?>(null) }
val confirmDialog = rememberConfirmDialog(onConfirm = { pendingDownload?.invoke() })
var readmeText by remember(module.moduleId) { mutableStateOf<String?>(null) }
var readmeLoaded by remember(module.moduleId) { mutableStateOf(false) }
val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
},
color = Color.Transparent,
title = module.moduleId,
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = {
navigator.popBackStack()
}
) {
Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onSurface
)
}
},
actions = {
if (module.homepageUrl.isNotBlank()) {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = { uriHandler.openUri(module.homepageUrl) }
) {
Icon(
imageVector = MiuixIcons.Useful.NavigatorSwitch,
contentDescription = null,
tint = colorScheme.onBackground
)
}
}
}
)
}
) { innerPadding ->
LaunchedEffect(module.moduleId) {
if (module.moduleId.isNotEmpty()) {
withContext(Dispatchers.IO) {
runCatching {
val url = "https://modules.kernelsu.org/module/${module.moduleId}.json"
ksuApp.okhttpClient.newCall(Request.Builder().url(url).build()).execute().use { resp ->
if (!resp.isSuccessful) return@use
val body = resp.body?.string() ?: return@use
val obj = JSONObject(body)
val readme = obj.optString("readme", "")
readmeText = readme.ifBlank { null }
}
}.onSuccess {
readmeLoaded = true
}.onFailure {
readmeLoaded = true
}
}
} else {
readmeLoaded = true
}
}
LazyColumn(
modifier = Modifier
.height(getWindowSize().height.dp)
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.hazeSource(state = hazeState),
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding(),
bottom = innerPadding.calculateBottomPadding(),
),
) {
item {
AnimatedVisibility(
visible = readmeLoaded && readmeText != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column {
SmallTitle(text = "README")
Card(
modifier = Modifier
.padding(horizontal = 12.dp),
insideMargin = PaddingValues(16.dp)
) {
Column {
MarkdownContent(content = readmeText!!)
}
}
}
}
}
if (module.authorsList.isNotEmpty()) {
item {
SmallTitle(
text = "AUTHORS",
modifier = Modifier.padding(top = 10.dp)
)
}
item {
Card(
modifier = Modifier
.padding(horizontal = 12.dp),
insideMargin = PaddingValues(16.dp)
) {
Column {
module.authorsList.forEachIndexed { index, author ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = author.name,
fontSize = 14.sp,
color = colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
val clickable = author.link.isNotBlank()
val tint = if (clickable) actionIconTint else actionIconTint.copy(alpha = 0.35f)
IconButton(
backgroundColor = secondaryContainer,
minHeight = 35.dp,
minWidth = 35.dp,
enabled = clickable,
onClick = {
if (clickable) {
uriHandler.openUri(author.link)
}
},
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.Rounded.Link,
tint = tint,
contentDescription = null
)
}
}
if (index != module.authorsList.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
}
}
}
}
}
}
if (module.releases.isNotEmpty()) {
item {
SmallTitle(
text = "RELEASES",
modifier = Modifier.padding(top = 10.dp)
)
}
items(
items = module.releases,
key = { it.tagName },
contentType = { "release" }
) { rel ->
val title = remember(rel.name, rel.tagName) { rel.name.ifBlank { rel.tagName } }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(bottom = 8.dp),
insideMargin = PaddingValues(16.dp)
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontSize = 17.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurface
)
Text(
text = rel.tagName,
fontSize = 12.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(top = 2.dp)
)
}
Text(
text = rel.publishedAt,
fontSize = 12.sp,
color = colorScheme.onSurfaceVariantSummary,
)
}
AnimatedVisibility(
visible = rel.assets.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
rel.assets.forEachIndexed { index, asset ->
val fileName = asset.name
val downloadingText = stringResource(R.string.module_downloading)
val sizeText = remember(asset.size) {
val s = asset.size
when {
s >= 1024L * 1024L * 1024L -> String.format("%.1f GB", s / (1024f * 1024f * 1024f))
s >= 1024L * 1024L -> String.format("%.1f MB", s / (1024f * 1024f))
s >= 1024L -> String.format("%.0f KB", s / 1024f)
else -> "$s B"
}
}
val onClickDownload = remember(fileName, asset.downloadUrl) {
{
pendingDownload = {
scope.launch(Dispatchers.IO) {
download(
context,
asset.downloadUrl,
fileName,
downloadingText.format(module.moduleId)
)
}
}
val confirmContent = context.getString(R.string.module_install_prompt_with_name, fileName)
confirmDialog.showConfirm(
title = confirmTitle,
content = confirmContent
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = fileName,
fontSize = 14.sp,
color = colorScheme.onSurface
)
Text(
text = sizeText,
fontSize = 12.sp,
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(top = 2.dp)
)
}
IconButton(
backgroundColor = secondaryContainer,
minHeight = 35.dp,
minWidth = 35.dp,
onClick = onClickDownload,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = MiuixIcons.Useful.Save,
tint = actionIconTint,
contentDescription = stringResource(R.string.install)
)
Text(
modifier = Modifier.padding(start = 4.dp, end = 2.dp),
text = stringResource(R.string.install),
color = actionIconTint,
fontWeight = FontWeight.Medium,
fontSize = 15.sp
)
}
}
}
if (index != rel.assets.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
}
}
}
}
}
}
}
}
item { Spacer(Modifier.height(12.dp)) }
}
}
}

View File

@@ -1,32 +1,65 @@
package com.sukisu.ultra.ui.screen package com.sukisu.ultra.ui.screen
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.* import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.captionBar
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.Fingerprint
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.Group
import androidx.compose.material.icons.filled.ImportExport import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.* import androidx.compose.runtime.Composable
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.* import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.getSystemService
import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.compose.dropUnlessResumed
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
@@ -35,27 +68,57 @@ import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScr
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.ResultRecipient import com.ramcosta.composedestinations.result.ResultRecipient
import com.ramcosta.composedestinations.result.getOr import com.ramcosta.composedestinations.result.getOr
import com.sukisu.ultra.R import dev.chrisbanes.haze.HazeState
import com.sukisu.ultra.ui.theme.CardConfig import dev.chrisbanes.haze.HazeStyle
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.DropdownItem
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.FloatingActionButton
import top.yukonga.miuix.kmp.basic.HorizontalDivider
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.ListPopup
import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.PullToRefresh
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Copy
import top.yukonga.miuix.kmp.icon.icons.useful.Refresh
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import top.yukonga.miuix.kmp.utils.getWindowSize
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
/** /**
* @author weishu * @author weishu
* @date 2023/10/20. * @date 2023/10/20.
*/ */
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable @Composable
@Destination<RootGraph>
fun AppProfileTemplateScreen( fun AppProfileTemplateScreen(
navigator: DestinationsNavigator, navigator: DestinationsNavigator,
resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean> resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean>
) { ) {
val viewModel = viewModel<TemplateViewModel>() val viewModel = viewModel<TemplateViewModel>()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = MiuixScrollBehavior()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (viewModel.templateList.isEmpty()) { if (viewModel.templateList.isEmpty()) {
@@ -70,10 +133,45 @@ fun AppProfileTemplateScreen(
} }
} }
val listState = rememberLazyListState()
var fabVisible by remember { mutableStateOf(true) }
var scrollDistance by remember { mutableFloatStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val isScrolledToEnd =
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == listState.layoutInfo.totalItemsCount - 1
&& (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.size
?: 0) < listState.layoutInfo.viewportEndOffset)
val delta = available.y
if (!isScrolledToEnd) {
scrollDistance += delta
if (scrollDistance < -50f) {
if (fabVisible) fabVisible = false
scrollDistance = 0f
} else if (scrollDistance > 50f) {
if (!fabVisible) fabVisible = true
scrollDistance = 0f
}
}
return Offset.Zero
}
}
}
val offsetHeight by animateDpAsState(
targetValue = if (fabVisible) 0.dp else 100.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(),
animationSpec = tween(durationMillis = 350)
)
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
Scaffold( Scaffold(
topBar = { topBar = {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current val context = LocalContext.current
val clipboardManager = context.getSystemService<ClipboardManager>()
val showToast = fun(msg: String) { val showToast = fun(msg: String) {
scope.launch(Dispatchers.Main) { scope.launch(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
@@ -85,20 +183,20 @@ fun AppProfileTemplateScreen(
scope.launch { viewModel.fetchTemplates(true) } scope.launch { viewModel.fetchTemplates(true) }
}, },
onImport = { onImport = {
scope.launch { clipboardManager.getText()?.text?.let {
val clipboardText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString() if (it.isEmpty()) {
if (clipboardText.isNullOrEmpty()) {
showToast(context.getString(R.string.app_profile_template_import_empty)) showToast(context.getString(R.string.app_profile_template_import_empty))
return@launch return@let
}
scope.launch {
viewModel.importTemplates(
it, {
showToast(context.getString(R.string.app_profile_template_import_success))
viewModel.fetchTemplates(false)
},
showToast
)
} }
viewModel.importTemplates(
clipboardText,
{
showToast(context.getString(R.string.app_profile_template_import_success))
viewModel.fetchTemplates(false)
},
showToast
)
} }
}, },
onExport = { onExport = {
@@ -107,176 +205,300 @@ fun AppProfileTemplateScreen(
{ {
showToast(context.getString(R.string.app_profile_template_export_empty)) showToast(context.getString(R.string.app_profile_template_export_empty))
} }
) { text -> ) {
clipboardManager?.setPrimaryClip(ClipData.newPlainText("", text)) clipboardManager.setText(AnnotatedString(it))
} }
} }
}, },
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior,
hazeState = hazeState,
hazeStyle = hazeStyle,
) )
}, },
floatingActionButton = { floatingActionButton = {
ExtendedFloatingActionButton( FloatingActionButton(
containerColor = colorScheme.primary,
shadowElevation = 0.dp,
onClick = { onClick = {
navigator.navigate( navigator.navigate(TemplateEditorScreenDestination(TemplateViewModel.TemplateInfo(), false)) {
TemplateEditorScreenDestination( launchSingleTop = true
TemplateViewModel.TemplateInfo(), }
false },
) modifier = Modifier
.offset(y = offsetHeight)
.padding(
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding() + 20.dp,
end = 20.dp
)
.border(0.05.dp, colorScheme.outline.copy(alpha = 0.5f), CircleShape),
content = {
Icon(
Icons.Rounded.Add,
null,
Modifier.size(40.dp),
tint = Color.White
) )
}, },
icon = { Icon(Icons.Filled.Add, null) },
text = { Text(stringResource(id = R.string.app_profile_template_create)) },
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) )
}, },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) popupHost = { },
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
) { innerPadding -> ) { innerPadding ->
PullToRefreshBox( var isRefreshing by rememberSaveable { mutableStateOf(false) }
modifier = Modifier.padding(innerPadding), val pullToRefreshState = rememberPullToRefreshState()
isRefreshing = viewModel.isRefreshing, LaunchedEffect(isRefreshing) {
onRefresh = { if (isRefreshing) {
scope.launch { viewModel.fetchTemplates() } delay(350)
viewModel.fetchTemplates()
isRefreshing = false
} }
}
val refreshTexts = listOf(
stringResource(R.string.refresh_pulling),
stringResource(R.string.refresh_release),
stringResource(R.string.refresh_refresh),
stringResource(R.string.refresh_complete),
)
val layoutDirection = LocalLayoutDirection.current
PullToRefresh(
isRefreshing = isRefreshing,
pullToRefreshState = pullToRefreshState,
onRefresh = { isRefreshing = true },
refreshTexts = refreshTexts,
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding() + 12.dp,
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateEndPadding(layoutDirection)
),
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .height(getWindowSize().height.dp)
.nestedScroll(scrollBehavior.nestedScrollConnection), .scrollEndHaptic()
contentPadding = remember { .overScrollVertical()
PaddingValues(bottom = 16.dp + 56.dp + 16.dp /* Scaffold Fab Spacing + Fab container height */) .nestedScroll(nestedScrollConnection)
} .nestedScroll(scrollBehavior.nestedScrollConnection)
.hazeSource(state = hazeState)
.padding(horizontal = 12.dp),
contentPadding = innerPadding,
overscrollEffect = null
) { ) {
item {
Spacer(Modifier.height(12.dp))
}
items(viewModel.templateList, key = { it.id }) { app -> items(viewModel.templateList, key = { it.id }) { app ->
TemplateItem(navigator, app) TemplateItem(navigator, app)
} }
item {
Spacer(
Modifier.height(
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
)
)
}
} }
} }
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun TemplateItem( private fun TemplateItem(
navigator: DestinationsNavigator, navigator: DestinationsNavigator,
template: TemplateViewModel.TemplateInfo template: TemplateViewModel.TemplateInfo
) { ) {
ListItem( Card(
modifier = Modifier modifier = Modifier.padding(bottom = 12.dp),
.clickable { onClick = {
navigator.navigate(TemplateEditorScreenDestination(template, !template.local)) navigator.navigate(TemplateEditorScreenDestination(template, !template.local)) {
}, popUpTo(TemplateEditorScreenDestination) {
headlineContent = { Text(template.name) }, inclusive = true
supportingContent = { }
Column { launchSingleTop = true
}
},
showIndication = true,
pressFeedbackType = PressFeedbackType.Sink
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text( Text(
text = "${template.id}${if (template.author.isEmpty()) "" else "@${template.author}"}", text = template.name,
style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight(550),
fontSize = MaterialTheme.typography.bodySmall.fontSize, color = colorScheme.onSurface,
) )
Text(template.description) Spacer(modifier = Modifier.weight(1f))
FlowRow { if (template.local) {
LabelText(label = "UID: ${template.uid}") Text(
LabelText(label = "GID: ${template.gid}") text = "LOCAL",
LabelText(label = template.context) color = colorScheme.onTertiaryContainer,
if (template.local) { fontWeight = FontWeight(750),
LabelText(label = "local") style = MiuixTheme.textStyles.footnote1
} else { )
LabelText(label = "remote") } else {
} Text(
text = "REMOTE",
color = colorScheme.onSurfaceSecondary,
fontWeight = FontWeight(750),
style = MiuixTheme.textStyles.footnote1
)
} }
} }
Text(
text = "${template.id}${if (template.author.isEmpty()) "" else " by @${template.author}"}",
modifier = Modifier.padding(top = 1.dp),
fontSize = 12.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = template.description,
fontSize = 14.sp,
color = colorScheme.onSurfaceVariantSummary,
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
InfoChip(
icon = Icons.Outlined.Fingerprint,
text = "UID: ${template.uid}"
)
InfoChip(
icon = Icons.Outlined.Group,
text = "GID: ${template.gid}"
)
InfoChip(
icon = Icons.Outlined.Shield,
text = template.context
)
}
} }
) }
}
@Composable
private fun InfoChip(icon: ImageVector, text: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurfaceSecondary.copy(alpha = 0.8f)
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = text,
fontSize = 12.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceSecondary
)
}
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TopBar( private fun TopBar(
onBack: () -> Unit, onBack: () -> Unit,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
onImport: () -> Unit = {}, onImport: () -> Unit = {},
onExport: () -> Unit = {}, onExport: () -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null scrollBehavior: ScrollBehavior,
hazeState: HazeState,
hazeStyle: HazeStyle,
) { ) {
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
val cardAlpha = CardConfig.cardAlpha
TopAppBar( TopAppBar(
title = { modifier = Modifier.hazeEffect(hazeState) {
Text(stringResource(R.string.settings_profile_template)) style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
}, },
colors = TopAppBarDefaults.topAppBarColors( color = Color.Transparent,
containerColor = cardColor.copy(alpha = cardAlpha), title = stringResource(R.string.settings_profile_template),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
),
navigationIcon = { navigationIcon = {
IconButton( IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack onClick = onBack
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } ) {
Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onBackground
)
}
}, },
actions = { actions = {
IconButton(onClick = onSync) { IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = onSync
) {
Icon( Icon(
Icons.Filled.Sync, imageVector = MiuixIcons.Useful.Refresh,
contentDescription = stringResource(id = R.string.app_profile_template_sync) contentDescription = stringResource(id = R.string.app_profile_template_sync),
tint = colorScheme.onBackground
) )
} }
var showDropdown by remember { mutableStateOf(false) } val showTopPopup = remember { mutableStateOf(false) }
IconButton(onClick = { ListPopup(
showDropdown = true show = showTopPopup,
}) { popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
Icon( alignment = PopupPositionProvider.Align.TopRight,
imageVector = Icons.Filled.ImportExport, onDismissRequest = {
contentDescription = stringResource(id = R.string.app_profile_import_export) showTopPopup.value = false
) }
) {
DropdownMenu(expanded = showDropdown, onDismissRequest = { ListPopupColumn {
showDropdown = false val items = listOf(
}) { stringResource(id = R.string.app_profile_import_from_clipboard),
DropdownMenuItem(text = { stringResource(id = R.string.app_profile_export_to_clipboard)
Text(stringResource(id = R.string.app_profile_import_from_clipboard)) )
}, onClick = { items.forEachIndexed { index, text ->
onImport() DropdownItem(
showDropdown = false text = text,
}) optionSize = items.size,
DropdownMenuItem(text = { index = index,
Text(stringResource(id = R.string.app_profile_export_to_clipboard)) onSelectedIndexChange = { selectedIndex ->
}, onClick = { if (selectedIndex == 0) {
onExport() onImport()
showDropdown = false } else {
}) onExport()
}
showTopPopup.value = false
}
)
}
} }
} }
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = { showTopPopup.value = true },
holdDownState = showTopPopup.value
) {
Icon(
imageVector = MiuixIcons.Useful.Copy,
contentDescription = stringResource(id = R.string.app_profile_import_export),
tint = colorScheme.onBackground
)
}
}, },
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
} }
@Composable
fun LabelText(label: String) {
Box(
modifier = Modifier
.padding(top = 4.dp, end = 4.dp)
.background(
Color.Black,
shape = RoundedCornerShape(4.dp)
)
) {
Text(
text = label,
modifier = Modifier.padding(vertical = 2.dp, horizontal = 5.dp),
style = TextStyle(
fontSize = 8.sp,
color = Color.White,
)
)
}
}

View File

@@ -2,47 +2,75 @@ package com.sukisu.ultra.ui.screen
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.captionBar
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable
import androidx.compose.material.icons.Icons import androidx.compose.runtime.getValue
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.mutableStateOf
import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.runtime.remember
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultBackNavigator
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import com.sukisu.ultra.Natives import com.sukisu.ultra.Natives
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.EditText
import com.sukisu.ultra.ui.component.profile.RootProfileConfig import com.sukisu.ultra.ui.component.profile.RootProfileConfig
import com.sukisu.ultra.ui.util.deleteAppProfileTemplate import com.sukisu.ultra.ui.util.deleteAppProfileTemplate
import com.sukisu.ultra.ui.util.getAppProfileTemplate import com.sukisu.ultra.ui.util.getAppProfileTemplate
import com.sukisu.ultra.ui.util.setAppProfileTemplate import com.sukisu.ultra.ui.util.setAppProfileTemplate
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
import com.sukisu.ultra.ui.viewmodel.toJSON import com.sukisu.ultra.ui.viewmodel.toJSON
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Confirm
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.getWindowSize
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
/** /**
* @author weishu * @author weishu
* @date 2023/10/20. * @date 2023/10/20.
*/ */
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable @Composable
@Destination<RootGraph>
fun TemplateEditorScreen( fun TemplateEditorScreen(
navigator: ResultBackNavigator<Boolean>, navigator: ResultBackNavigator<Boolean>,
initialTemplate: TemplateViewModel.TemplateInfo, initialTemplate: TemplateViewModel.TemplateInfo,
@@ -56,7 +84,12 @@ fun TemplateEditorScreen(
mutableStateOf(initialTemplate) mutableStateOf(initialTemplate)
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
BackHandler { BackHandler {
navigator.navigateBack(result = !readOnly) navigator.navigateBack(result = !readOnly)
@@ -64,15 +97,9 @@ fun TemplateEditorScreen(
Scaffold( Scaffold(
topBar = { topBar = {
val author =
if (initialTemplate.author.isNotEmpty()) "@${initialTemplate.author}" else ""
val readOnlyHint = if (readOnly) {
" - ${stringResource(id = R.string.app_profile_template_readonly)}"
} else {
""
}
val titleSummary = "${initialTemplate.id}$author$readOnlyHint"
val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed) val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
val context = LocalContext.current val context = LocalContext.current
TopBar( TopBar(
@@ -84,7 +111,7 @@ fun TemplateEditorScreen(
stringResource(R.string.app_profile_template_edit) stringResource(R.string.app_profile_template_edit)
}, },
readOnly = readOnly, readOnly = readOnly,
summary = titleSummary, isCreation = isCreation,
onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) }, onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) },
onDelete = { onDelete = {
if (deleteAppProfileTemplate(template.id)) { if (deleteAppProfileTemplate(template.id)) {
@@ -92,106 +119,146 @@ fun TemplateEditorScreen(
} }
}, },
onSave = { onSave = {
when (idCheck(template.id)) {
0 -> Unit
1 -> {
Toast.makeText(context, idConflictError, Toast.LENGTH_SHORT).show()
return@TopBar
}
2 -> {
Toast.makeText(context, idInvalidError, Toast.LENGTH_SHORT).show()
return@TopBar
}
}
if (saveTemplate(template, isCreation)) { if (saveTemplate(template, isCreation)) {
navigator.navigateBack(result = true) navigator.navigateBack(result = true)
} else { } else {
Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show() Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show()
} }
}, },
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior,
hazeState = hazeState,
hazeStyle = hazeStyle,
) )
}, },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) popupHost = { },
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
) { innerPadding -> ) { innerPadding ->
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.padding(innerPadding) .height(getWindowSize().height.dp)
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(scrollBehavior.nestedScrollConnection)
.verticalScroll(rememberScrollState()) .hazeSource(state = hazeState)
.pointerInteropFilter { .pointerInteropFilter {
// disable click and ripple if readOnly // disable click and ripple if readOnly
readOnly readOnly
} },
contentPadding = innerPadding,
overscrollEffect = null
) { ) {
if (isCreation) { item {
var errorHint by remember { Card(
mutableStateOf("") modifier = Modifier
} .fillMaxWidth()
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist) .padding(12.dp),
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid) ) {
TextEdit( var errorHint by rememberSaveable { mutableStateOf(false) }
label = stringResource(id = R.string.app_profile_template_id),
text = template.id,
errorHint = errorHint,
isError = errorHint.isNotEmpty()
) { value ->
errorHint = if (isTemplateExist(value)) {
idConflictError
} else if (!isValidTemplateId(value)) {
idInvalidError
} else {
""
}
template = template.copy(id = value)
}
}
TextEdit( TextEdit(
label = stringResource(id = R.string.app_profile_template_name), label = stringResource(id = R.string.app_profile_template_name),
text = template.name text = template.name
) { value -> ) { value ->
template.copy(name = value).run { template.copy(name = value).run {
if (autoSave) { if (autoSave) {
if (!saveTemplate(this)) { if (!saveTemplate(this)) {
// failed // failed
return@run return@run
}
}
template = this
} }
} }
template = this
} TextEdit(
} label = stringResource(id = R.string.app_profile_template_id),
TextEdit( text = template.id,
label = stringResource(id = R.string.app_profile_template_description), isError = errorHint
text = template.description ) { value ->
) { value -> errorHint = value.isNotEmpty() && (isTemplateExist(value) || !isValidTemplateId(value))
template.copy(description = value).run { template = template.copy(id = value)
if (autoSave) { }
if (!saveTemplate(this)) { TextEdit(
// failed label = stringResource(R.string.module_author),
return@run text = template.author
) { value ->
template.copy(author = value).run {
if (autoSave) {
if (!saveTemplate(this)) {
// failed
return@run
}
}
template = this
} }
} }
template = this
}
}
RootProfileConfig(fixedName = true, TextEdit(
profile = toNativeProfile(template), label = stringResource(id = R.string.app_profile_template_description),
onProfileChange = { text = template.description
template.copy( ) { value ->
uid = it.uid, template.copy(description = value).run {
gid = it.gid, if (autoSave) {
groups = it.groups, if (!saveTemplate(this)) {
capabilities = it.capabilities, // failed
context = it.context, return@run
namespace = it.namespace, }
rules = it.rules.split("\n") }
).run { template = this
if (autoSave) { }
if (!saveTemplate(this)) { }
// failed
return@run RootProfileConfig(
fixedName = true,
profile = toNativeProfile(template),
onProfileChange = {
template.copy(
uid = it.uid,
gid = it.gid,
groups = it.groups,
capabilities = it.capabilities,
context = it.context,
namespace = it.namespace,
rules = it.rules.split("\n")
).run {
if (autoSave) {
if (!saveTemplate(this)) {
// failed
return@run
}
}
template = this
} }
} }
template = this )
} }
}) Spacer(
Modifier.height(
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
)
)
}
} }
} }
} }
fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile { fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile {
return Natives.Profile().copy(rootTemplate = templateInfo.id, return Natives.Profile().copy(
rootTemplate = templateInfo.id,
uid = templateInfo.uid, uid = templateInfo.uid,
gid = templateInfo.gid, gid = templateInfo.gid,
groups = templateInfo.groups, groups = templateInfo.groups,
@@ -213,6 +280,10 @@ fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean {
return true return true
} }
fun idCheck(value: String): Int {
return if (value.isEmpty()) 0 else if (isTemplateExist(value)) 1 else if (!isValidTemplateId(value)) 2 else 0
}
fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean { fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean {
if (!isTemplateValid(template)) { if (!isTemplateValid(template)) {
return false return false
@@ -227,50 +298,67 @@ fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean =
return setAppProfileTemplate(template.id, json.toString()) return setAppProfileTemplate(template.id, json.toString())
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TopBar( private fun TopBar(
title: String, title: String,
readOnly: Boolean, readOnly: Boolean,
summary: String = "", isCreation: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onSave: () -> Unit = {}, onSave: () -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null scrollBehavior: ScrollBehavior,
hazeState: HazeState,
hazeStyle: HazeStyle,
) { ) {
TopAppBar( TopAppBar(
title = { modifier = Modifier.hazeEffect(hazeState) {
Column { style = hazeStyle
Text(title) blurRadius = 30.dp
if (summary.isNotBlank()) { noiseFactor = 0f
Text( },
text = summary, color = Color.Transparent,
style = MaterialTheme.typography.bodyMedium, title = title,
) navigationIcon = {
}
}
}, navigationIcon = {
IconButton( IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = onBack onClick = onBack
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } ) {
}, actions = {
if (readOnly) {
return@TopAppBar
}
IconButton(onClick = onDelete) {
Icon( Icon(
Icons.Filled.DeleteForever, imageVector = MiuixIcons.Useful.Back,
contentDescription = stringResource(id = R.string.app_profile_template_delete) contentDescription = null,
) tint = colorScheme.onSurface
}
IconButton(onClick = onSave) {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = stringResource(id = R.string.app_profile_template_save)
) )
} }
}, },
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), actions = {
when {
!readOnly && !isCreation -> {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = onDelete
) {
Icon(
imageVector = MiuixIcons.Useful.Delete,
contentDescription = stringResource(id = R.string.app_profile_template_delete),
tint = colorScheme.onBackground
)
}
}
isCreation -> {
IconButton(
modifier = Modifier.padding(end = 16.dp),
onClick = onSave
) {
Icon(
imageVector = MiuixIcons.Useful.Confirm,
contentDescription = stringResource(id = R.string.app_profile_template_save),
tint = colorScheme.onBackground
)
}
}
}
},
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
} }
@@ -279,35 +367,22 @@ private fun TopBar(
private fun TextEdit( private fun TextEdit(
label: String, label: String,
text: String, text: String,
errorHint: String = "",
isError: Boolean = false, isError: Boolean = false,
onValueChange: (String) -> Unit = {} onValueChange: (String) -> Unit = {}
) { ) {
ListItem(headlineContent = { val editText = remember { mutableStateOf(text) }
val keyboardController = LocalSoftwareKeyboardController.current EditText(
OutlinedTextField( title = label.uppercase(),
value = text, textValue = editText,
modifier = Modifier.fillMaxWidth(), onTextValueChange = { newText ->
label = { Text(label) }, editText.value = newText
suffix = { onValueChange(newText)
if (errorHint.isNotBlank()) { },
Text( keyboardOptions = KeyboardOptions(
text = if (isError) errorHint else "", keyboardType = KeyboardType.Ascii,
style = MaterialTheme.typography.bodySmall, ),
color = MaterialTheme.colorScheme.error isError = isError,
) )
}
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
onValueChange = onValueChange
)
})
} }
private fun isValidTemplateId(id: String): Boolean { private fun isValidTemplateId(id: String): Boolean {

View File

@@ -1,22 +1,22 @@
package com.sukisu.ultra.ui.screen package com.sukisu.ultra.ui.screen
import android.content.Context import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.* import androidx.compose.material.icons.rounded.Add
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.annotation.RootGraph
@@ -24,13 +24,30 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.rememberConfirmDialog import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.component.ConfirmResult import com.sukisu.ultra.ui.component.ConfirmResult
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.theme.getCardColors
import com.sukisu.ultra.ui.theme.getCardElevation
import com.sukisu.ultra.ui.util.* import com.sukisu.ultra.ui.util.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.FloatingActionButton
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
import top.yukonga.miuix.kmp.icon.icons.useful.Refresh
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.getWindowSize
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
private val SPACING_SMALL = 3.dp private val SPACING_SMALL = 3.dp
private val SPACING_MEDIUM = 8.dp private val SPACING_MEDIUM = 8.dp
@@ -38,16 +55,13 @@ private val SPACING_LARGE = 16.dp
data class UmountPathEntry( data class UmountPathEntry(
val path: String, val path: String,
val flags: Int, val flags: Int
val isDefault: Boolean
) )
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph> @Destination<RootGraph>
@Composable @Composable
fun UmountManagerScreen(navigator: DestinationsNavigator) { fun UmountManager(navigator: DestinationsNavigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = MiuixScrollBehavior()
val snackBarHost = LocalSnackbarHost.current
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val confirmDialog = rememberConfirmDialog() val confirmDialog = rememberConfirmDialog()
@@ -75,59 +89,64 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.umount_path_manager)) }, title = stringResource(R.string.umount_path_manager),
navigationIcon = { navigationIcon = {
IconButton(onClick = { navigator.navigateUp() }) { IconButton(onClick = { navigator.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = null
)
} }
}, },
actions = { actions = {
IconButton(onClick = { loadPaths() }) { IconButton(onClick = { loadPaths() }) {
Icon(Icons.Filled.Refresh, contentDescription = null) Icon(
imageVector = MiuixIcons.Useful.Refresh,
contentDescription = null
)
} }
}, },
scrollBehavior = scrollBehavior, color = Color.Transparent,
colors = TopAppBarDefaults.topAppBarColors( scrollBehavior = scrollBehavior
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(
alpha = CardConfig.cardAlpha
)
)
) )
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { showAddDialog = true } onClick = { showAddDialog = true }
) { ) {
Icon(Icons.Filled.Add, contentDescription = null) Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
tint = Color.White
)
} }
}, }
snackbarHost = { SnackbarHost(snackBarHost) }
) { paddingValues -> ) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(paddingValues) .padding(paddingValues)
.height(getWindowSize().height.dp)
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(scrollBehavior.nestedScrollConnection)
) { ) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(SPACING_LARGE), .padding(SPACING_LARGE)
colors = getCardColors(MaterialTheme.colorScheme.primaryContainer),
elevation = getCardElevation()
) { ) {
Column( Row(
modifier = Modifier.padding(SPACING_LARGE) modifier = Modifier.padding(SPACING_LARGE),
verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Info, imageVector = Icons.Outlined.Info,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer tint = colorScheme.primary
) )
Spacer(modifier = Modifier.height(SPACING_MEDIUM)) Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text( Text(
text = stringResource(R.string.umount_path_restart_notice), text = stringResource(R.string.umount_path_restart_notice)
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
) )
} }
} }
@@ -137,7 +156,7 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator() top.yukonga.miuix.kmp.basic.CircularProgressIndicator()
} }
} else { } else {
LazyColumn( LazyColumn(
@@ -153,14 +172,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
val success = removeUmountPath(entry.path) val success = removeUmountPath(entry.path)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (success) { if (success) {
snackBarHost.showSnackbar( Toast.makeText(
context.getString(R.string.umount_path_removed) context,
) context.getString(R.string.umount_path_removed),
Toast.LENGTH_SHORT
).show()
loadPaths() loadPaths()
} else { } else {
snackBarHost.showSnackbar( Toast.makeText(
context.getString(R.string.operation_failed) context,
) context.getString(R.string.operation_failed),
Toast.LENGTH_SHORT
).show()
} }
} }
} }
@@ -190,14 +213,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
val success = clearCustomUmountPaths() val success = clearCustomUmountPaths()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (success) { if (success) {
snackBarHost.showSnackbar( Toast.makeText(
context.getString(R.string.custom_paths_cleared) context,
) context.getString(R.string.custom_paths_cleared),
Toast.LENGTH_SHORT
).show()
loadPaths() loadPaths()
} else { } else {
snackBarHost.showSnackbar( Toast.makeText(
context.getString(R.string.operation_failed) context,
) context.getString(R.string.operation_failed),
Toast.LENGTH_SHORT
).show()
} }
} }
} }
@@ -206,9 +233,7 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Icon(Icons.Filled.DeleteForever, contentDescription = null) Text(text = stringResource(R.string.clear_custom_paths))
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text(stringResource(R.string.clear_custom_paths))
} }
Button( Button(
@@ -217,22 +242,24 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
val success = applyUmountConfigToKernel() val success = applyUmountConfigToKernel()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (success) { if (success) {
snackBarHost.showSnackbar( Toast.makeText(
context.getString(R.string.config_applied) context,
) context.getString(R.string.config_applied),
Toast.LENGTH_SHORT
).show()
} else { } else {
snackBarHost.showSnackbar( Toast.makeText(
context.getString(R.string.operation_failed) context,
) context.getString(R.string.operation_failed),
Toast.LENGTH_SHORT
).show()
} }
} }
} }
}, },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Icon(Icons.Filled.Check, contentDescription = null) Text(text = stringResource(R.string.apply_config))
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text(stringResource(R.string.apply_config))
} }
} }
} }
@@ -251,14 +278,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (success) { if (success) {
saveUmountConfig() saveUmountConfig()
snackBarHost.showSnackbar( Toast.makeText(
context.getString(R.string.umount_path_added) context,
) context.getString(R.string.umount_path_added),
Toast.LENGTH_SHORT
).show()
loadPaths() loadPaths()
} else { } else {
snackBarHost.showSnackbar( Toast.makeText(
context.getString(R.string.operation_failed) context,
) context.getString(R.string.operation_failed),
Toast.LENGTH_SHORT
).show()
} }
} }
} }
@@ -278,9 +309,7 @@ fun UmountPathCard(
val context = LocalContext.current val context = LocalContext.current
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth()
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
elevation = getCardElevation()
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -289,12 +318,9 @@ fun UmountPathCard(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Folder, imageVector = Icons.Outlined.Folder,
contentDescription = null, contentDescription = null,
tint = if (entry.isDefault) tint = colorScheme.primary,
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
@@ -302,8 +328,7 @@ fun UmountPathCard(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = entry.path, text = entry.path
style = MaterialTheme.typography.titleMedium
) )
Spacer(modifier = Modifier.height(SPACING_SMALL)) Spacer(modifier = Modifier.height(SPACING_SMALL))
Text( Text(
@@ -311,35 +336,28 @@ fun UmountPathCard(
append(context.getString(R.string.flags)) append(context.getString(R.string.flags))
append(": ") append(": ")
append(entry.flags.toUmountFlagName(context)) append(entry.flags.toUmountFlagName(context))
if (entry.isDefault) {
append(" | ")
append(context.getString(R.string.default_entry))
}
}, },
style = MaterialTheme.typography.bodySmall, color = colorScheme.onSurfaceVariantSummary
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (!entry.isDefault) { IconButton(
IconButton( onClick = {
onClick = { scope.launch {
scope.launch { if (confirmDialog.awaitConfirm(
if (confirmDialog.awaitConfirm( title = context.getString(R.string.confirm_delete),
title = context.getString(R.string.confirm_delete), content = context.getString(R.string.confirm_delete_umount_path, entry.path)
content = context.getString(R.string.confirm_delete_umount_path, entry.path) ) == ConfirmResult.Confirmed) {
) == ConfirmResult.Confirmed) { onDelete()
onDelete()
}
} }
} }
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
} }
) {
Icon(
imageVector = MiuixIcons.Useful.Delete,
contentDescription = null,
tint = colorScheme.primary
)
} }
} }
} }
@@ -352,50 +370,69 @@ fun AddUmountPathDialog(
) { ) {
var path by rememberSaveable { mutableStateOf("") } var path by rememberSaveable { mutableStateOf("") }
var flags by rememberSaveable { mutableStateOf("-1") } var flags by rememberSaveable { mutableStateOf("-1") }
val showDialog = remember { mutableStateOf(true) }
AlertDialog( SuperDialog(
onDismissRequest = onDismiss, show = showDialog,
title = { Text(stringResource(R.string.add_umount_path)) }, title = stringResource(R.string.add_umount_path),
text = { onDismissRequest = {
Column { showDialog.value = false
OutlinedTextField( onDismiss()
value = path, }
onValueChange = { path = it }, ) {
label = { Text(stringResource(R.string.mount_path)) }, TextField(
modifier = Modifier.fillMaxWidth(), value = path,
singleLine = true onValueChange = { path = it },
) label = stringResource(R.string.mount_path),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(SPACING_MEDIUM)) Spacer(modifier = Modifier.height(SPACING_MEDIUM))
OutlinedTextField( TextField(
value = flags, value = flags,
onValueChange = { flags = it }, onValueChange = { flags = it },
label = { Text(stringResource(R.string.umount_flags)) }, label = stringResource(R.string.umount_flags),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), )
supportingText = { Text(stringResource(R.string.umount_flags_hint)) }
) Spacer(modifier = Modifier.height(SPACING_SMALL))
}
}, Text(
confirmButton = { text = stringResource(R.string.umount_flags_hint),
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(start = SPACING_MEDIUM)
)
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton( TextButton(
text = stringResource(android.R.string.cancel),
onClick = {
showDialog.value = false
onDismiss()
},
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(20.dp))
TextButton(
text = stringResource(android.R.string.ok),
onClick = { onClick = {
val flagsInt = flags.toIntOrNull() ?: -1 val flagsInt = flags.toIntOrNull() ?: -1
showDialog.value = false
onConfirm(path, flagsInt) onConfirm(path, flagsInt)
}, },
modifier = Modifier.weight(1f),
enabled = path.isNotBlank() enabled = path.isNotBlank()
) { )
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
} }
) }
} }
private fun parseUmountPaths(output: String): List<UmountPathEntry> { private fun parseUmountPaths(output: String): List<UmountPathEntry> {
@@ -404,11 +441,10 @@ private fun parseUmountPaths(output: String): List<UmountPathEntry> {
return lines.drop(2).mapNotNull { line -> return lines.drop(2).mapNotNull { line ->
val parts = line.trim().split(Regex("\\s+")) val parts = line.trim().split(Regex("\\s+"))
if (parts.size >= 3) { if (parts.size >= 2) {
UmountPathEntry( UmountPathEntry(
path = parts[0], path = parts[0],
flags = parts[1].toIntOrNull() ?: -1, flags = parts[1].toIntOrNull() ?: -1
isDefault = parts[2].equals("Yes", ignoreCase = true)
) )
} else null } else null
} }

View File

@@ -0,0 +1,197 @@
package com.sukisu.ultra.ui.screen.settings
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import com.sukisu.ultra.R
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.utils.getWindowSize
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
@Composable
@Destination<RootGraph>
fun Personalization(
navigator: DestinationsNavigator
) {
val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
},
color = Color.Transparent,
title = stringResource(R.string.personalization),
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(
onClick = {
navigator.popBackStack()
}
) {
Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = "Back"
)
}
}
)
},
popupHost = { },
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
) { innerPadding ->
val context = LocalContext.current
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
LazyColumn(
modifier = Modifier
.height(getWindowSize().height.dp)
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.hazeSource(state = hazeState)
.padding(horizontal = 12.dp),
contentPadding = innerPadding,
overscrollEffect = null,
) {
item {
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
val themeItems = listOf(
stringResource(id = R.string.settings_theme_mode_system),
stringResource(id = R.string.settings_theme_mode_light),
stringResource(id = R.string.settings_theme_mode_dark),
stringResource(id = R.string.settings_theme_mode_monet_system),
stringResource(id = R.string.settings_theme_mode_monet_light),
stringResource(id = R.string.settings_theme_mode_monet_dark),
)
var themeMode by rememberSaveable {
mutableIntStateOf(prefs.getInt("color_mode", 0))
}
SuperDropdown(
title = stringResource(id = R.string.settings_theme),
summary = stringResource(id = R.string.settings_theme_summary),
items = themeItems,
leftAction = {
Icon(
Icons.Rounded.Palette,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(id = R.string.settings_theme),
tint = colorScheme.onBackground
)
},
selectedIndex = themeMode,
onSelectedIndexChange = { index ->
prefs.edit { putInt("color_mode", index) }
themeMode = index
}
)
AnimatedVisibility(
visible = themeMode in 3..5
) {
val colorItems = listOf(
stringResource(id = R.string.settings_key_color_default),
stringResource(id = R.string.color_blue),
stringResource(id = R.string.color_red),
stringResource(id = R.string.color_green),
stringResource(id = R.string.color_purple),
stringResource(id = R.string.color_orange),
stringResource(id = R.string.color_teal),
stringResource(id = R.string.color_pink),
stringResource(id = R.string.color_brown),
)
val colorValues = listOf(
0,
Color(0xFF1A73E8).toArgb(),
Color(0xFFEA4335).toArgb(),
Color(0xFF34A853).toArgb(),
Color(0xFF9333EA).toArgb(),
Color(0xFFFB8C00).toArgb(),
Color(0xFF009688).toArgb(),
Color(0xFFE91E63).toArgb(),
Color(0xFF795548).toArgb(),
)
var keyColorIndex by rememberSaveable {
mutableIntStateOf(
colorValues.indexOf(prefs.getInt("key_color", 0)).takeIf { it >= 0 } ?: 0
)
}
SuperDropdown(
title = stringResource(id = R.string.settings_key_color),
summary = stringResource(id = R.string.settings_key_color_summary),
items = colorItems,
leftAction = {
Icon(
Icons.Rounded.Palette,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(id = R.string.settings_key_color),
tint = colorScheme.onBackground
)
},
selectedIndex = keyColorIndex,
onSelectedIndexChange = { index ->
prefs.edit { putInt("key_color", colorValues[index]) }
keyColorIndex = index
}
)
}
}
}
}
}
}

View File

@@ -0,0 +1,558 @@
package com.sukisu.ultra.ui.screen.settings
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CleaningServices
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Scanner
import androidx.compose.material.icons.rounded.Backup
import androidx.compose.material.icons.rounded.FolderDelete
import androidx.compose.material.icons.rounded.Restore
import androidx.compose.material.icons.rounded.Security
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.generated.destinations.UmountManagerDestination
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.ConfirmResult
import com.sukisu.ultra.ui.component.DynamicManagerCard
import com.sukisu.ultra.ui.component.KsuIsValid
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.util.cleanRuntimeEnvironment
import com.sukisu.ultra.ui.util.getUidMultiUserScan
import com.sukisu.ultra.ui.util.readUidScannerFile
import com.sukisu.ultra.ui.util.setUidAutoScan
import com.sukisu.ultra.ui.util.setUidMultiUserScan
import com.sukisu.ultra.ui.util.getSELinuxStatus
import com.topjohnwu.superuser.Shell
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.getWindowSize
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
@Composable
@Destination<RootGraph>
fun Tools(
navigator: DestinationsNavigator
) {
val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
},
color = Color.Transparent,
title = stringResource(R.string.tools),
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(onClick = { navigator.popBackStack() }) {
Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = "Back"
)
}
}
)
},
popupHost = { },
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
) { innerPadding ->
LazyColumn(
modifier = Modifier
.height(getWindowSize().height.dp)
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.hazeSource(state = hazeState)
.padding(horizontal = 12.dp),
contentPadding = innerPadding,
overscrollEffect = null,
) {
item {
KsuIsValid {
SelinuxToggleSection(scope = scope, context = context)
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
UidScannerSection(prefs = prefs, scope = scope, context = context)
}
DynamicManagerCard()
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
val umontManager = stringResource(id = R.string.umount_path_manager)
SuperArrow(
title = umontManager,
leftAction = {
Icon(
Icons.Rounded.FolderDelete,
modifier = Modifier.padding(end = 16.dp),
contentDescription = umontManager,
tint = colorScheme.onBackground
)
},
onClick = {
navigator.navigate(UmountManagerDestination)
}
)
}
AllowlistBackupSection(scope = scope, context = context)
}
}
}
}
}
@Composable
fun UidScannerSection(
prefs: SharedPreferences,
scope: CoroutineScope,
context: Context
) {
val realAuto = Natives.isUidScannerEnabled()
val realMulti = getUidMultiUserScan()
var autoOn by remember { mutableStateOf(realAuto) }
var multiOn by remember { mutableStateOf(realMulti) }
LaunchedEffect(Unit) {
autoOn = realAuto
multiOn = realMulti
prefs.edit {
putBoolean("uid_auto_scan", autoOn)
putBoolean("uid_multi_user_scan", multiOn)
}
}
SuperSwitch(
title = stringResource(R.string.uid_auto_scan_title),
summary = stringResource(R.string.uid_auto_scan_summary),
leftAction = {
Icon(
imageVector = Icons.Filled.Scanner,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.uid_auto_scan_title),
tint = colorScheme.onBackground
)
},
checked = autoOn,
onCheckedChange = { target ->
autoOn = target
if (!target) multiOn = false
scope.launch(Dispatchers.IO) {
setUidAutoScan(target)
val actual = Natives.isUidScannerEnabled() || readUidScannerFile()
withContext(Dispatchers.Main) {
autoOn = actual
if (!actual) multiOn = false
prefs.edit {
putBoolean("uid_auto_scan", actual)
putBoolean("uid_multi_user_scan", multiOn)
}
if (actual != target) {
Toast.makeText(
context,
context.getString(R.string.uid_scanner_setting_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
}
)
AnimatedVisibility(
visible = autoOn,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
SuperSwitch(
title = stringResource(R.string.uid_multi_user_scan_title),
summary = stringResource(R.string.uid_multi_user_scan_summary),
leftAction = {
Icon(
imageVector = Icons.Filled.Groups,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.uid_multi_user_scan_title),
tint = colorScheme.onBackground
)
},
checked = multiOn,
onCheckedChange = { target ->
scope.launch(Dispatchers.IO) {
val ok = setUidMultiUserScan(target)
withContext(Dispatchers.Main) {
if (ok) {
multiOn = target
prefs.edit { putBoolean("uid_multi_user_scan", target) }
} else {
Toast.makeText(
context,
context.getString(R.string.uid_scanner_setting_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
}
)
}
AnimatedVisibility(
visible = autoOn,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
val confirmDialog = rememberConfirmDialog()
SuperArrow(
title = stringResource(R.string.clean_runtime_environment),
summary = stringResource(R.string.clean_runtime_environment_summary),
leftAction = {
Icon(
imageVector = Icons.Filled.CleaningServices,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.clean_runtime_environment),
tint = colorScheme.onBackground
)
},
onClick = {
scope.launch {
if (confirmDialog.awaitConfirm(
title = context.getString(R.string.clean_runtime_environment),
content = context.getString(R.string.clean_runtime_environment_confirm)
) == ConfirmResult.Confirmed
) {
if (cleanRuntimeEnvironment()) {
autoOn = false
multiOn = false
prefs.edit {
putBoolean("uid_auto_scan", false)
putBoolean("uid_multi_user_scan", false)
}
Natives.setUidScannerEnabled(false)
Toast.makeText(
context,
context.getString(R.string.clean_runtime_environment_success),
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
context.getString(R.string.clean_runtime_environment_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
}
)
}
}
@Composable
fun SelinuxToggleSection(
scope: CoroutineScope,
context: Context
) {
var selinuxEnforcing by remember { mutableStateOf(true) }
var selinuxLoading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
val current = withContext(Dispatchers.IO) { !isSelinuxPermissive() }
selinuxEnforcing = current
selinuxLoading = false
}
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
val statusLabel = getSELinuxStatus()
SuperSwitch(
title = stringResource(R.string.tools_selinux_toggle),
summary = stringResource(
R.string.tools_selinux_summary,
statusLabel
),
leftAction = {
Icon(
imageVector = Icons.Rounded.Security,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(id = R.string.tools_selinux_toggle),
tint = colorScheme.onBackground
)
},
checked = selinuxEnforcing,
enabled = !selinuxLoading,
onCheckedChange = { target ->
selinuxLoading = true
scope.launch(Dispatchers.IO) {
val success = if (target) {
setSelinuxPermissive(false)
} else {
setSelinuxPermissive(true)
}
val actual = !isSelinuxPermissive()
withContext(Dispatchers.Main) {
selinuxEnforcing = actual
selinuxLoading = false
Toast.makeText(
context,
if (success && actual == target) {
context.getString(
R.string.tools_selinux_apply_success,
context.getString(
if (actual) {
R.string.selinux_status_enforcing
} else {
R.string.selinux_status_permissive
}
)
)
} else {
context.getString(R.string.tools_selinux_apply_failed)
},
Toast.LENGTH_SHORT
).show()
}
}
}
)
}
}
@Composable
private fun AllowlistBackupSection(
scope: CoroutineScope,
context: Context
) {
val contextRef = remember { context }
val backupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/octet-stream")
) { uri ->
if (uri == null) {
return@rememberLauncherForActivityResult
}
scope.launch {
val success = backupAllowlistToUri(contextRef, uri)
Toast.makeText(
contextRef,
contextRef.getString(
if (success) {
R.string.allowlist_backup_success
} else {
R.string.allowlist_backup_failed
}
),
Toast.LENGTH_SHORT
).show()
}
}
val restoreLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
if (uri == null) {
return@rememberLauncherForActivityResult
}
scope.launch {
val success = restoreAllowlistFromUri(contextRef, uri)
Toast.makeText(
contextRef,
contextRef.getString(
if (success) {
R.string.allowlist_restore_success
} else {
R.string.allowlist_restore_failed
}
),
Toast.LENGTH_SHORT
).show()
}
}
Card(
modifier = Modifier
.padding(vertical = 12.dp)
.fillMaxWidth(),
) {
SuperArrow(
title = stringResource(R.string.allowlist_backup_title),
summary = stringResource(R.string.allowlist_backup_summary_picker),
leftAction = {
Icon(
imageVector = Icons.Rounded.Backup,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.allowlist_backup_title),
tint = colorScheme.onBackground
)
},
onClick = {
backupLauncher.launch("ksu_allowlist_backup.bin")
}
)
SuperArrow(
title = stringResource(R.string.allowlist_restore_title),
summary = stringResource(R.string.allowlist_restore_summary_picker),
leftAction = {
Icon(
imageVector = Icons.Rounded.Restore,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.allowlist_restore_title),
tint = colorScheme.onBackground
)
},
onClick = {
restoreLauncher.launch(arrayOf("*/*"))
}
)
}
}
private fun isSelinuxPermissive(): Boolean {
val result = Shell.cmd("getenforce").exec()
val output = result.out.joinToString("\n").trim().lowercase()
return output == "permissive"
}
private fun setSelinuxPermissive(permissive: Boolean): Boolean {
val target = if (permissive) "0" else "1"
val result = Shell.cmd("setenforce $target").exec()
return result.isSuccess
}
private suspend fun backupAllowlistToUri(context: Context, targetUri: Uri): Boolean = withContext(Dispatchers.IO) {
val tempFile = File(context.cacheDir, "allowlist_backup_tmp.bin")
try {
if (!copyAllowlistToFile(tempFile)) return@withContext false
return@withContext runCatching {
context.contentResolver.openOutputStream(targetUri, "w")?.use { output ->
tempFile.inputStream().use { input ->
input.copyTo(output)
}
true
} ?: false
}.getOrElse { false }
} finally {
tempFile.delete()
}
}
private suspend fun restoreAllowlistFromUri(context: Context, sourceUri: Uri): Boolean = withContext(Dispatchers.IO) {
val tempFile = File(context.cacheDir, "allowlist_restore_tmp.bin")
try {
val downloaded = runCatching {
context.contentResolver.openInputStream(sourceUri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
true
} ?: false
}.getOrElse { false }
if (!downloaded) return@withContext false
return@withContext copyFileToAllowlist(tempFile)
} finally {
tempFile.delete()
}
}
private suspend fun copyAllowlistToFile(targetFile: File): Boolean = withContext(Dispatchers.IO) {
runCatching {
targetFile.parentFile?.mkdirs()
val result = Shell.cmd(
"cp /data/adb/ksu/.allowlist \"${targetFile.absolutePath}\"",
"chmod 0644 \"${targetFile.absolutePath}\""
).exec()
result.isSuccess
}.getOrDefault(false)
}
private suspend fun copyFileToAllowlist(sourceFile: File): Boolean = withContext(Dispatchers.IO) {
if (!sourceFile.exists()) return@withContext false
runCatching {
val result = Shell.cmd(
"cp \"${sourceFile.absolutePath}\" /data/adb/ksu/.allowlist",
"chmod 0644 /data/adb/ksu/.allowlist"
).exec()
result.isSuccess
}.getOrDefault(false)
}

View File

@@ -0,0 +1,112 @@
package com.sukisu.ultra.ui.susfs.component
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.*
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun BottomActionButtons(
modifier: Modifier = Modifier,
primaryButtonText: String,
onPrimaryClick: () -> Unit,
secondaryButtonText: String? = null,
onSecondaryClick: (() -> Unit)? = null,
isLoading: Boolean = false,
enabled: Boolean = true
) {
Card(
modifier = modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (secondaryButtonText != null && onSecondaryClick != null) {
Button(
onClick = onSecondaryClick,
enabled = !isLoading && enabled,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
cornerRadius = 8.dp
) {
Text(
text = secondaryButtonText,
style = MiuixTheme.textStyles.body2
)
}
Button(
onClick = onPrimaryClick,
enabled = !isLoading && enabled,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
cornerRadius = 8.dp
) {
Text(
text = primaryButtonText,
style = MiuixTheme.textStyles.body2
)
}
} else {
Button(
onClick = onPrimaryClick,
enabled = !isLoading && enabled,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
cornerRadius = 8.dp
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = primaryButtonText,
style = MiuixTheme.textStyles.body2
)
}
}
}
}
}
@Composable
fun ResetButton(
modifier: Modifier = Modifier,
title: String,
onClick: () -> Unit,
enabled: Boolean = true
) {
Card(
modifier = modifier
.padding(vertical = 12.dp)
.fillMaxWidth(),
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
cornerRadius = 8.dp
) {
Text(
text = title
)
}
}
}

View File

@@ -0,0 +1,404 @@
package com.sukisu.ultra.ui.susfs.component
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.*
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.theme.MiuixTheme
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun BackupRestoreComponent(
isLoading: Boolean,
onLoadingChange: (Boolean) -> Unit,
onConfigReload: () -> Unit
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var internalLoading by remember { mutableStateOf(false) }
val actualLoading = isLoading || internalLoading
var showBackupDialog by remember { mutableStateOf(false) }
var showRestoreDialog by remember { mutableStateOf(false) }
var showRestoreConfirmDialog by remember { mutableStateOf(false) }
var selectedBackupFile by remember { mutableStateOf<String?>(null) }
var backupInfo by remember { mutableStateOf<SuSFSManager.BackupData?>(null) }
// 备份文件选择器
val backupFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { fileUri ->
coroutineScope.launch {
try {
internalLoading = true
onLoadingChange(true)
val fileName = SuSFSManager.getDefaultBackupFileName()
val tempFile = File(context.cacheDir, fileName)
val success = SuSFSManager.createBackup(context, tempFile.absolutePath)
if (success) {
try {
context.contentResolver.openOutputStream(fileUri)?.use { outputStream ->
tempFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
tempFile.delete()
}
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
internalLoading = false
onLoadingChange(false)
showBackupDialog = false
}
}
}
}
// 还原文件选择器
val restoreFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { fileUri ->
coroutineScope.launch {
try {
val tempFile = File(context.cacheDir, "temp_restore.susfs_backup")
context.contentResolver.openInputStream(fileUri)?.use { inputStream ->
tempFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
// 验证备份文件
val backup = SuSFSManager.validateBackupFile(tempFile.absolutePath)
if (backup != null) {
selectedBackupFile = tempFile.absolutePath
backupInfo = backup
showRestoreConfirmDialog = true
} else {
tempFile.delete()
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
showRestoreDialog = false
}
}
}
}
// 备份对话框
BackupDialog(
showDialog = showBackupDialog,
onDismiss = { showBackupDialog = false },
isLoading = actualLoading,
onBackup = {
val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
val timestamp = dateFormat.format(Date())
backupFileLauncher.launch("SuSFS_Config_$timestamp.susfs_backup")
}
)
// 还原对话框
RestoreDialog(
showDialog = showRestoreDialog,
onDismiss = { showRestoreDialog = false },
isLoading = actualLoading,
onSelectFile = {
restoreFileLauncher.launch(arrayOf("application/json", "*/*"))
}
)
// 还原确认对话框
RestoreConfirmDialog(
showDialog = showRestoreConfirmDialog,
onDismiss = {
showRestoreConfirmDialog = false
selectedBackupFile = null
backupInfo = null
},
backupInfo = backupInfo,
isLoading = actualLoading,
onConfirm = {
selectedBackupFile?.let { filePath ->
coroutineScope.launch {
try {
internalLoading = true
onLoadingChange(true)
val success = SuSFSManager.restoreFromBackup(context, filePath)
if (success) {
onConfigReload()
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
internalLoading = false
onLoadingChange(false)
showRestoreConfirmDialog = false
kotlinx.coroutines.delay(100)
selectedBackupFile = null
backupInfo = null
}
}
}
}
)
// 按钮行
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = { showBackupDialog = true },
enabled = !actualLoading,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
cornerRadius = 8.dp
) {
Text(
text = stringResource(R.string.susfs_backup_title)
)
}
Button(
onClick = { showRestoreDialog = true },
enabled = !actualLoading,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp),
cornerRadius = 8.dp
) {
Text(
text = stringResource(R.string.susfs_restore_title)
)
}
}
}
}
@Composable
private fun BackupDialog(
showDialog: Boolean,
onDismiss: () -> Unit,
isLoading: Boolean,
onBackup: () -> Unit
) {
val showDialogState = remember { mutableStateOf(showDialog) }
LaunchedEffect(showDialog) {
showDialogState.value = showDialog
}
if (showDialogState.value) {
SuperDialog(
show = showDialogState,
title = stringResource(R.string.susfs_backup_title),
onDismissRequest = onDismiss,
content = {
Column(
modifier = Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(stringResource(R.string.susfs_backup_description))
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(stringResource(R.string.cancel))
}
Button(
onClick = onBackup,
enabled = !isLoading,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(stringResource(R.string.susfs_backup_create))
}
}
}
}
)
}
}
@Composable
private fun RestoreDialog(
showDialog: Boolean,
onDismiss: () -> Unit,
isLoading: Boolean,
onSelectFile: () -> Unit
) {
val showDialogState = remember { mutableStateOf(showDialog) }
LaunchedEffect(showDialog) {
showDialogState.value = showDialog
}
if (showDialogState.value) {
SuperDialog(
show = showDialogState,
title = stringResource(R.string.susfs_restore_title),
onDismissRequest = onDismiss,
content = {
Column(
modifier = Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(stringResource(R.string.susfs_restore_description))
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(stringResource(R.string.cancel))
}
Button(
onClick = onSelectFile,
enabled = !isLoading,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(stringResource(R.string.susfs_restore_select_file))
}
}
}
}
)
}
}
@Composable
private fun RestoreConfirmDialog(
showDialog: Boolean,
onDismiss: () -> Unit,
backupInfo: SuSFSManager.BackupData?,
isLoading: Boolean,
onConfirm: () -> Unit
) {
val showDialogState = remember { mutableStateOf(showDialog && backupInfo != null) }
LaunchedEffect(showDialog, backupInfo) {
showDialogState.value = showDialog && backupInfo != null
}
if (showDialogState.value && backupInfo != null) {
SuperDialog(
show = showDialogState,
title = stringResource(R.string.susfs_restore_confirm_title),
onDismissRequest = onDismiss,
content = {
Column(
modifier = Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(stringResource(R.string.susfs_restore_confirm_description))
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
Text(
text = stringResource(
R.string.susfs_backup_info_date,
dateFormat.format(Date(backupInfo.timestamp))
),
fontSize = MiuixTheme.textStyles.body2.fontSize
)
Text(
text = stringResource(R.string.susfs_backup_info_device, backupInfo.deviceInfo),
fontSize = MiuixTheme.textStyles.body2.fontSize
)
Text(
text = stringResource(R.string.susfs_backup_info_version, backupInfo.version),
fontSize = MiuixTheme.textStyles.body2.fontSize
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onDismiss,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(stringResource(R.string.cancel))
}
Button(
onClick = onConfirm,
enabled = !isLoading,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(stringResource(R.string.susfs_restore_confirm))
}
}
}
}
)
}
}

View File

@@ -1,928 +0,0 @@
package com.sukisu.ultra.ui.susfs.component
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
import com.sukisu.ultra.ui.susfs.util.SuSFSManager.isSusVersion158
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
/**
* SUS路径内容组件
*/
@Composable
fun SusPathsContent(
susPaths: Set<String>,
isLoading: Boolean,
onAddPath: () -> Unit,
onAddAppPath: () -> Unit,
onRemovePath: (String) -> Unit,
onEditPath: ((String) -> Unit)? = null,
forceRefreshApps: Boolean = false
) {
val superUserApps = SuperUserViewModel.apps
val superUserIsRefreshing = remember { SuperUserViewModel().isRefreshing }
LaunchedEffect(superUserIsRefreshing, superUserApps.size) {
if (!superUserIsRefreshing && superUserApps.isNotEmpty()) {
AppInfoCache.clearCache()
}
}
LaunchedEffect(forceRefreshApps) {
if (forceRefreshApps) {
AppInfoCache.clearCache()
}
}
val (appPathGroups, otherPaths) = remember(susPaths) {
val appPathRegex = Regex(".*/Android/data/([^/]+)/?.*")
val uidPathRegex = Regex("/sys/fs/cgroup/uid_([0-9]+)")
val appPathMap = mutableMapOf<String, MutableList<String>>()
val uidToPackageMap = mutableMapOf<String, String>()
val others = mutableListOf<String>()
// 构建UID到包名的映射
SuperUserViewModel.apps.forEach { app ->
try {
val uid = app.packageInfo.applicationInfo?.uid
uidToPackageMap[uid.toString()] = app.packageName
} catch (_: Exception) {
}
}
susPaths.forEach { path ->
val appDataMatch = appPathRegex.find(path)
val uidMatch = uidPathRegex.find(path)
when {
appDataMatch != null -> {
val packageName = appDataMatch.groupValues[1]
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
}
uidMatch != null -> {
val uid = uidMatch.groupValues[1]
val packageName = uidToPackageMap[uid]
if (packageName != null) {
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
} else {
others.add(path)
}
}
else -> {
others.add(path)
}
}
}
val sortedAppGroups = appPathMap.toList()
.sortedBy { it.first }
.map { (packageName, paths) -> packageName to paths.sorted() }
Pair(sortedAppGroups, others.sorted())
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 应用路径分组
if (appPathGroups.isNotEmpty()) {
item {
SectionHeader(
title = stringResource(R.string.app_paths_section),
subtitle = null,
icon = Icons.Default.Apps,
count = appPathGroups.size
)
}
items(appPathGroups) { (packageName, paths) ->
AppPathGroupCard(
packageName = packageName,
paths = paths,
onDeleteGroup = {
paths.forEach { path -> onRemovePath(path) }
},
onEditGroup = if (onEditPath != null) {
{
onEditPath(paths.first())
}
} else null,
isLoading = isLoading
)
}
}
// 其他路径
if (otherPaths.isNotEmpty()) {
item {
SectionHeader(
title = stringResource(R.string.other_paths_section),
subtitle = null,
icon = Icons.Default.Folder,
count = otherPaths.size
)
}
items(otherPaths) { path ->
PathItemCard(
path = path,
icon = Icons.Default.Folder,
onDelete = { onRemovePath(path) },
onEdit = if (onEditPath != null) { { onEditPath(path) } } else null,
isLoading = isLoading
)
}
}
if (susPaths.isEmpty()) {
item {
EmptyStateCard(
message = stringResource(R.string.susfs_no_paths_configured)
)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onAddPath,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.add_custom_path))
}
Button(
onClick = onAddAppPath,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.Apps,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.add_app_path))
}
}
}
}
}
}
/**
* SUS循环路径内容组件
*/
@Composable
fun SusLoopPathsContent(
susLoopPaths: Set<String>,
isLoading: Boolean,
onAddLoopPath: () -> Unit,
onRemoveLoopPath: (String) -> Unit,
onEditLoopPath: ((String) -> Unit)? = null
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 说明卡片
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.sus_loop_paths_description_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.sus_loop_paths_description_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.susfs_loop_path_restriction_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary
)
}
}
}
if (susLoopPaths.isEmpty()) {
item {
EmptyStateCard(
message = stringResource(R.string.susfs_no_loop_paths_configured)
)
}
} else {
item {
SectionHeader(
title = stringResource(R.string.loop_paths_section),
subtitle = null,
icon = Icons.Default.Loop,
count = susLoopPaths.size
)
}
items(susLoopPaths.toList()) { path ->
PathItemCard(
path = path,
icon = Icons.Default.Loop,
onDelete = { onRemoveLoopPath(path) },
onEdit = if (onEditLoopPath != null) { { onEditLoopPath(path) } } else null,
isLoading = isLoading
)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onAddLoopPath,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.add_loop_path))
}
}
}
}
}
}
/**
* SUS Maps内容组件
*/
@Composable
fun SusMapsContent(
susMaps: Set<String>,
isLoading: Boolean,
onAddSusMap: () -> Unit,
onRemoveSusMap: (String) -> Unit,
onEditSusMap: ((String) -> Unit)? = null
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 说明卡片
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.sus_maps_description_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.sus_maps_description_text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.sus_maps_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary
)
Text(
text = stringResource(R.string.sus_maps_debug_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
if (susMaps.isEmpty()) {
item {
EmptyStateCard(
message = stringResource(R.string.susfs_no_sus_maps_configured)
)
}
} else {
item {
SectionHeader(
title = stringResource(R.string.sus_maps_section),
subtitle = null,
icon = Icons.Default.Security,
count = susMaps.size
)
}
items(susMaps.toList()) { map ->
PathItemCard(
path = map,
icon = Icons.Default.Security,
onDelete = { onRemoveSusMap(map) },
onEdit = if (onEditSusMap != null) { { onEditSusMap(map) } } else null,
isLoading = isLoading
)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onAddSusMap,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.add))
}
}
}
}
}
}
/**
* SUS挂载内容组件
*/
@Composable
fun SusMountsContent(
susMounts: Set<String>,
hideSusMountsForAllProcs: Boolean,
isSusVersion158: Boolean,
isLoading: Boolean,
onAddMount: () -> Unit,
onRemoveMount: (String) -> Unit,
onEditMount: ((String) -> Unit)? = null,
onToggleHideSusMountsForAllProcs: (Boolean) -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (isSusVersion158) {
item {
SusMountHidingControlCard(
hideSusMountsForAllProcs = hideSusMountsForAllProcs,
isLoading = isLoading,
onToggleHiding = onToggleHideSusMountsForAllProcs
)
}
}
if (susMounts.isEmpty()) {
item {
EmptyStateCard(
message = stringResource(R.string.susfs_no_mounts_configured)
)
}
} else {
items(susMounts.toList()) { mount ->
PathItemCard(
path = mount,
icon = Icons.Default.Storage,
onDelete = { onRemoveMount(mount) },
onEdit = if (onEditMount != null) { { onEditMount(mount) } } else null,
isLoading = isLoading
)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onAddMount,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.add))
}
}
}
}
}
}
/**
* 尝试卸载内容组件
*/
@Composable
fun TryUmountContent(
tryUmounts: Set<String>,
umountForZygoteIsoService: Boolean,
isLoading: Boolean,
onAddUmount: () -> Unit,
onRemoveUmount: (String) -> Unit,
onEditUmount: ((String) -> Unit)? = null,
onToggleUmountForZygoteIsoService: (Boolean) -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (isSusVersion158()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Security,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.umount_zygote_iso_service),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.umount_zygote_iso_service_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 14.sp
)
}
Switch(
checked = umountForZygoteIsoService,
onCheckedChange = onToggleUmountForZygoteIsoService,
enabled = !isLoading
)
}
}
}
}
if (tryUmounts.isEmpty()) {
item {
EmptyStateCard(
message = stringResource(R.string.susfs_no_umounts_configured)
)
}
} else {
items(tryUmounts.toList()) { umountEntry ->
val parts = umountEntry.split("|")
val path = if (parts.isNotEmpty()) parts[0] else umountEntry
val mode = if (parts.size > 1) parts[1] else "0"
val modeText = if (mode == "0")
stringResource(R.string.susfs_umount_mode_normal_short)
else
stringResource(R.string.susfs_umount_mode_detach_short)
PathItemCard(
path = path,
icon = Icons.Default.Storage,
additionalInfo = stringResource(R.string.susfs_umount_mode_display, modeText, mode),
onDelete = { onRemoveUmount(umountEntry) },
onEdit = if (onEditUmount != null) { { onEditUmount(umountEntry) } } else null,
isLoading = isLoading
)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onAddUmount,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.add))
}
}
}
}
}
}
/**
* Kstat配置内容组件
*/
@Composable
fun KstatConfigContent(
kstatConfigs: Set<String>,
addKstatPaths: Set<String>,
isLoading: Boolean,
onAddKstatStatically: () -> Unit,
onAddKstat: () -> Unit,
onRemoveKstatConfig: (String) -> Unit,
onEditKstatConfig: ((String) -> Unit)? = null,
onRemoveAddKstat: (String) -> Unit,
onEditAddKstat: ((String) -> Unit)? = null,
onUpdateKstat: (String) -> Unit,
onUpdateKstatFullClone: (String) -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.kstat_config_description_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.kstat_config_description_add_statically),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.kstat_config_description_add),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.kstat_config_description_update),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.kstat_config_description_update_full_clone),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (kstatConfigs.isNotEmpty()) {
item {
Text(
text = stringResource(R.string.static_kstat_config),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
items(kstatConfigs.toList()) { config ->
KstatConfigItemCard(
config = config,
onDelete = { onRemoveKstatConfig(config) },
onEdit = if (onEditKstatConfig != null) { { onEditKstatConfig(config) } } else null,
isLoading = isLoading
)
}
}
if (addKstatPaths.isNotEmpty()) {
item {
Text(
text = stringResource(R.string.kstat_path_management),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
items(addKstatPaths.toList()) { path ->
AddKstatPathItemCard(
path = path,
onDelete = { onRemoveAddKstat(path) },
onEdit = if (onEditAddKstat != null) { { onEditAddKstat(path) } } else null,
onUpdate = { onUpdateKstat(path) },
onUpdateFullClone = { onUpdateKstatFullClone(path) },
isLoading = isLoading
)
}
}
if (kstatConfigs.isEmpty() && addKstatPaths.isEmpty()) {
item {
EmptyStateCard(
message = stringResource(R.string.no_kstat_config_message)
)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onAddKstat,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.add))
}
Button(
onClick = onAddKstatStatically,
modifier = Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.add))
}
}
}
}
}
}
/**
* 路径设置内容组件
*/
@SuppressLint("SdCardPath")
@Composable
fun PathSettingsContent(
androidDataPath: String,
onAndroidDataPathChange: (String) -> Unit,
sdcardPath: String,
onSdcardPathChange: (String) -> Unit,
isLoading: Boolean,
onSetAndroidDataPath: () -> Unit,
onSetSdcardPath: () -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = androidDataPath,
onValueChange = onAndroidDataPathChange,
label = { Text(stringResource(R.string.susfs_android_data_path_label)) },
placeholder = { Text("/sdcard/Android/data") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
shape = RoundedCornerShape(8.dp)
)
Button(
onClick = onSetAndroidDataPath,
enabled = !isLoading && androidDataPath.isNotBlank(),
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
shape = RoundedCornerShape(8.dp)
) {
Text(stringResource(R.string.susfs_set_android_data_path))
}
}
}
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = sdcardPath,
onValueChange = onSdcardPathChange,
label = { Text(stringResource(R.string.susfs_sdcard_path_label)) },
placeholder = { Text("/sdcard") },
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
singleLine = true,
shape = RoundedCornerShape(8.dp)
)
Button(
onClick = onSetSdcardPath,
enabled = !isLoading && sdcardPath.isNotBlank(),
modifier = Modifier
.fillMaxWidth()
.height(40.dp),
shape = RoundedCornerShape(8.dp)
) {
Text(stringResource(R.string.susfs_set_sdcard_path))
}
}
}
}
}
}
/**
* 启用功能状态内容组件
*/
@Composable
fun EnabledFeaturesContent(
enabledFeatures: List<SuSFSManager.EnabledFeature>,
onRefresh: () -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.susfs_enabled_features_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
if (enabledFeatures.isEmpty()) {
item {
EmptyStateCard(
message = stringResource(R.string.susfs_no_features_found)
)
}
} else {
items(enabledFeatures) { feature ->
FeatureStatusCard(
feature = feature,
onRefresh = onRefresh
)
}
}
}
}

View File

@@ -0,0 +1,351 @@
package com.sukisu.ultra.ui.susfs.component
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.sukisu.ultra.R
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.extra.SuperDialog
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
sealed class DialogField {
data class TextField(
val value: String,
val onValueChange: (String) -> Unit,
val labelRes: Int,
val enabled: Boolean = true,
val modifier: Modifier = Modifier.fillMaxWidth()
) : DialogField()
data class Dropdown(
val titleRes: Int,
val summary: String,
val items: List<String>,
val selectedIndex: Int,
val onSelectedIndexChange: (Int) -> Unit,
val enabled: Boolean = true
) : DialogField()
data class CustomContent(
val content: @Composable ColumnScope.() -> Unit
) : DialogField()
}
/**
* 通用多功能对话框组件
*
* @param showDialog 是否显示对话框
* @param onDismiss 关闭对话框回调
* @param onConfirm 确认回调,返回是否应该关闭对话框
* @param titleRes 标题资源ID
* @param isLoading 是否正在加载
* @param fields 对话框字段列表
* @param confirmTextRes 确认按钮文本资源ID默认为"添加"
* @param cancelTextRes 取消按钮文本资源ID默认为"取消"
* @param isConfirmEnabled 确认按钮是否启用默认为true
* @param scrollable 内容是否可滚动默认为false
* @param onReset 重置回调,用于清空字段
*/
@Composable
fun UniversalDialog(
showDialog: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Boolean,
titleRes: Int,
isLoading: Boolean = false,
fields: List<DialogField>,
confirmTextRes: Int = R.string.add,
cancelTextRes: Int = R.string.cancel,
isConfirmEnabled: Boolean = true,
scrollable: Boolean = false,
onReset: (() -> Unit)? = null
) {
val showDialogState = remember { mutableStateOf(showDialog) }
LaunchedEffect(showDialog) {
showDialogState.value = showDialog
}
if (showDialogState.value) {
SuperDialog(
show = showDialogState,
title = stringResource(titleRes),
onDismissRequest = {
onDismiss()
onReset?.invoke()
},
content = {
val contentModifier = if (scrollable) {
Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
} else {
Modifier.padding(horizontal = 24.dp)
}
Column(
modifier = contentModifier,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
fields.forEach { field ->
when (field) {
is DialogField.TextField -> {
TextField(
value = field.value,
onValueChange = field.onValueChange,
label = stringResource(field.labelRes),
useLabelAsPlaceholder = true,
modifier = field.modifier,
enabled = field.enabled && !isLoading
)
}
is DialogField.Dropdown -> {
SuperDropdown(
title = stringResource(field.titleRes),
summary = field.summary,
items = field.items,
selectedIndex = field.selectedIndex,
onSelectedIndexChange = field.onSelectedIndexChange,
enabled = field.enabled && !isLoading
)
}
is DialogField.CustomContent -> {
field.content.invoke(this)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = {
onDismiss()
onReset?.invoke()
},
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(
text = stringResource(cancelTextRes)
)
}
Button(
onClick = {
if (onConfirm()) {
onDismiss()
onReset?.invoke()
}
},
enabled = isConfirmEnabled && !isLoading,
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(
text = stringResource(confirmTextRes)
)
}
}
}
}
)
}
}
@Composable
fun DescriptionCard(
title: String,
description: String,
modifier: Modifier = Modifier,
warning: String? = null,
additionalInfo: String? = null
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.defaultColors(
color = colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
cornerRadius = 8.dp
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = title,
style = MiuixTheme.textStyles.body1,
fontWeight = FontWeight.Medium,
color = colorScheme.primary
)
Text(
text = description,
style = MiuixTheme.textStyles.body2,
color = colorScheme.onSurfaceVariantSummary,
lineHeight = 16.sp
)
warning?.let {
Text(
text = it,
style = MiuixTheme.textStyles.body2,
color = colorScheme.secondary,
fontWeight = FontWeight.Medium,
lineHeight = 16.sp
)
}
additionalInfo?.let {
Text(
text = it,
style = MiuixTheme.textStyles.body2,
color = colorScheme.onSurfaceVariantSummary.copy(alpha = 0.8f),
lineHeight = 14.sp
)
}
}
}
}
@Composable
fun ConfirmDialog(
showDialog: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
titleRes: Int,
messageRes: Int,
isLoading: Boolean = false
) {
UniversalDialog(
showDialog = showDialog,
onDismiss = onDismiss,
onConfirm = {
onConfirm()
true
},
titleRes = titleRes,
isLoading = isLoading,
fields = listOf(
DialogField.CustomContent {
Text(
text = stringResource(messageRes),
style = MiuixTheme.textStyles.body2
)
}
),
confirmTextRes = R.string.confirm,
cancelTextRes = R.string.cancel,
isConfirmEnabled = !isLoading
)
}
@Composable
fun EmptyStateCard(
message: String,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.defaultColors(
color = colorScheme.surfaceVariant.copy(alpha = 0.15f)
),
cornerRadius = 8.dp
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
contentAlignment = Alignment.Center
) {
Text(
text = message,
style = MiuixTheme.textStyles.body2,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun SectionHeader(
title: String,
subtitle: String?,
icon: ImageVector,
count: Int
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.defaultColors(
color = colorScheme.surfaceVariant.copy(alpha = 0.25f)
),
cornerRadius = 8.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MiuixTheme.textStyles.body1,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
subtitle?.let {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = it,
style = MiuixTheme.textStyles.body2,
color = colorScheme.onSurfaceVariantSummary
)
}
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(colorScheme.primaryContainer)
.padding(horizontal = 10.dp, vertical = 5.dp)
) {
Text(
text = count.toString(),
style = MiuixTheme.textStyles.body2.copy(fontSize = 12.sp),
color = colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Medium
)
}
}
}
}

View File

@@ -0,0 +1,355 @@
package com.sukisu.ultra.ui.susfs.content
import android.content.Context
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.component.BackupRestoreComponent
import com.sukisu.ultra.ui.susfs.component.DescriptionCard
import com.sukisu.ultra.ui.susfs.component.ResetButton
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
import com.sukisu.ultra.ui.util.isAbDevice
import top.yukonga.miuix.kmp.basic.*
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@Composable
fun BasicSettingsContent(
unameValue: String,
onUnameValueChange: (String) -> Unit,
buildTimeValue: String,
onBuildTimeValueChange: (String) -> Unit,
executeInPostFsData: Boolean,
onExecuteInPostFsDataChange: (Boolean) -> Unit,
autoStartEnabled: Boolean,
canEnableAutoStart: Boolean,
isLoading: Boolean,
onAutoStartToggle: (Boolean) -> Unit,
onShowSlotInfo: () -> Unit,
context: Context,
enableHideBl: Boolean,
onEnableHideBlChange: (Boolean) -> Unit,
enableCleanupResidue: Boolean,
onEnableCleanupResidueChange: (Boolean) -> Unit,
enableAvcLogSpoofing: Boolean,
onEnableAvcLogSpoofingChange: (Boolean) -> Unit,
hideSusMountsForAllProcs: Boolean,
onHideSusMountsForAllProcsChange: (Boolean) -> Unit,
umountForZygoteIsoService: Boolean,
onUmountForZygoteIsoServiceChange: (Boolean) -> Unit,
onReset: (() -> Unit)? = null,
onApply: (() -> Unit)? = null,
onConfigReload: () -> Unit
) {
val isAbDevice = produceState(initialValue = false) {
value = isAbDevice()
}.value
// 执行位置选择
val locationItems = listOf(
stringResource(R.string.susfs_execution_location_service),
stringResource(R.string.susfs_execution_location_post_fs_data)
)
// 说明卡片
DescriptionCard(
title = stringResource(R.string.susfs_config_description),
description = stringResource(R.string.susfs_config_description_text)
)
// Uname输入框
TextField(
value = unameValue,
onValueChange = onUnameValueChange,
label = stringResource(R.string.susfs_uname_label),
useLabelAsPlaceholder = true,
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
enabled = !isLoading
)
// 构建时间伪装输入框
TextField(
value = buildTimeValue,
onValueChange = onBuildTimeValueChange,
label = stringResource(R.string.susfs_build_time_label),
useLabelAsPlaceholder = true,
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
enabled = !isLoading
)
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
SuperDropdown(
title = stringResource(R.string.susfs_execution_location_label),
summary = if (executeInPostFsData) {
stringResource(R.string.susfs_execution_location_post_fs_data)
} else {
stringResource(R.string.susfs_execution_location_service)
},
items = locationItems,
selectedIndex = if (executeInPostFsData) 1 else 0,
onSelectedIndexChange = { index ->
onExecuteInPostFsDataChange(index == 1)
},
enabled = !isLoading,
leftAction = {
Icon(
Icons.Default.LocationOn,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.padding(end = 16.dp)
)
}
)
}
// 当前值显示
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = stringResource(R.string.susfs_current_value, SuSFSManager.getUnameValue(context)),
style = MiuixTheme.textStyles.body2.copy(fontSize = 13.sp),
color = colorScheme.onSurfaceVariantSummary
)
Text(
text = stringResource(R.string.susfs_current_build_time, SuSFSManager.getBuildTimeValue(context)),
style = MiuixTheme.textStyles.body2.copy(fontSize = 13.sp),
color = colorScheme.onSurfaceVariantSummary
)
Text(
text = stringResource(R.string.susfs_current_execution_location, if (SuSFSManager.getExecuteInPostFsData(context)) "Post-FS-Data" else "Service"),
style = MiuixTheme.textStyles.body2.copy(fontSize = 13.sp),
color = colorScheme.onSurfaceVariantSummary
)
}
}
// 应用按钮
if (onApply != null) {
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
Button(
onClick = { onApply() },
enabled = !isLoading && (unameValue.isNotBlank() || buildTimeValue.isNotBlank()),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
cornerRadius = 8.dp
) {
Text(
text = stringResource(R.string.susfs_apply)
)
}
}
}
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
// 开机自启动开关
SuperSwitch(
title = stringResource(R.string.susfs_autostart_title),
summary = if (canEnableAutoStart) {
stringResource(R.string.susfs_autostart_description)
} else {
stringResource(R.string.susfs_autostart_requirement)
},
leftAction = {
Icon(
Icons.Default.AutoMode,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.susfs_autostart_title),
tint = if (canEnableAutoStart) colorScheme.onBackground else colorScheme.onSurfaceVariantSummary
)
},
checked = autoStartEnabled,
onCheckedChange = onAutoStartToggle,
enabled = !isLoading && canEnableAutoStart
)
// 隐藏BL脚本开关
SuperSwitch(
title = stringResource(R.string.hide_bl_script),
summary = stringResource(R.string.hide_bl_script_description),
leftAction = {
Icon(
Icons.Default.Security,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.hide_bl_script),
tint = colorScheme.onBackground
)
},
checked = enableHideBl,
onCheckedChange = onEnableHideBlChange,
enabled = !isLoading
)
// 清理残留脚本开关
SuperSwitch(
title = stringResource(R.string.cleanup_residue),
summary = stringResource(R.string.cleanup_residue_description),
leftAction = {
Icon(
Icons.Default.CleaningServices,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.cleanup_residue),
tint = colorScheme.onBackground
)
},
checked = enableCleanupResidue,
onCheckedChange = onEnableCleanupResidueChange,
enabled = !isLoading
)
// AVC日志欺骗开关
SuperSwitch(
title = stringResource(R.string.avc_log_spoofing),
summary = stringResource(R.string.avc_log_spoofing_description),
leftAction = {
Icon(
Icons.Default.VisibilityOff,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.avc_log_spoofing),
tint = colorScheme.onBackground
)
},
checked = enableAvcLogSpoofing,
onCheckedChange = onEnableAvcLogSpoofingChange,
enabled = !isLoading
)
// 对所有进程隐藏SuS挂载开关
SuperSwitch(
title = stringResource(R.string.susfs_hide_mounts_for_all_procs_label),
summary = if (hideSusMountsForAllProcs) {
stringResource(R.string.susfs_hide_mounts_for_all_procs_enabled_description)
} else {
stringResource(R.string.susfs_hide_mounts_for_all_procs_disabled_description)
},
leftAction = {
Icon(
if (hideSusMountsForAllProcs) Icons.Default.VisibilityOff else Icons.Default.Visibility,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.susfs_hide_mounts_for_all_procs_label),
tint = colorScheme.onBackground
)
},
checked = hideSusMountsForAllProcs,
onCheckedChange = onHideSusMountsForAllProcsChange,
enabled = !isLoading
)
// 卸载 Zygote 隔离服务开关
SuperSwitch(
title = stringResource(R.string.umount_zygote_iso_service),
summary = stringResource(R.string.umount_zygote_iso_service_description),
leftAction = {
Icon(
Icons.Default.Security,
modifier = Modifier.padding(end = 16.dp),
contentDescription = stringResource(R.string.umount_zygote_iso_service),
tint = colorScheme.onBackground
)
},
checked = umountForZygoteIsoService,
onCheckedChange = onUmountForZygoteIsoServiceChange,
enabled = !isLoading
)
}
// 槽位信息按钮
if (isAbDevice) {
Card(
modifier = Modifier
.padding(top = 12.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.susfs_slot_info_title),
style = MiuixTheme.textStyles.title3,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
}
Text(
text = stringResource(R.string.susfs_slot_info_description),
style = MiuixTheme.textStyles.body2.copy(fontSize = 13.sp),
color = colorScheme.onSurfaceVariantSummary,
lineHeight = 16.sp
)
Button(
onClick = onShowSlotInfo,
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(vertical = 8.dp),
cornerRadius = 8.dp
) {
Text(
text = stringResource(R.string.susfs_slot_info_title)
)
}
}
}
}
BackupRestoreComponent(
isLoading = isLoading,
onLoadingChange = { },
onConfigReload = onConfigReload
)
// 重置按钮
if (onReset != null) {
ResetButton(
title = stringResource(R.string.susfs_reset_confirm_title),
onClick = onReset
)
}
}

View File

@@ -0,0 +1,79 @@
package com.sukisu.ultra.ui.susfs.content
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
import com.sukisu.ultra.ui.susfs.component.FeatureStatusCard
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
import top.yukonga.miuix.kmp.basic.*
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@Composable
fun EnabledFeaturesContent(
enabledFeatures: List<SuSFSManager.EnabledFeature>,
onRefresh: () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.defaultColors(
color = colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
cornerRadius = 8.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.susfs_enabled_features_description),
style = MiuixTheme.textStyles.body2,
color = colorScheme.onSurfaceVariantSummary,
lineHeight = 16.sp
)
}
}
if (enabledFeatures.isEmpty()) {
EmptyStateCard(
message = stringResource(R.string.susfs_no_features_found)
)
} else {
enabledFeatures.forEach { feature ->
FeatureStatusCard(
feature = feature,
onRefresh = onRefresh
)
}
}
}
BottomActionButtons(
primaryButtonText = stringResource(R.string.refresh),
onPrimaryClick = onRefresh
)
}

View File

@@ -0,0 +1,99 @@
package com.sukisu.ultra.ui.susfs.content
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.component.AddKstatPathItemCard
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
import com.sukisu.ultra.ui.susfs.component.DescriptionCard
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
import com.sukisu.ultra.ui.susfs.component.KstatConfigItemCard
import com.sukisu.ultra.ui.susfs.component.SectionHeader
@Composable
fun KstatConfigContent(
kstatConfigs: Set<String>,
addKstatPaths: Set<String>,
isLoading: Boolean,
onAddKstatStatically: () -> Unit,
onAddKstat: () -> Unit,
onRemoveKstatConfig: (String) -> Unit,
onEditKstatConfig: ((String) -> Unit)? = null,
onRemoveAddKstat: (String) -> Unit,
onEditAddKstat: ((String) -> Unit)? = null,
onUpdateKstat: (String) -> Unit,
onUpdateKstatFullClone: (String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
DescriptionCard(
title = stringResource(R.string.kstat_config_description_title),
description = stringResource(R.string.kstat_config_description_add_statically) + "\n" +
stringResource(R.string.kstat_config_description_add) + "\n" +
stringResource(R.string.kstat_config_description_update) + "\n" +
stringResource(R.string.kstat_config_description_update_full_clone)
)
if (kstatConfigs.isNotEmpty()) {
SectionHeader(
title = stringResource(R.string.static_kstat_config),
subtitle = null,
icon = Icons.Default.Settings,
count = kstatConfigs.size
)
kstatConfigs.toList().forEach { config ->
KstatConfigItemCard(
config = config,
onDelete = { onRemoveKstatConfig(config) },
onEdit = if (onEditKstatConfig != null) {
{ onEditKstatConfig(config) }
} else null,
isLoading = isLoading
)
}
}
if (addKstatPaths.isNotEmpty()) {
SectionHeader(
title = stringResource(R.string.kstat_path_management),
subtitle = null,
icon = Icons.Default.Folder,
count = addKstatPaths.size
)
addKstatPaths.toList().forEach { path ->
AddKstatPathItemCard(
path = path,
onDelete = { onRemoveAddKstat(path) },
onEdit = if (onEditAddKstat != null) {
{ onEditAddKstat(path) }
} else null,
onUpdate = { onUpdateKstat(path) },
onUpdateFullClone = { onUpdateKstatFullClone(path) },
isLoading = isLoading
)
}
}
if (kstatConfigs.isEmpty() && addKstatPaths.isEmpty()) {
EmptyStateCard(
message = stringResource(R.string.no_kstat_config_message)
)
}
}
BottomActionButtons(
primaryButtonText = stringResource(R.string.add),
onPrimaryClick = onAddKstat,
secondaryButtonText = stringResource(R.string.add),
onSecondaryClick = onAddKstatStatically,
isLoading = isLoading
)
}

View File

@@ -0,0 +1,80 @@
package com.sukisu.ultra.ui.susfs.content
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.component.ResetButton
import top.yukonga.miuix.kmp.basic.*
@SuppressLint("SdCardPath")
@Composable
fun PathSettingsContent(
androidDataPath: String,
onAndroidDataPathChange: (String) -> Unit,
sdcardPath: String,
onSdcardPathChange: (String) -> Unit,
isLoading: Boolean,
onSetAndroidDataPath: () -> Unit,
onSetSdcardPath: () -> Unit,
onReset: (() -> Unit)? = null
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
TextField(
value = androidDataPath,
onValueChange = onAndroidDataPathChange,
label = stringResource(R.string.susfs_android_data_path_label),
useLabelAsPlaceholder = true,
modifier = Modifier.fillMaxWidth(),
cornerRadius = 16.dp,
enabled = !isLoading
)
TextField(
value = sdcardPath,
onValueChange = onSdcardPathChange,
label = stringResource(R.string.susfs_sdcard_path_label),
useLabelAsPlaceholder = true,
modifier = Modifier.fillMaxWidth(),
cornerRadius = 16.dp,
enabled = !isLoading
)
Button(
onClick = {
onSetAndroidDataPath()
onSetSdcardPath()
},
enabled = !isLoading && androidDataPath.isNotBlank() && sdcardPath.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
cornerRadius = 16.dp
) {
Text(
text = stringResource(R.string.susfs_apply)
)
}
}
}
}
if (onReset != null) {
ResetButton(
title = stringResource(R.string.susfs_reset_path_title),
onClick = onReset
)
}
}

View File

@@ -0,0 +1,77 @@
package com.sukisu.ultra.ui.susfs.content
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Loop
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
import com.sukisu.ultra.ui.susfs.component.DescriptionCard
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
import com.sukisu.ultra.ui.susfs.component.PathItemCard
import com.sukisu.ultra.ui.susfs.component.ResetButton
import com.sukisu.ultra.ui.susfs.component.SectionHeader
@Composable
fun SusLoopPathsContent(
susLoopPaths: Set<String>,
isLoading: Boolean,
onAddLoopPath: () -> Unit,
onRemoveLoopPath: (String) -> Unit,
onEditLoopPath: ((String) -> Unit)? = null,
onReset: (() -> Unit)? = null
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 说明卡片
DescriptionCard(
title = stringResource(R.string.sus_loop_paths_description_title),
description = stringResource(R.string.sus_loop_paths_description_text),
warning = stringResource(R.string.susfs_loop_path_restriction_warning)
)
if (susLoopPaths.isEmpty()) {
EmptyStateCard(
message = stringResource(R.string.susfs_no_loop_paths_configured)
)
} else {
SectionHeader(
title = stringResource(R.string.loop_paths_section),
subtitle = null,
icon = Icons.Default.Loop,
count = susLoopPaths.size
)
susLoopPaths.toList().forEach { path ->
PathItemCard(
path = path,
icon = Icons.Default.Loop,
onDelete = { onRemoveLoopPath(path) },
onEdit = if (onEditLoopPath != null) {
{ onEditLoopPath(path) }
} else null,
isLoading = isLoading
)
}
}
}
BottomActionButtons(
primaryButtonText = stringResource(R.string.add_loop_path),
onPrimaryClick = onAddLoopPath,
isLoading = isLoading
)
if (onReset != null && susLoopPaths.isNotEmpty()) {
ResetButton(
title = stringResource(R.string.susfs_reset_loop_paths_title),
onClick = onReset
)
}
}

View File

@@ -0,0 +1,78 @@
package com.sukisu.ultra.ui.susfs.content
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Security
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
import com.sukisu.ultra.ui.susfs.component.DescriptionCard
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
import com.sukisu.ultra.ui.susfs.component.PathItemCard
import com.sukisu.ultra.ui.susfs.component.ResetButton
import com.sukisu.ultra.ui.susfs.component.SectionHeader
@Composable
fun SusMapsContent(
susMaps: Set<String>,
isLoading: Boolean,
onAddSusMap: () -> Unit,
onRemoveSusMap: (String) -> Unit,
onEditSusMap: ((String) -> Unit)? = null,
onReset: (() -> Unit)? = null
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 说明卡片
DescriptionCard(
title = stringResource(R.string.sus_maps_description_title),
description = stringResource(R.string.sus_maps_description_text),
warning = stringResource(R.string.sus_maps_warning),
additionalInfo = stringResource(R.string.sus_maps_debug_info)
)
if (susMaps.isEmpty()) {
EmptyStateCard(
message = stringResource(R.string.susfs_no_sus_maps_configured)
)
} else {
SectionHeader(
title = stringResource(R.string.sus_maps_section),
subtitle = null,
icon = Icons.Default.Security,
count = susMaps.size
)
susMaps.toList().forEach { map ->
PathItemCard(
path = map,
icon = Icons.Default.Security,
onDelete = { onRemoveSusMap(map) },
onEdit = if (onEditSusMap != null) {
{ onEditSusMap(map) }
} else null,
isLoading = isLoading
)
}
}
}
BottomActionButtons(
primaryButtonText = stringResource(R.string.add),
onPrimaryClick = onAddSusMap,
isLoading = isLoading
)
if (onReset != null && susMaps.isNotEmpty()) {
ResetButton(
title = stringResource(R.string.susfs_reset_sus_maps_title),
onClick = onReset
)
}
}

View File

@@ -0,0 +1,192 @@
package com.sukisu.ultra.ui.susfs.content
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.susfs.component.AppInfoCache
import com.sukisu.ultra.ui.susfs.component.AppPathGroupCard
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
import com.sukisu.ultra.ui.susfs.component.PathItemCard
import com.sukisu.ultra.ui.susfs.component.ResetButton
import com.sukisu.ultra.ui.susfs.component.SectionHeader
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
@Composable
fun SusPathsContent(
susPaths: Set<String>,
isLoading: Boolean,
onAddPath: () -> Unit,
onAddAppPath: () -> Unit,
onRemovePath: (String) -> Unit,
onEditPath: ((String) -> Unit)? = null,
forceRefreshApps: Boolean = false,
onReset: (() -> Unit)? = null
) {
var superUserApps by remember { mutableStateOf(SuperUserViewModel.getAppsSafely()) }
LaunchedEffect(Unit) {
snapshotFlow { SuperUserViewModel.apps }
.distinctUntilChanged()
.collect { _ ->
superUserApps = SuperUserViewModel.getAppsSafely()
if (superUserApps.isNotEmpty()) {
try {
AppInfoCache.clearCache()
} catch (_: Exception) {
}
}
}
}
LaunchedEffect(forceRefreshApps) {
if (forceRefreshApps) {
try {
AppInfoCache.clearCache()
} catch (_: Exception) {
// Ignore cache clear errors
}
}
}
val (appPathGroups, otherPaths) = remember(susPaths, superUserApps) {
val appPathRegex = Regex(".*/Android/data/([^/]+)/?.*")
val uidPathRegex = Regex("/sys/fs/cgroup(?:/[^/]+)*/uid_([0-9]+)")
val appPathMap = mutableMapOf<String, MutableList<String>>()
val uidToPackageMap = mutableMapOf<String, String>()
val others = mutableListOf<String>()
// 构建UID到包名的映射
try {
superUserApps.forEach { app: SuperUserViewModel.AppInfo ->
try {
val uid = app.packageInfo.applicationInfo?.uid
if (uid != null) {
uidToPackageMap[uid.toString()] = app.packageName
}
} catch (_: Exception) {
// Ignore individual app errors
}
}
} catch (_: Exception) {
// Ignore mapping errors
}
susPaths.forEach { path ->
val appDataMatch = appPathRegex.find(path)
val uidMatch = uidPathRegex.find(path)
when {
appDataMatch != null -> {
val packageName = appDataMatch.groupValues[1]
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
}
uidMatch != null -> {
val uid = uidMatch.groupValues[1]
val packageName = uidToPackageMap[uid]
if (packageName != null) {
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
} else {
others.add(path)
}
}
else -> {
others.add(path)
}
}
}
val sortedAppGroups = appPathMap.toList()
.sortedBy { it.first }
.map { (packageName, paths) -> packageName to paths.sorted() }
Pair(sortedAppGroups, others.sorted())
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 应用路径分组
if (appPathGroups.isNotEmpty()) {
SectionHeader(
title = stringResource(R.string.app_paths_section),
subtitle = null,
icon = Icons.Default.Apps,
count = appPathGroups.size
)
appPathGroups.forEach { (packageName, paths) ->
AppPathGroupCard(
packageName = packageName,
paths = paths,
onDeleteGroup = {
paths.forEach { path -> onRemovePath(path) }
},
onEditGroup = if (onEditPath != null) {
{
onEditPath(paths.first())
}
} else null,
isLoading = isLoading
)
}
}
// 其他路径
if (otherPaths.isNotEmpty()) {
SectionHeader(
title = stringResource(R.string.other_paths_section),
subtitle = null,
icon = Icons.Default.Folder,
count = otherPaths.size
)
otherPaths.forEach { path ->
PathItemCard(
path = path,
icon = Icons.Default.Folder,
onDelete = { onRemovePath(path) },
onEdit = if (onEditPath != null) {
{ onEditPath(path) }
} else null,
isLoading = isLoading
)
}
}
if (susPaths.isEmpty()) {
EmptyStateCard(
message = stringResource(R.string.susfs_no_paths_configured)
)
}
}
BottomActionButtons(
primaryButtonText = stringResource(R.string.add_custom_path),
onPrimaryClick = onAddPath,
secondaryButtonText = stringResource(R.string.susfs_apply),
onSecondaryClick = onAddAppPath,
isLoading = isLoading
)
if (onReset != null && susPaths.isNotEmpty()) {
ResetButton(
title = stringResource(R.string.susfs_reset_paths_title),
onClick = onReset
)
}
}

View File

@@ -0,0 +1,74 @@
package com.sukisu.ultra.ui.susfs.util
import android.content.Context
import android.util.Log
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object SuSFSModuleManager {
private const val TAG = "SuSFSModuleManager"
private const val MODULE_ID = "susfs_manager"
private const val MODULE_PATH = "/data/adb/modules/$MODULE_ID"
data class CommandResult(val isSuccess: Boolean, val output: String, val errorOutput: String = "")
private fun runCmdWithResult(cmd: String): CommandResult {
val result = Shell.getShell().newJob().add(cmd).exec()
return CommandResult(
isSuccess = result.isSuccess,
output = result.out.joinToString("\n"),
errorOutput = result.err.joinToString("\n")
)
}
fun getModulePath(): String = MODULE_PATH
private fun getCurrentModuleConfig(context: Context): SuSFSManager.ModuleConfig {
return SuSFSManager.getCurrentModuleConfig(context)
}
suspend fun createMagiskModule(context: Context): Boolean = withContext(Dispatchers.IO) {
try {
val config = getCurrentModuleConfig(context)
// 创建模块目录
if (!runCmdWithResult("mkdir -p $MODULE_PATH").isSuccess) {
return@withContext false
}
// 创建 module.prop
val moduleProp = ScriptGenerator.generateModuleProp(MODULE_ID)
val modulePropCmd = "cat > $MODULE_PATH/module.prop << 'EOF'\n$moduleProp\nEOF"
if (!runCmdWithResult(modulePropCmd).isSuccess) {
return@withContext false
}
// 生成并创建所有脚本文件
val scripts = ScriptGenerator.generateAllScripts(config)
scripts.all { (filename, content) ->
val writeCmd = "cat > $MODULE_PATH/$filename << 'EOF'\n$content\nEOF"
runCmdWithResult(writeCmd).isSuccess &&
runCmdWithResult("chmod 755 $MODULE_PATH/$filename").isSuccess
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create module", e)
false
}
}
suspend fun removeMagiskModule(): Boolean = withContext(Dispatchers.IO) {
try {
runCmdWithResult("rm -rf $MODULE_PATH").isSuccess
} catch (e: Exception) {
Log.e(TAG, "Failed to remove module", e)
false
}
}
suspend fun updateMagiskModule(context: Context): Boolean {
return removeMagiskModule() && createMagiskModule(context)
}
}

View File

@@ -26,13 +26,13 @@ object ScriptGenerator {
} }
// 日志相关的通用脚本片段 // 日志相关的通用脚本片段
private fun generateLogSetup(logFileName: String): String = """ private fun generateLogSetup(logFileName: String): String = $$"""
# 日志目录 # 日志目录
LOG_DIR="$LOG_DIR" LOG_DIR="$$LOG_DIR"
LOG_FILE="${'$'}LOG_DIR/$logFileName" LOG_FILE="$LOG_DIR/$$logFileName"
# 创建日志目录 # 创建日志目录
mkdir -p "${'$'}LOG_DIR" mkdir -p "$LOG_DIR"
# 获取当前时间 # 获取当前时间
get_current_time() { get_current_time() {
@@ -41,11 +41,11 @@ object ScriptGenerator {
""".trimIndent() """.trimIndent()
// 二进制文件检查的通用脚本片段 // 二进制文件检查的通用脚本片段
private fun generateBinaryCheck(targetPath: String): String = """ private fun generateBinaryCheck(targetPath: String): String = $$"""
# 检查SuSFS二进制文件 # 检查SuSFS二进制文件
SUSFS_BIN="$targetPath" SUSFS_BIN="$$targetPath"
if [ ! -f "${'$'}SUSFS_BIN" ]; then if [ ! -f "$SUSFS_BIN" ]; then
echo "$(get_current_time): SuSFS二进制文件未找到: ${'$'}SUSFS_BIN" >> "${'$'}LOG_FILE" echo "$(get_current_time): SuSFS二进制文件未找到: $SUSFS_BIN" >> "$LOG_FILE"
exit 1 exit 1
fi fi
""".trimIndent() """.trimIndent()
@@ -66,8 +66,8 @@ object ScriptGenerator {
appendLine() appendLine()
if (shouldConfigureInService(config)) { if (shouldConfigureInService(config)) {
// 添加SUS路径 (仅在不支持隐藏挂载时) // 添加SUS路径
if (!config.support158 && config.susPaths.isNotEmpty()) { if (config.susPaths.isNotEmpty()) {
appendLine() appendLine()
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done") appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
appendLine("sleep 45") appendLine("sleep 45")
@@ -94,7 +94,7 @@ object ScriptGenerator {
generateCleanupResidueSection() generateCleanupResidueSection()
} }
appendLine("echo \"$(get_current_time): Service脚本执行完成\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): Service脚本执行完成\" >> \"$LOG_FILE\"")
} }
} }
@@ -112,16 +112,16 @@ object ScriptGenerator {
private fun StringBuilder.generateLogSettingSection(enableLog: Boolean) { private fun StringBuilder.generateLogSettingSection(enableLog: Boolean) {
appendLine("# 设置日志启用状态") appendLine("# 设置日志启用状态")
val logValue = if (enableLog) 1 else 0 val logValue = if (enableLog) 1 else 0
appendLine("\"${'$'}SUSFS_BIN\" enable_log $logValue") appendLine($$"\"$SUSFS_BIN\" enable_log $$logValue")
appendLine("echo \"$(get_current_time): 日志功能设置为: ${if (enableLog) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 日志功能设置为: $${if (enableLog) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
appendLine() appendLine()
} }
private fun StringBuilder.generateAvcLogSpoofingSection(enableAvcLogSpoofing: Boolean) { private fun StringBuilder.generateAvcLogSpoofingSection(enableAvcLogSpoofing: Boolean) {
appendLine("# 设置AVC日志欺骗状态") appendLine("# 设置AVC日志欺骗状态")
val avcLogValue = if (enableAvcLogSpoofing) 1 else 0 val avcLogValue = if (enableAvcLogSpoofing) 1 else 0
appendLine("\"${'$'}SUSFS_BIN\" enable_avc_log_spoofing $avcLogValue") appendLine($$"\"$SUSFS_BIN\" enable_avc_log_spoofing $$avcLogValue")
appendLine("echo \"$(get_current_time): AVC日志欺骗功能设置为: ${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): AVC日志欺骗功能设置为: $${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
appendLine() appendLine()
} }
@@ -129,8 +129,8 @@ object ScriptGenerator {
if (susPaths.isNotEmpty()) { if (susPaths.isNotEmpty()) {
appendLine("# 添加SUS路径") appendLine("# 添加SUS路径")
susPaths.forEach { path -> susPaths.forEach { path ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path '$path'") appendLine($$"\"$SUSFS_BIN\" add_sus_path '$$path'")
appendLine("echo \"$(get_current_time): 添加SUS路径: $path\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 添加SUS路径: $$path\" >> \"$LOG_FILE\"")
} }
appendLine() appendLine()
} }
@@ -140,8 +140,8 @@ object ScriptGenerator {
if (susLoopPaths.isNotEmpty()) { if (susLoopPaths.isNotEmpty()) {
appendLine("# 添加SUS循环路径") appendLine("# 添加SUS循环路径")
susLoopPaths.forEach { path -> susLoopPaths.forEach { path ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path_loop '$path'") appendLine($$"\"$SUSFS_BIN\" add_sus_path_loop '$$path'")
appendLine("echo \"$(get_current_time): 添加SUS循环路径: $path\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 添加SUS循环路径: $$path\" >> \"$LOG_FILE\"")
} }
appendLine() appendLine()
} }
@@ -156,8 +156,8 @@ object ScriptGenerator {
if (addKstatPaths.isNotEmpty()) { if (addKstatPaths.isNotEmpty()) {
appendLine("# 添加Kstat路径") appendLine("# 添加Kstat路径")
addKstatPaths.forEach { path -> addKstatPaths.forEach { path ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat '$path'") appendLine($$"\"$SUSFS_BIN\" add_sus_kstat '$$path'")
appendLine("echo \"$(get_current_time): 添加Kstat路径: $path\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 添加Kstat路径: $$path\" >> \"$LOG_FILE\"")
} }
appendLine() appendLine()
} }
@@ -171,11 +171,11 @@ object ScriptGenerator {
val path = parts[0] val path = parts[0]
val params = parts.drop(1).joinToString("' '", "'", "'") val params = parts.drop(1).joinToString("' '", "'", "'")
appendLine() appendLine()
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat_statically '$path' $params") appendLine($$"\"$SUSFS_BIN\" add_sus_kstat_statically '$$path' $$params")
appendLine("echo \"$(get_current_time): 添加Kstat静态配置: $path\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 添加Kstat静态配置: $$path\" >> \"$LOG_FILE\"")
appendLine() appendLine()
appendLine("\"${'$'}SUSFS_BIN\" update_sus_kstat '$path'") appendLine($$"\"$SUSFS_BIN\" update_sus_kstat '$$path'")
appendLine("echo \"$(get_current_time): 更新Kstat配置: $path\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 更新Kstat配置: $$path\" >> \"$LOG_FILE\"")
} }
} }
appendLine() appendLine()
@@ -185,8 +185,8 @@ object ScriptGenerator {
private fun StringBuilder.generateUnameSection(config: SuSFSManager.ModuleConfig) { private fun StringBuilder.generateUnameSection(config: SuSFSManager.ModuleConfig) {
if (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) { if (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
appendLine("# 设置uname和构建时间") appendLine("# 设置uname和构建时间")
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'") appendLine($$"\"$SUSFS_BIN\" set_uname '$${config.unameValue}' '$${config.buildTimeValue}'")
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 设置uname为: $${config.unameValue}, 构建时间为: $${config.buildTimeValue}\" >> \"$LOG_FILE\"")
appendLine() appendLine()
} }
} }
@@ -194,44 +194,44 @@ object ScriptGenerator {
private fun StringBuilder.generateHideBlSection() { private fun StringBuilder.generateHideBlSection() {
appendLine("# 隐藏BL 来自 Shamiko 脚本") appendLine("# 隐藏BL 来自 Shamiko 脚本")
appendLine( appendLine(
""" $$"""
RESETPROP_BIN="/data/adb/ksu/bin/resetprop" RESETPROP_BIN="/data/adb/ksu/bin/resetprop"
check_reset_prop() { check_reset_prop() {
local NAME=$1 local NAME=$1
local EXPECTED=$2 local EXPECTED=$2
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME) local VALUE=$("$RESETPROP_BIN" $NAME)
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED [ -z $VALUE ] || [ $VALUE = $EXPECTED ] || "$RESETPROP_BIN" $NAME $EXPECTED
} }
check_missing_prop() { check_missing_prop() {
local NAME=$1 local NAME=$1
local EXPECTED=$2 local EXPECTED=$2
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME) local VALUE=$("$RESETPROP_BIN" $NAME)
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED [ -z $VALUE ] && "$RESETPROP_BIN" $NAME $EXPECTED
} }
check_missing_match_prop() { check_missing_match_prop() {
local NAME=$1 local NAME=$1
local EXPECTED=$2 local EXPECTED=$2
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME) local VALUE=$("$RESETPROP_BIN" $NAME)
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED [ -z $VALUE ] || [ $VALUE = $EXPECTED ] || "$RESETPROP_BIN" $NAME $EXPECTED
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED [ -z $VALUE ] && "$RESETPROP_BIN" $NAME $EXPECTED
} }
contains_reset_prop() { contains_reset_prop() {
local NAME=$1 local NAME=$1
local CONTAINS=$2 local CONTAINS=$2
local NEWVAL=$3 local NEWVAL=$3
case "$("${'$'}RESETPROP_BIN" ${'$'}NAME)" in case "$("$RESETPROP_BIN" $NAME)" in
*"${'$'}CONTAINS"*) "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}NEWVAL ;; *"$CONTAINS"*) "$RESETPROP_BIN" $NAME $NEWVAL ;;
esac esac
} }
""".trimIndent()) """.trimIndent())
appendLine() appendLine()
appendLine("sleep 30") appendLine("sleep 30")
appendLine() appendLine()
appendLine("\"${'$'}RESETPROP_BIN\" -w sys.boot_completed 0") appendLine($$"\"$RESETPROP_BIN\" -w sys.boot_completed 0")
// 添加所有系统属性重置 // 添加所有系统属性重置
val systemProps = listOf( val systemProps = listOf(
@@ -292,27 +292,28 @@ object ScriptGenerator {
// 清理残留脚本生成 // 清理残留脚本生成
private fun StringBuilder.generateCleanupResidueSection() { private fun StringBuilder.generateCleanupResidueSection() {
appendLine("# 清理工具残留文件") appendLine("# 清理工具残留文件")
appendLine("echo \"$(get_current_time): 开始清理工具残留\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 开始清理工具残留\" >> \"$LOG_FILE\"")
appendLine() appendLine()
// 定义清理函数 // 定义清理函数
appendLine(""" appendLine(
$$"""
cleanup_path() { cleanup_path() {
local path="$1" local path="$1"
local desc="$2" local desc="$2"
local current="$3" local current="$3"
local total="$4" local total="$4"
if [ -n "${'$'}desc" ]; then if [ -n "$desc" ]; then
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path (${'$'}desc)" >> "${'$'}LOG_FILE" echo "$(get_current_time): [$current/$total] 清理: $path ($desc)" >> "$LOG_FILE"
else else
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path" >> "${'$'}LOG_FILE" echo "$(get_current_time): [$current/$total] 清理: $path" >> "$LOG_FILE"
fi fi
if rm -rf "${'$'}path" 2>/dev/null; then if rm -rf "$path" 2>/dev/null; then
echo "$(get_current_time): ✓ 成功清理: ${'$'}path" >> "${'$'}LOG_FILE" echo "$(get_current_time): ✓ 成功清理: $path" >> "$LOG_FILE"
else else
echo "$(get_current_time): ✗ 清理失败或不存在: ${'$'}path" >> "${'$'}LOG_FILE" echo "$(get_current_time): ✗ 清理失败或不存在: $path" >> "$LOG_FILE"
fi fi
} }
""".trimIndent()) """.trimIndent())
@@ -360,11 +361,11 @@ object ScriptGenerator {
cleanupPaths.forEachIndexed { index, (path, desc) -> cleanupPaths.forEachIndexed { index, (path, desc) ->
val current = index + 1 val current = index + 1
appendLine("cleanup_path '$path' '$desc' $current \$TOTAL") appendLine($$"cleanup_path '$$path' '$$desc' $$current $TOTAL")
} }
appendLine() appendLine()
appendLine("echo \"$(get_current_time): 工具残留清理完成\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 工具残留清理完成\" >> \"$LOG_FILE\"")
appendLine() appendLine()
} }
@@ -381,35 +382,33 @@ object ScriptGenerator {
appendLine() appendLine()
appendLine(generateBinaryCheck(config.targetPath)) appendLine(generateBinaryCheck(config.targetPath))
appendLine() appendLine()
appendLine("echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"$LOG_FILE\"")
appendLine() appendLine()
// 设置uname和构建时间 - 只有在选择在post-fs-data中执行时才执行 // 设置uname和构建时间 - 只有在选择在post-fs-data中执行时才执行
if (config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) { if (config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
appendLine("# 设置uname和构建时间") appendLine("# 设置uname和构建时间")
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'") appendLine($$"\"$SUSFS_BIN\" set_uname '$${config.unameValue}' '$${config.buildTimeValue}'")
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 设置uname为: $${config.unameValue}, 构建时间为: $${config.buildTimeValue}\" >> \"$LOG_FILE\"")
appendLine() appendLine()
} }
generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService, config.support158) generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService)
// 添加AVC日志欺骗设置 // 添加AVC日志欺骗设置
generateAvcLogSpoofingSection(config.enableAvcLogSpoofing) generateAvcLogSpoofingSection(config.enableAvcLogSpoofing)
appendLine("echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"$LOG_FILE\"")
} }
} }
// 添加新的生成方法 // 添加新的生成方法
private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean, support158: Boolean) { private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean) {
if (support158) { appendLine("# 设置Zygote隔离服务卸载状态")
appendLine("# 设置Zygote隔离服务卸载状态") val umountValue = if (umountForZygoteIsoService) 1 else 0
val umountValue = if (umountForZygoteIsoService) 1 else 0 appendLine($$"\"$SUSFS_BIN\" umount_for_zygote_iso_service $$umountValue")
appendLine("\"${'$'}SUSFS_BIN\" umount_for_zygote_iso_service $umountValue") appendLine($$"echo \"$(get_current_time): Zygote隔离服务卸载设置为: $${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
appendLine("echo \"$(get_current_time): Zygote隔离服务卸载设置为: ${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"") appendLine()
appendLine()
}
} }
/** /**
@@ -423,37 +422,12 @@ object ScriptGenerator {
appendLine() appendLine()
appendLine(generateLogSetup("susfs_post_mount.log")) appendLine(generateLogSetup("susfs_post_mount.log"))
appendLine() appendLine()
appendLine("echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"$LOG_FILE\"")
appendLine() appendLine()
appendLine(generateBinaryCheck(config.targetPath)) appendLine(generateBinaryCheck(config.targetPath))
appendLine() appendLine()
// 添加SUS挂载 appendLine($$"echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"$LOG_FILE\"")
if (config.susMounts.isNotEmpty()) {
appendLine("# 添加SUS挂载")
config.susMounts.forEach { mount ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_mount '$mount'")
appendLine("echo \"$(get_current_time): 添加SUS挂载: $mount\" >> \"${'$'}LOG_FILE\"")
}
appendLine()
}
// 添加尝试卸载
if (config.tryUmounts.isNotEmpty()) {
appendLine("# 添加尝试卸载")
config.tryUmounts.forEach { umount ->
val parts = umount.split("|")
if (parts.size == 2) {
val path = parts[0]
val mode = parts[1]
appendLine("\"${'$'}SUSFS_BIN\" add_try_umount '$path' $mode")
appendLine("echo \"$(get_current_time): 添加尝试卸载: $path (模式: $mode)\" >> \"${'$'}LOG_FILE\"")
}
}
appendLine()
}
appendLine("echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"${'$'}LOG_FILE\"")
} }
} }
@@ -469,42 +443,39 @@ object ScriptGenerator {
appendLine() appendLine()
appendLine(generateLogSetup("susfs_boot_completed.log")) appendLine(generateLogSetup("susfs_boot_completed.log"))
appendLine() appendLine()
appendLine("echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"$LOG_FILE\"")
appendLine() appendLine()
appendLine(generateBinaryCheck(config.targetPath)) appendLine(generateBinaryCheck(config.targetPath))
appendLine() appendLine()
// 仅在支持隐藏挂载功能时执行相关配置 // SUS挂载隐藏控制
if (config.support158) { val hideValue = if (config.hideSusMountsForAllProcs) 1 else 0
// SUS挂载隐藏控制 appendLine("# 设置SUS挂载隐藏控制")
val hideValue = if (config.hideSusMountsForAllProcs) 1 else 0 appendLine($$"\"$SUSFS_BIN\" hide_sus_mnts_for_all_procs $$hideValue")
appendLine("# 设置SUS挂载隐藏控制") appendLine($$"echo \"$(get_current_time): SUS挂载隐藏控制设置为: $${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"$LOG_FILE\"")
appendLine("\"${'$'}SUSFS_BIN\" hide_sus_mnts_for_all_procs $hideValue") appendLine()
appendLine("echo \"$(get_current_time): SUS挂载隐藏控制设置为: ${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"${'$'}LOG_FILE\"")
// 路径设置和SUS路径设置
if (config.susPaths.isNotEmpty() || config.susLoopPaths.isNotEmpty()) {
generatePathSettingSection(config.androidDataPath, config.sdcardPath)
appendLine() appendLine()
// 路径设置和SUS路径设置 // 添加普通SUS路径
if (config.susPaths.isNotEmpty() || config.susLoopPaths.isNotEmpty()) { if (config.susPaths.isNotEmpty()) {
generatePathSettingSection(config.androidDataPath, config.sdcardPath) generateSusPathsSection(config.susPaths)
appendLine() }
// 添加普通SUS路径 // 添加循环SUS路径
if (config.susPaths.isNotEmpty()) { if (config.susLoopPaths.isNotEmpty()) {
generateSusPathsSection(config.susPaths) generateSusLoopPathsSection(config.susLoopPaths)
} }
// 添加循环SUS路径 if (config.susMaps.isNotEmpty()) {
if (config.susLoopPaths.isNotEmpty()) { generateSusMapsSection(config.susMaps)
generateSusLoopPathsSection(config.susLoopPaths)
}
if (config.susMaps.isNotEmpty()) {
generateSusMapsSection(config.susMaps)
}
} }
} }
appendLine("echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"$LOG_FILE\"")
} }
} }
@@ -512,8 +483,8 @@ object ScriptGenerator {
if (susMaps.isNotEmpty()) { if (susMaps.isNotEmpty()) {
appendLine("# 添加SUS映射") appendLine("# 添加SUS映射")
susMaps.forEach { map -> susMaps.forEach { map ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_map '$map'") appendLine($$"\"$SUSFS_BIN\" add_sus_map '$$map'")
appendLine("echo \"$(get_current_time): 添加SUS映射: $map\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): 添加SUS映射: $$map\" >> \"$LOG_FILE\"")
} }
appendLine() appendLine()
} }
@@ -526,12 +497,12 @@ object ScriptGenerator {
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done") appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
appendLine("sleep 60") appendLine("sleep 60")
appendLine() appendLine()
appendLine("\"${'$'}SUSFS_BIN\" set_android_data_root_path '$androidDataPath'") appendLine($$"\"$SUSFS_BIN\" set_android_data_root_path '$$androidDataPath'")
appendLine("echo \"$(get_current_time): Android Data路径设置为: $androidDataPath\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): Android Data路径设置为: $$androidDataPath\" >> \"$LOG_FILE\"")
appendLine() appendLine()
appendLine("# 设置SD卡路径") appendLine("# 设置SD卡路径")
appendLine("\"${'$'}SUSFS_BIN\" set_sdcard_root_path '$sdcardPath'") appendLine($$"\"$SUSFS_BIN\" set_sdcard_root_path '$$sdcardPath'")
appendLine("echo \"$(get_current_time): SD卡路径设置为: $sdcardPath\" >> \"${'$'}LOG_FILE\"") appendLine($$"echo \"$(get_current_time): SD卡路径设置为: $$sdcardPath\" >> \"$LOG_FILE\"")
appendLine() appendLine()
} }
@@ -539,8 +510,8 @@ object ScriptGenerator {
* 生成module.prop文件内容 * 生成module.prop文件内容
*/ */
fun generateModuleProp(moduleId: String): String { fun generateModuleProp(moduleId: String): String {
val moduleVersion = "v1.0.2" val moduleVersion = "v4.0.0"
val moduleVersionCode = "1002" val moduleVersionCode = "40000"
return """ return """
id=$moduleId id=$moduleId

View File

@@ -1,192 +0,0 @@
package com.sukisu.ultra.ui.theme
import android.content.Context
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.CardDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Stable
object CardConfig {
// 卡片透明度
var cardAlpha by mutableFloatStateOf(1f)
internal set
// 卡片亮度
var cardDim by mutableFloatStateOf(0f)
internal set
// 卡片阴影
var cardElevation by mutableStateOf(0.dp)
internal set
// 功能开关
var isShadowEnabled by mutableStateOf(true)
internal set
var isCustomBackgroundEnabled by mutableStateOf(false)
internal set
var isCustomAlphaSet by mutableStateOf(false)
internal set
var isCustomDimSet by mutableStateOf(false)
internal set
var isUserDarkModeEnabled by mutableStateOf(false)
internal set
var isUserLightModeEnabled by mutableStateOf(false)
internal set
// 配置键名
private object Keys {
const val CARD_ALPHA = "card_alpha"
const val CARD_DIM = "card_dim"
const val CUSTOM_BACKGROUND_ENABLED = "custom_background_enabled"
const val IS_SHADOW_ENABLED = "is_shadow_enabled"
const val IS_CUSTOM_ALPHA_SET = "is_custom_alpha_set"
const val IS_CUSTOM_DIM_SET = "is_custom_dim_set"
const val IS_USER_DARK_MODE_ENABLED = "is_user_dark_mode_enabled"
const val IS_USER_LIGHT_MODE_ENABLED = "is_user_light_mode_enabled"
}
fun updateAlpha(alpha: Float, isCustom: Boolean = true) {
cardAlpha = alpha.coerceIn(0f, 1f)
if (isCustom) isCustomAlphaSet = true
}
fun updateDim(dim: Float, isCustom: Boolean = true) {
cardDim = dim.coerceIn(0f, 1f)
if (isCustom) isCustomDimSet = true
}
fun updateShadow(enabled: Boolean, elevation: Dp = cardElevation) {
isShadowEnabled = enabled
cardElevation = if (enabled) elevation else cardElevation
}
fun updateBackground(enabled: Boolean) {
isCustomBackgroundEnabled = enabled
// 自定义背景时自动禁用阴影以获得更好的视觉效果
if (enabled) {
updateShadow(false)
}
}
fun updateThemePreference(darkMode: Boolean?, lightMode: Boolean?) {
isUserDarkModeEnabled = darkMode ?: false
isUserLightModeEnabled = lightMode ?: false
}
fun reset() {
cardAlpha = 1f
cardDim = 0f
cardElevation = 0.dp
isShadowEnabled = true
isCustomBackgroundEnabled = false
isCustomAlphaSet = false
isCustomDimSet = false
isUserDarkModeEnabled = false
isUserLightModeEnabled = false
}
fun setThemeDefaults(isDarkMode: Boolean) {
if (!isCustomAlphaSet) {
updateAlpha(if (isDarkMode) 0.88f else 1f, false)
}
if (!isCustomDimSet) {
updateDim(if (isDarkMode) 0.25f else 0f, false)
}
// 暗色模式下默认启用轻微阴影
if (isDarkMode && !isCustomBackgroundEnabled) {
updateShadow(true, 2.dp)
}
}
fun save(context: Context) {
val prefs = context.getSharedPreferences("card_settings", Context.MODE_PRIVATE)
prefs.edit().apply {
putFloat(Keys.CARD_ALPHA, cardAlpha)
putFloat(Keys.CARD_DIM, cardDim)
putBoolean(Keys.CUSTOM_BACKGROUND_ENABLED, isCustomBackgroundEnabled)
putBoolean(Keys.IS_SHADOW_ENABLED, isShadowEnabled)
putBoolean(Keys.IS_CUSTOM_ALPHA_SET, isCustomAlphaSet)
putBoolean(Keys.IS_CUSTOM_DIM_SET, isCustomDimSet)
putBoolean(Keys.IS_USER_DARK_MODE_ENABLED, isUserDarkModeEnabled)
putBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, isUserLightModeEnabled)
apply()
}
}
fun load(context: Context) {
val prefs = context.getSharedPreferences("card_settings", Context.MODE_PRIVATE)
cardAlpha = prefs.getFloat(Keys.CARD_ALPHA, 1f).coerceIn(0f, 1f)
cardDim = prefs.getFloat(Keys.CARD_DIM, 0f).coerceIn(0f, 1f)
isCustomBackgroundEnabled = prefs.getBoolean(Keys.CUSTOM_BACKGROUND_ENABLED, false)
isShadowEnabled = prefs.getBoolean(Keys.IS_SHADOW_ENABLED, true)
isCustomAlphaSet = prefs.getBoolean(Keys.IS_CUSTOM_ALPHA_SET, false)
isCustomDimSet = prefs.getBoolean(Keys.IS_CUSTOM_DIM_SET, false)
isUserDarkModeEnabled = prefs.getBoolean(Keys.IS_USER_DARK_MODE_ENABLED, false)
isUserLightModeEnabled = prefs.getBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, false)
// 应用阴影设置
updateShadow(isShadowEnabled, if (isShadowEnabled) cardElevation else 0.dp)
}
@Deprecated("使用 updateShadow 替代", ReplaceWith("updateShadow(enabled)"))
fun updateShadowEnabled(enabled: Boolean) {
updateShadow(enabled)
}
}
object CardStyleProvider {
@Composable
fun getCardColors(originalColor: Color) = CardDefaults.cardColors(
containerColor = originalColor.copy(alpha = CardConfig.cardAlpha),
contentColor = determineContentColor(originalColor),
disabledContainerColor = originalColor.copy(alpha = CardConfig.cardAlpha * 0.38f),
disabledContentColor = determineContentColor(originalColor).copy(alpha = 0.38f)
)
@Composable
fun getCardElevation() = CardDefaults.cardElevation(
defaultElevation = CardConfig.cardElevation,
pressedElevation = if (CardConfig.isShadowEnabled) {
(CardConfig.cardElevation.value + 0).dp
} else 0.dp,
focusedElevation = if (CardConfig.isShadowEnabled) {
(CardConfig.cardElevation.value + 0).dp
} else 0.dp,
hoveredElevation = if (CardConfig.isShadowEnabled) {
(CardConfig.cardElevation.value + 0).dp
} else 0.dp,
draggedElevation = if (CardConfig.isShadowEnabled) {
(CardConfig.cardElevation.value + 0).dp
} else 0.dp,
disabledElevation = 0.dp
)
@Composable
private fun determineContentColor(originalColor: Color): Color {
val isDarkTheme = isSystemInDarkTheme()
return when {
ThemeConfig.isThemeChanging -> {
if (isDarkTheme) Color.White else Color.Black
}
CardConfig.isUserLightModeEnabled -> Color.Black
CardConfig.isUserDarkModeEnabled -> Color.White
else -> {
val luminance = originalColor.luminance()
val threshold = if (isDarkTheme) 0.4f else 0.6f
if (luminance > threshold) Color.Black else Color.White
}
}
}
}
// 向后兼容
@Composable
fun getCardColors(originalColor: Color) = CardStyleProvider.getCardColors(originalColor)
@Composable
fun getCardElevation() = CardStyleProvider.getCardElevation()

View File

@@ -1,615 +0,0 @@
package com.sukisu.ultra.ui.theme
import androidx.compose.ui.graphics.Color
sealed class ThemeColors {
// 浅色
abstract val primaryLight: Color
abstract val onPrimaryLight: Color
abstract val primaryContainerLight: Color
abstract val onPrimaryContainerLight: Color
abstract val secondaryLight: Color
abstract val onSecondaryLight: Color
abstract val secondaryContainerLight: Color
abstract val onSecondaryContainerLight: Color
abstract val tertiaryLight: Color
abstract val onTertiaryLight: Color
abstract val tertiaryContainerLight: Color
abstract val onTertiaryContainerLight: Color
abstract val errorLight: Color
abstract val onErrorLight: Color
abstract val errorContainerLight: Color
abstract val onErrorContainerLight: Color
abstract val backgroundLight: Color
abstract val onBackgroundLight: Color
abstract val surfaceLight: Color
abstract val onSurfaceLight: Color
abstract val surfaceVariantLight: Color
abstract val onSurfaceVariantLight: Color
abstract val outlineLight: Color
abstract val outlineVariantLight: Color
abstract val scrimLight: Color
abstract val inverseSurfaceLight: Color
abstract val inverseOnSurfaceLight: Color
abstract val inversePrimaryLight: Color
abstract val surfaceDimLight: Color
abstract val surfaceBrightLight: Color
abstract val surfaceContainerLowestLight: Color
abstract val surfaceContainerLowLight: Color
abstract val surfaceContainerLight: Color
abstract val surfaceContainerHighLight: Color
abstract val surfaceContainerHighestLight: Color
// 深色
abstract val primaryDark: Color
abstract val onPrimaryDark: Color
abstract val primaryContainerDark: Color
abstract val onPrimaryContainerDark: Color
abstract val secondaryDark: Color
abstract val onSecondaryDark: Color
abstract val secondaryContainerDark: Color
abstract val onSecondaryContainerDark: Color
abstract val tertiaryDark: Color
abstract val onTertiaryDark: Color
abstract val tertiaryContainerDark: Color
abstract val onTertiaryContainerDark: Color
abstract val errorDark: Color
abstract val onErrorDark: Color
abstract val errorContainerDark: Color
abstract val onErrorContainerDark: Color
abstract val backgroundDark: Color
abstract val onBackgroundDark: Color
abstract val surfaceDark: Color
abstract val onSurfaceDark: Color
abstract val surfaceVariantDark: Color
abstract val onSurfaceVariantDark: Color
abstract val outlineDark: Color
abstract val outlineVariantDark: Color
abstract val scrimDark: Color
abstract val inverseSurfaceDark: Color
abstract val inverseOnSurfaceDark: Color
abstract val inversePrimaryDark: Color
abstract val surfaceDimDark: Color
abstract val surfaceBrightDark: Color
abstract val surfaceContainerLowestDark: Color
abstract val surfaceContainerLowDark: Color
abstract val surfaceContainerDark: Color
abstract val surfaceContainerHighDark: Color
abstract val surfaceContainerHighestDark: Color
// 默认主题 (蓝色)
object Default : ThemeColors() {
override val primaryLight = Color(0xFF415F91)
override val onPrimaryLight = Color(0xFFFFFFFF)
override val primaryContainerLight = Color(0xFFD6E3FF)
override val onPrimaryContainerLight = Color(0xFF284777)
override val secondaryLight = Color(0xFF565F71)
override val onSecondaryLight = Color(0xFFFFFFFF)
override val secondaryContainerLight = Color(0xFFDAE2F9)
override val onSecondaryContainerLight = Color(0xFF3E4759)
override val tertiaryLight = Color(0xFF705575)
override val onTertiaryLight = Color(0xFFFFFFFF)
override val tertiaryContainerLight = Color(0xFFFAD8FD)
override val onTertiaryContainerLight = Color(0xFF573E5C)
override val errorLight = Color(0xFFBA1A1A)
override val onErrorLight = Color(0xFFFFFFFF)
override val errorContainerLight = Color(0xFFFFDAD6)
override val onErrorContainerLight = Color(0xFF93000A)
override val backgroundLight = Color(0xFFF9F9FF)
override val onBackgroundLight = Color(0xFF191C20)
override val surfaceLight = Color(0xFFF9F9FF)
override val onSurfaceLight = Color(0xFF191C20)
override val surfaceVariantLight = Color(0xFFE0E2EC)
override val onSurfaceVariantLight = Color(0xFF44474E)
override val outlineLight = Color(0xFF74777F)
override val outlineVariantLight = Color(0xFFC4C6D0)
override val scrimLight = Color(0xFF000000)
override val inverseSurfaceLight = Color(0xFF2E3036)
override val inverseOnSurfaceLight = Color(0xFFF0F0F7)
override val inversePrimaryLight = Color(0xFFAAC7FF)
override val surfaceDimLight = Color(0xFFD9D9E0)
override val surfaceBrightLight = Color(0xFFF9F9FF)
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
override val surfaceContainerLowLight = Color(0xFFF3F3FA)
override val surfaceContainerLight = Color(0xFFEDEDF4)
override val surfaceContainerHighLight = Color(0xFFE7E8EE)
override val surfaceContainerHighestLight = Color(0xFFE2E2E9)
override val primaryDark = Color(0xFFAAC7FF)
override val onPrimaryDark = Color(0xFF0A305F)
override val primaryContainerDark = Color(0xFF284777)
override val onPrimaryContainerDark = Color(0xFFD6E3FF)
override val secondaryDark = Color(0xFFBEC6DC)
override val onSecondaryDark = Color(0xFF283141)
override val secondaryContainerDark = Color(0xFF3E4759)
override val onSecondaryContainerDark = Color(0xFFDAE2F9)
override val tertiaryDark = Color(0xFFDDBCE0)
override val onTertiaryDark = Color(0xFF3F2844)
override val tertiaryContainerDark = Color(0xFF573E5C)
override val onTertiaryContainerDark = Color(0xFFFAD8FD)
override val errorDark = Color(0xFFFFB4AB)
override val onErrorDark = Color(0xFF690005)
override val errorContainerDark = Color(0xFF93000A)
override val onErrorContainerDark = Color(0xFFFFDAD6)
override val backgroundDark = Color(0xFF111318)
override val onBackgroundDark = Color(0xFFE2E2E9)
override val surfaceDark = Color(0xFF111318)
override val onSurfaceDark = Color(0xFFE2E2E9)
override val surfaceVariantDark = Color(0xFF44474E)
override val onSurfaceVariantDark = Color(0xFFC4C6D0)
override val outlineDark = Color(0xFF8E9099)
override val outlineVariantDark = Color(0xFF44474E)
override val scrimDark = Color(0xFF000000)
override val inverseSurfaceDark = Color(0xFFE2E2E9)
override val inverseOnSurfaceDark = Color(0xFF2E3036)
override val inversePrimaryDark = Color(0xFF415F91)
override val surfaceDimDark = Color(0xFF111318)
override val surfaceBrightDark = Color(0xFF37393E)
override val surfaceContainerLowestDark = Color(0xFF0C0E13)
override val surfaceContainerLowDark = Color(0xFF191C20)
override val surfaceContainerDark = Color(0xFF1D2024)
override val surfaceContainerHighDark = Color(0xFF282A2F)
override val surfaceContainerHighestDark = Color(0xFF33353A)
}
// 绿色主题
object Green : ThemeColors() {
override val primaryLight = Color(0xFF4C662B)
override val onPrimaryLight = Color(0xFFFFFFFF)
override val primaryContainerLight = Color(0xFFCDEDA3)
override val onPrimaryContainerLight = Color(0xFF354E16)
override val secondaryLight = Color(0xFF586249)
override val onSecondaryLight = Color(0xFFFFFFFF)
override val secondaryContainerLight = Color(0xFFDCE7C8)
override val onSecondaryContainerLight = Color(0xFF404A33)
override val tertiaryLight = Color(0xFF386663)
override val onTertiaryLight = Color(0xFFFFFFFF)
override val tertiaryContainerLight = Color(0xFFBCECE7)
override val onTertiaryContainerLight = Color(0xFF1F4E4B)
override val errorLight = Color(0xFFBA1A1A)
override val onErrorLight = Color(0xFFFFFFFF)
override val errorContainerLight = Color(0xFFFFDAD6)
override val onErrorContainerLight = Color(0xFF93000A)
override val backgroundLight = Color(0xFFF9FAEF)
override val onBackgroundLight = Color(0xFF1A1C16)
override val surfaceLight = Color(0xFFF9FAEF)
override val onSurfaceLight = Color(0xFF1A1C16)
override val surfaceVariantLight = Color(0xFFE1E4D5)
override val onSurfaceVariantLight = Color(0xFF44483D)
override val outlineLight = Color(0xFF75796C)
override val outlineVariantLight = Color(0xFFC5C8BA)
override val scrimLight = Color(0xFF000000)
override val inverseSurfaceLight = Color(0xFF2F312A)
override val inverseOnSurfaceLight = Color(0xFFF1F2E6)
override val inversePrimaryLight = Color(0xFFB1D18A)
override val surfaceDimLight = Color(0xFFDADBD0)
override val surfaceBrightLight = Color(0xFFF9FAEF)
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
override val surfaceContainerLowLight = Color(0xFFF3F4E9)
override val surfaceContainerLight = Color(0xFFEEEFE3)
override val surfaceContainerHighLight = Color(0xFFE8E9DE)
override val surfaceContainerHighestLight = Color(0xFFE2E3D8)
override val primaryDark = Color(0xFFB1D18A)
override val onPrimaryDark = Color(0xFF1F3701)
override val primaryContainerDark = Color(0xFF354E16)
override val onPrimaryContainerDark = Color(0xFFCDEDA3)
override val secondaryDark = Color(0xFFBFCBAD)
override val onSecondaryDark = Color(0xFF2A331E)
override val secondaryContainerDark = Color(0xFF404A33)
override val onSecondaryContainerDark = Color(0xFFDCE7C8)
override val tertiaryDark = Color(0xFFA0D0CB)
override val onTertiaryDark = Color(0xFF003735)
override val tertiaryContainerDark = Color(0xFF1F4E4B)
override val onTertiaryContainerDark = Color(0xFFBCECE7)
override val errorDark = Color(0xFFFFB4AB)
override val onErrorDark = Color(0xFF690005)
override val errorContainerDark = Color(0xFF93000A)
override val onErrorContainerDark = Color(0xFFFFDAD6)
override val backgroundDark = Color(0xFF12140E)
override val onBackgroundDark = Color(0xFFE2E3D8)
override val surfaceDark = Color(0xFF12140E)
override val onSurfaceDark = Color(0xFFE2E3D8)
override val surfaceVariantDark = Color(0xFF44483D)
override val onSurfaceVariantDark = Color(0xFFC5C8BA)
override val outlineDark = Color(0xFF8F9285)
override val outlineVariantDark = Color(0xFF44483D)
override val scrimDark = Color(0xFF000000)
override val inverseSurfaceDark = Color(0xFFE2E3D8)
override val inverseOnSurfaceDark = Color(0xFF2F312A)
override val inversePrimaryDark = Color(0xFF4C662B)
override val surfaceDimDark = Color(0xFF12140E)
override val surfaceBrightDark = Color(0xFF383A32)
override val surfaceContainerLowestDark = Color(0xFF0C0F09)
override val surfaceContainerLowDark = Color(0xFF1A1C16)
override val surfaceContainerDark = Color(0xFF1E201A)
override val surfaceContainerHighDark = Color(0xFF282B24)
override val surfaceContainerHighestDark = Color(0xFF33362E)
}
// 紫色主题
object Purple : ThemeColors() {
override val primaryLight = Color(0xFF7C4E7E)
override val onPrimaryLight = Color(0xFFFFFFFF)
override val primaryContainerLight = Color(0xFFFFD6FC)
override val onPrimaryContainerLight = Color(0xFF623765)
override val secondaryLight = Color(0xFF6C586B)
override val onSecondaryLight = Color(0xFFFFFFFF)
override val secondaryContainerLight = Color(0xFFF5DBF1)
override val onSecondaryContainerLight = Color(0xFF534152)
override val tertiaryLight = Color(0xFF825249)
override val onTertiaryLight = Color(0xFFFFFFFF)
override val tertiaryContainerLight = Color(0xFFFFDAD4)
override val onTertiaryContainerLight = Color(0xFF673B33)
override val errorLight = Color(0xFFBA1A1A)
override val onErrorLight = Color(0xFFFFFFFF)
override val errorContainerLight = Color(0xFFFFDAD6)
override val onErrorContainerLight = Color(0xFF93000A)
override val backgroundLight = Color(0xFFFFF7FA)
override val onBackgroundLight = Color(0xFF1F1A1F)
override val surfaceLight = Color(0xFFFFF7FA)
override val onSurfaceLight = Color(0xFF1F1A1F)
override val surfaceVariantLight = Color(0xFFEDDFE8)
override val onSurfaceVariantLight = Color(0xFF4D444C)
override val outlineLight = Color(0xFF7F747C)
override val outlineVariantLight = Color(0xFFD0C3CC)
override val scrimLight = Color(0xFF000000)
override val inverseSurfaceLight = Color(0xFF352F34)
override val inverseOnSurfaceLight = Color(0xFFF9EEF4)
override val inversePrimaryLight = Color(0xFFECB4EC)
override val surfaceDimLight = Color(0xFFE2D7DE)
override val surfaceBrightLight = Color(0xFFFFF7FA)
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
override val surfaceContainerLowLight = Color(0xFFFCF0F7)
override val surfaceContainerLight = Color(0xFFF6EBF2)
override val surfaceContainerHighLight = Color(0xFFF0E5EC)
override val surfaceContainerHighestLight = Color(0xFFEBDFE6)
override val primaryDark = Color(0xFFECB4EC)
override val onPrimaryDark = Color(0xFF49204D)
override val primaryContainerDark = Color(0xFF623765)
override val onPrimaryContainerDark = Color(0xFFFFD6FC)
override val secondaryDark = Color(0xFFD8BFD5)
override val onSecondaryDark = Color(0xFF3B2B3B)
override val secondaryContainerDark = Color(0xFF534152)
override val onSecondaryContainerDark = Color(0xFFF5DBF1)
override val tertiaryDark = Color(0xFFF6B8AD)
override val onTertiaryDark = Color(0xFF4C251F)
override val tertiaryContainerDark = Color(0xFF673B33)
override val onTertiaryContainerDark = Color(0xFFFFDAD4)
override val errorDark = Color(0xFFFFB4AB)
override val onErrorDark = Color(0xFF690005)
override val errorContainerDark = Color(0xFF93000A)
override val onErrorContainerDark = Color(0xFFFFDAD6)
override val backgroundDark = Color(0xFF171216)
override val onBackgroundDark = Color(0xFFEBDFE6)
override val surfaceDark = Color(0xFF171216)
override val onSurfaceDark = Color(0xFFEBDFE6)
override val surfaceVariantDark = Color(0xFF4D444C)
override val onSurfaceVariantDark = Color(0xFFD0C3CC)
override val outlineDark = Color(0xFF998D96)
override val outlineVariantDark = Color(0xFF4D444C)
override val scrimDark = Color(0xFF000000)
override val inverseSurfaceDark = Color(0xFFEBDFE6)
override val inverseOnSurfaceDark = Color(0xFF352F34)
override val inversePrimaryDark = Color(0xFF7C4E7E)
override val surfaceDimDark = Color(0xFF171216)
override val surfaceBrightDark = Color(0xFF3E373D)
override val surfaceContainerLowestDark = Color(0xFF110D11)
override val surfaceContainerLowDark = Color(0xFF1F1A1F)
override val surfaceContainerDark = Color(0xFF231E23)
override val surfaceContainerHighDark = Color(0xFF2E282D)
override val surfaceContainerHighestDark = Color(0xFF393338)
}
// 橙色主题
object Orange : ThemeColors() {
override val primaryLight = Color(0xFF8B4F24)
override val onPrimaryLight = Color(0xFFFFFFFF)
override val primaryContainerLight = Color(0xFFFFDCC7)
override val onPrimaryContainerLight = Color(0xFF6E390E)
override val secondaryLight = Color(0xFF755846)
override val onSecondaryLight = Color(0xFFFFFFFF)
override val secondaryContainerLight = Color(0xFFFFDCC7)
override val onSecondaryContainerLight = Color(0xFF5B4130)
override val tertiaryLight = Color(0xFF865219)
override val onTertiaryLight = Color(0xFFFFFFFF)
override val tertiaryContainerLight = Color(0xFFFFDCBF)
override val onTertiaryContainerLight = Color(0xFF6A3B01)
override val errorLight = Color(0xFFBA1A1A)
override val onErrorLight = Color(0xFFFFFFFF)
override val errorContainerLight = Color(0xFFFFDAD6)
override val onErrorContainerLight = Color(0xFF93000A)
override val backgroundLight = Color(0xFFFFF8F5)
override val onBackgroundLight = Color(0xFF221A15)
override val surfaceLight = Color(0xFFFFF8F5)
override val onSurfaceLight = Color(0xFF221A15)
override val surfaceVariantLight = Color(0xFFF4DED3)
override val onSurfaceVariantLight = Color(0xFF52443C)
override val outlineLight = Color(0xFF84746A)
override val outlineVariantLight = Color(0xFFD7C3B8)
override val scrimLight = Color(0xFF000000)
override val inverseSurfaceLight = Color(0xFF382E29)
override val inverseOnSurfaceLight = Color(0xFFFFEDE5)
override val inversePrimaryLight = Color(0xFFFFB787)
override val surfaceDimLight = Color(0xFFE7D7CE)
override val surfaceBrightLight = Color(0xFFFFF8F5)
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
override val surfaceContainerLowLight = Color(0xFFFFF1EA)
override val surfaceContainerLight = Color(0xFFFCEBE2)
override val surfaceContainerHighLight = Color(0xFFF6E5DC)
override val surfaceContainerHighestLight = Color(0xFFF0DFD7)
override val primaryDark = Color(0xFFFFB787)
override val onPrimaryDark = Color(0xFF502400)
override val primaryContainerDark = Color(0xFF6E390E)
override val onPrimaryContainerDark = Color(0xFFFFDCC7)
override val secondaryDark = Color(0xFFE5BFA8)
override val onSecondaryDark = Color(0xFF422B1B)
override val secondaryContainerDark = Color(0xFF5B4130)
override val onSecondaryContainerDark = Color(0xFFFFDCC7)
override val tertiaryDark = Color(0xFFFDB876)
override val onTertiaryDark = Color(0xFF4B2800)
override val tertiaryContainerDark = Color(0xFF6A3B01)
override val onTertiaryContainerDark = Color(0xFFFFDCBF)
override val errorDark = Color(0xFFFFB4AB)
override val onErrorDark = Color(0xFF690005)
override val errorContainerDark = Color(0xFF93000A)
override val onErrorContainerDark = Color(0xFFFFDAD6)
override val backgroundDark = Color(0xFF19120D)
override val onBackgroundDark = Color(0xFFF0DFD7)
override val surfaceDark = Color(0xFF19120D)
override val onSurfaceDark = Color(0xFFF0DFD7)
override val surfaceVariantDark = Color(0xFF52443C)
override val onSurfaceVariantDark = Color(0xFFD7C3B8)
override val outlineDark = Color(0xFF9F8D83)
override val outlineVariantDark = Color(0xFF52443C)
override val scrimDark = Color(0xFF000000)
override val inverseSurfaceDark = Color(0xFFF0DFD7)
override val inverseOnSurfaceDark = Color(0xFF382E29)
override val inversePrimaryDark = Color(0xFF8B4F24)
override val surfaceDimDark = Color(0xFF19120D)
override val surfaceBrightDark = Color(0xFF413731)
override val surfaceContainerLowestDark = Color(0xFF140D08)
override val surfaceContainerLowDark = Color(0xFF221A15)
override val surfaceContainerDark = Color(0xFF261E19)
override val surfaceContainerHighDark = Color(0xFF312823)
override val surfaceContainerHighestDark = Color(0xFF3D332D)
}
// 粉色主题
object Pink : ThemeColors() {
override val primaryLight = Color(0xFF8C4A60)
override val onPrimaryLight = Color(0xFFFFFFFF)
override val primaryContainerLight = Color(0xFFFFD9E2)
override val onPrimaryContainerLight = Color(0xFF703348)
override val secondaryLight = Color(0xFF8B4A62)
override val onSecondaryLight = Color(0xFFFFFFFF)
override val secondaryContainerLight = Color(0xFFFFD9E3)
override val onSecondaryContainerLight = Color(0xFF6F334B)
override val tertiaryLight = Color(0xFF8B4A62)
override val onTertiaryLight = Color(0xFFFFFFFF)
override val tertiaryContainerLight = Color(0xFFFFD9E3)
override val onTertiaryContainerLight = Color(0xFF6F334B)
override val errorLight = Color(0xFFBA1A1A)
override val onErrorLight = Color(0xFFFFFFFF)
override val errorContainerLight = Color(0xFFFFDAD6)
override val onErrorContainerLight = Color(0xFF93000A)
override val backgroundLight = Color(0xFFFFF8F8)
override val onBackgroundLight = Color(0xFF22191B)
override val surfaceLight = Color(0xFFFFF8F8)
override val onSurfaceLight = Color(0xFF22191B)
override val surfaceVariantLight = Color(0xFFF2DDE1)
override val onSurfaceVariantLight = Color(0xFF514346)
override val outlineLight = Color(0xFF837377)
override val outlineVariantLight = Color(0xFFD5C2C5)
override val scrimLight = Color(0xFF000000)
override val inverseSurfaceLight = Color(0xFF372E30)
override val inverseOnSurfaceLight = Color(0xFFFDEDEF)
override val inversePrimaryLight = Color(0xFFFFB1C7)
override val surfaceDimLight = Color(0xFFE6D6D9)
override val surfaceBrightLight = Color(0xFFFFF8F8)
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
override val surfaceContainerLowLight = Color(0xFFFFF0F2)
override val surfaceContainerLight = Color(0xFFFBEAED)
override val surfaceContainerHighLight = Color(0xFFF5E4E7)
override val surfaceContainerHighestLight = Color(0xFFEFDFE1)
override val primaryDark = Color(0xFFFFB1C7)
override val onPrimaryDark = Color(0xFF541D32)
override val primaryContainerDark = Color(0xFF703348)
override val onPrimaryContainerDark = Color(0xFFFFD9E2)
override val secondaryDark = Color(0xFFFFB0CB)
override val onSecondaryDark = Color(0xFF541D34)
override val secondaryContainerDark = Color(0xFF6F334B)
override val onSecondaryContainerDark = Color(0xFFFFD9E3)
override val tertiaryDark = Color(0xFFFFB0CB)
override val onTertiaryDark = Color(0xFF541D34)
override val tertiaryContainerDark = Color(0xFF6F334B)
override val onTertiaryContainerDark = Color(0xFFFFD9E3)
override val errorDark = Color(0xFFFFB4AB)
override val onErrorDark = Color(0xFF690005)
override val errorContainerDark = Color(0xFF93000A)
override val onErrorContainerDark = Color(0xFFFFDAD6)
override val backgroundDark = Color(0xFF191113)
override val onBackgroundDark = Color(0xFFEFDFE1)
override val surfaceDark = Color(0xFF191113)
override val onSurfaceDark = Color(0xFFEFDFE1)
override val surfaceVariantDark = Color(0xFF514346)
override val onSurfaceVariantDark = Color(0xFFD5C2C5)
override val outlineDark = Color(0xFF9E8C90)
override val outlineVariantDark = Color(0xFF514346)
override val scrimDark = Color(0xFF000000)
override val inverseSurfaceDark = Color(0xFFEFDFE1)
override val inverseOnSurfaceDark = Color(0xFF372E30)
override val inversePrimaryDark = Color(0xFF8C4A60)
override val surfaceDimDark = Color(0xFF191113)
override val surfaceBrightDark = Color(0xFF413739)
override val surfaceContainerLowestDark = Color(0xFF140C0E)
override val surfaceContainerLowDark = Color(0xFF22191B)
override val surfaceContainerDark = Color(0xFF261D1F)
override val surfaceContainerHighDark = Color(0xFF31282A)
override val surfaceContainerHighestDark = Color(0xFF3C3234)
}
// 灰色主题
object Gray : ThemeColors() {
override val primaryLight = Color(0xFF5B5C5C)
override val onPrimaryLight = Color(0xFFFFFFFF)
override val primaryContainerLight = Color(0xFF747474)
override val onPrimaryContainerLight = Color(0xFFFEFCFC)
override val secondaryLight = Color(0xFF5F5E5E)
override val onSecondaryLight = Color(0xFFFFFFFF)
override val secondaryContainerLight = Color(0xFFE4E2E1)
override val onSecondaryContainerLight = Color(0xFF656464)
override val tertiaryLight = Color(0xFF5E5B5D)
override val onTertiaryLight = Color(0xFFFFFFFF)
override val tertiaryContainerLight = Color(0xFF777375)
override val onTertiaryContainerLight = Color(0xFFFFFBFF)
override val errorLight = Color(0xFFBA1A1A)
override val onErrorLight = Color(0xFFFFFFFF)
override val errorContainerLight = Color(0xFFFFDAD6)
override val onErrorContainerLight = Color(0xFF93000A)
override val backgroundLight = Color(0xFFFCF8F8)
override val onBackgroundLight = Color(0xFF1C1B1B)
override val surfaceLight = Color(0xFFFCF8F8)
override val onSurfaceLight = Color(0xFF1C1B1B)
override val surfaceVariantLight = Color(0xFFE0E3E3)
override val onSurfaceVariantLight = Color(0xFF444748)
override val outlineLight = Color(0xFF747878)
override val outlineVariantLight = Color(0xFFC4C7C7)
override val scrimLight = Color(0xFF000000)
override val inverseSurfaceLight = Color(0xFF313030)
override val inverseOnSurfaceLight = Color(0xFFF4F0EF)
override val inversePrimaryLight = Color(0xFFC7C6C6)
override val surfaceDimLight = Color(0xFFDDD9D8)
override val surfaceBrightLight = Color(0xFFFCF8F8)
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
override val surfaceContainerLowLight = Color(0xFFF7F3F2)
override val surfaceContainerLight = Color(0xFFF1EDEC)
override val surfaceContainerHighLight = Color(0xFFEBE7E7)
override val surfaceContainerHighestLight = Color(0xFFE5E2E1)
override val primaryDark = Color(0xFFC7C6C6)
override val onPrimaryDark = Color(0xFF303031)
override val primaryContainerDark = Color(0xFF919190)
override val onPrimaryContainerDark = Color(0xFF161718)
override val secondaryDark = Color(0xFFC8C6C5)
override val onSecondaryDark = Color(0xFF303030)
override val secondaryContainerDark = Color(0xFF474746)
override val onSecondaryContainerDark = Color(0xFFB7B5B4)
override val tertiaryDark = Color(0xFFCAC5C7)
override val onTertiaryDark = Color(0xFF323031)
override val tertiaryContainerDark = Color(0xFF948F91)
override val onTertiaryContainerDark = Color(0xFF181718)
override val errorDark = Color(0xFFFFB4AB)
override val onErrorDark = Color(0xFF690005)
override val errorContainerDark = Color(0xFF93000A)
override val onErrorContainerDark = Color(0xFFFFDAD6)
override val backgroundDark = Color(0xFF141313)
override val onBackgroundDark = Color(0xFFE5E2E1)
override val surfaceDark = Color(0xFF141313)
override val onSurfaceDark = Color(0xFFE5E2E1)
override val surfaceVariantDark = Color(0xFF444748)
override val onSurfaceVariantDark = Color(0xFFC4C7C7)
override val outlineDark = Color(0xFF8E9192)
override val outlineVariantDark = Color(0xFF444748)
override val scrimDark = Color(0xFF000000)
override val inverseSurfaceDark = Color(0xFFE5E2E1)
override val inverseOnSurfaceDark = Color(0xFF313030)
override val inversePrimaryDark = Color(0xFF5E5E5E)
override val surfaceDimDark = Color(0xFF141313)
override val surfaceBrightDark = Color(0xFF3A3939)
override val surfaceContainerLowestDark = Color(0xFF0E0E0E)
override val surfaceContainerLowDark = Color(0xFF1C1B1B)
override val surfaceContainerDark = Color(0xFF201F1F)
override val surfaceContainerHighDark = Color(0xFF2A2A2A)
override val surfaceContainerHighestDark = Color(0xFF353434)
}
// 黄色主题
object Yellow : ThemeColors() {
override val primaryLight = Color(0xFF6D5E0F)
override val onPrimaryLight = Color(0xFFFFFFFF)
override val primaryContainerLight = Color(0xFFF8E288)
override val onPrimaryContainerLight = Color(0xFF534600)
override val secondaryLight = Color(0xFF6D5E0F)
override val onSecondaryLight = Color(0xFFFFFFFF)
override val secondaryContainerLight = Color(0xFFF7E388)
override val onSecondaryContainerLight = Color(0xFF534600)
override val tertiaryLight = Color(0xFF685F13)
override val onTertiaryLight = Color(0xFFFFFFFF)
override val tertiaryContainerLight = Color(0xFFF1E58A)
override val onTertiaryContainerLight = Color(0xFF4F4800)
override val errorLight = Color(0xFFBA1A1A)
override val onErrorLight = Color(0xFFFFFFFF)
override val errorContainerLight = Color(0xFFFFDAD6)
override val onErrorContainerLight = Color(0xFF93000A)
override val backgroundLight = Color(0xFFFFF9ED)
override val onBackgroundLight = Color(0xFF1E1C13)
override val surfaceLight = Color(0xFFFFF9ED)
override val onSurfaceLight = Color(0xFF1E1C13)
override val surfaceVariantLight = Color(0xFFE9E2D0)
override val onSurfaceVariantLight = Color(0xFF4B4739)
override val outlineLight = Color(0xFF7C7768)
override val outlineVariantLight = Color(0xFFCDC6B4)
override val scrimLight = Color(0xFF000000)
override val inverseSurfaceLight = Color(0xFF333027)
override val inverseOnSurfaceLight = Color(0xFFF7F0E2)
override val inversePrimaryLight = Color(0xFFDAC66F)
override val surfaceDimLight = Color(0xFFE0D9CC)
override val surfaceBrightLight = Color(0xFFFFF9ED)
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
override val surfaceContainerLowLight = Color(0xFFFAF3E5)
override val surfaceContainerLight = Color(0xFFF4EDDF)
override val surfaceContainerHighLight = Color(0xFFEEE8DA)
override val surfaceContainerHighestLight = Color(0xFFE8E2D4)
override val primaryDark = Color(0xFFDAC66F)
override val onPrimaryDark = Color(0xFF393000)
override val primaryContainerDark = Color(0xFF534600)
override val onPrimaryContainerDark = Color(0xFFF8E288)
override val secondaryDark = Color(0xFFDAC76F)
override val onSecondaryDark = Color(0xFF393000)
override val secondaryContainerDark = Color(0xFF534600)
override val onSecondaryContainerDark = Color(0xFFF7E388)
override val tertiaryDark = Color(0xFFD4C871)
override val onTertiaryDark = Color(0xFF363100)
override val tertiaryContainerDark = Color(0xFF4F4800)
override val onTertiaryContainerDark = Color(0xFFF1E58A)
override val errorDark = Color(0xFFFFB4AB)
override val onErrorDark = Color(0xFF690005)
override val errorContainerDark = Color(0xFF93000A)
override val onErrorContainerDark = Color(0xFFFFDAD6)
override val backgroundDark = Color(0xFF15130B)
override val onBackgroundDark = Color(0xFFE8E2D4)
override val surfaceDark = Color(0xFF15130B)
override val onSurfaceDark = Color(0xFFE8E2D4)
override val surfaceVariantDark = Color(0xFF4B4739)
override val onSurfaceVariantDark = Color(0xFFCDC6B4)
override val outlineDark = Color(0xFF969080)
override val outlineVariantDark = Color(0xFF4B4739)
override val scrimDark = Color(0xFF000000)
override val inverseSurfaceDark = Color(0xFFE8E2D4)
override val inverseOnSurfaceDark = Color(0xFF333027)
override val inversePrimaryDark = Color(0xFF6D5E0F)
override val surfaceDimDark = Color(0xFF15130B)
override val surfaceBrightDark = Color(0xFF3C3930)
override val surfaceContainerLowestDark = Color(0xFF100E07)
override val surfaceContainerLowDark = Color(0xFF1E1C13)
override val surfaceContainerDark = Color(0xFF222017)
override val surfaceContainerHighDark = Color(0xFF2C2A21)
override val surfaceContainerHighestDark = Color(0xFF37352B)
}
companion object {
fun fromName(name: String): ThemeColors = when (name.lowercase()) {
"green" -> Green
"purple" -> Purple
"orange" -> Orange
"pink" -> Pink
"gray" -> Gray
"yellow" -> Yellow
else -> Default
}
}
}

Some files were not shown because too many files have changed in this diff Show More