Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5be402710 | ||
|
|
809b74a5f3 | ||
|
|
a98a718d61 | ||
|
|
45ea8455fc | ||
|
|
f81023246f | ||
|
|
8b06df3468 | ||
|
|
46cfd936a0 | ||
|
|
de04ea9db0 | ||
|
|
3853928305 | ||
|
|
5e64eee624 | ||
|
|
3a97e6580f | ||
|
|
228b6b1273 | ||
|
|
314fbe8cf7 | ||
|
|
ccb38061ee | ||
|
|
b631344e7c | ||
|
|
85d739a153 | ||
|
|
9817724a10 | ||
|
|
7c7e72f111 | ||
|
|
c5d473c815 | ||
|
|
f2de18bc26 | ||
|
|
61f5785729 | ||
|
|
a7713f0445 | ||
|
|
0109723187 | ||
|
|
15fe454b6d | ||
|
|
5c80febdbd | ||
|
|
39f4a5991a | ||
|
|
b2565fda08 | ||
|
|
923ba8c213 | ||
|
|
c94608a2eb | ||
|
|
ccb59cb7ca | ||
|
|
36d93501c8 | ||
|
|
27f6db889a | ||
|
|
6898d82daf | ||
|
|
8d8d0180ae | ||
|
|
f7b875fc16 | ||
|
|
0d73908d1b | ||
|
|
3dd210cfec | ||
|
|
18c65c8495 | ||
|
|
4f9b745cd0 | ||
|
|
a585989a03 | ||
|
|
ba6f29557e | ||
|
|
79b78e35ba | ||
|
|
932fabd35c | ||
|
|
4ea5c8f450 | ||
|
|
c6b184793e | ||
|
|
e3ef521de5 | ||
|
|
3d4e0e48b4 | ||
|
|
ff3071ca08 | ||
|
|
dd969eac22 | ||
|
|
9f2e5f513d | ||
|
|
385f4ab2c5 | ||
|
|
6826406494 | ||
|
|
6465e7a874 | ||
|
|
c753dd1345 | ||
|
|
06c8580788 | ||
|
|
5f228f1896 | ||
|
|
2368c5afd5 | ||
|
|
16ec695b63 | ||
|
|
404352b536 | ||
|
|
8e7f1f1cc7 | ||
|
|
d2a6fa4513 | ||
|
|
9574409955 | ||
|
|
9c2924de78 | ||
|
|
d7878ddd45 | ||
|
|
bc3399fd1b | ||
|
|
ba1aaaa160 | ||
|
|
a4e5a571bd | ||
|
|
3c501295b7 | ||
|
|
a8acea9180 | ||
|
|
4f79c94ab9 | ||
|
|
a14551b3ec | ||
|
|
2ea748dac1 | ||
|
|
429874b4d6 | ||
|
|
cd86589ad3 | ||
|
|
22cb7596a7 | ||
|
|
029ae8d389 | ||
|
|
58c8289890 | ||
|
|
94fa1e360a | ||
|
|
1d1ce396d3 | ||
|
|
99d58c8cfd | ||
|
|
ad2a23f55e | ||
|
|
f48d2e6cac | ||
|
|
d45676f059 | ||
|
|
0439a00f1d | ||
|
|
cbcaa07fd5 | ||
|
|
c4d8c49e5c | ||
|
|
c8020b2066 | ||
|
|
8442ebcb7a | ||
|
|
25fbc22f66 | ||
|
|
a4a9df3a25 |
71
.cursor/rules/general.mdc
Normal file
71
.cursor/rules/general.mdc
Normal 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%.
|
||||
25
.github/workflows/build-manager.yml
vendored
25
.github/workflows/build-manager.yml
vendored
@@ -2,7 +2,7 @@ name: Build Manager
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "dev", "ci" ]
|
||||
branches: [ "main", "dev", "ci", "miuix" ]
|
||||
paths:
|
||||
- '.github/workflows/build-manager.yml'
|
||||
- '.github/workflows/build-lkm.yml'
|
||||
@@ -11,13 +11,14 @@ on:
|
||||
- 'userspace/ksud/**'
|
||||
- 'userspace/user_scanner/**'
|
||||
pull_request:
|
||||
branches: [ "main", "dev" ]
|
||||
branches: [ "main", "dev", "miuix" ]
|
||||
paths:
|
||||
- '.github/workflows/build-manager.yml'
|
||||
- '.github/workflows/build-lkm.yml'
|
||||
- 'manager/**'
|
||||
- 'kernel/**'
|
||||
- 'userspace/ksud/**'
|
||||
- 'userspace/user_scanner/**'
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
@@ -81,7 +82,14 @@ jobs:
|
||||
- name: Determine manager variant for telegram bot
|
||||
id: determine
|
||||
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
|
||||
# maybe need a new var
|
||||
echo "topicid=${{ vars.MESSAGE_SPOOFED_THREAD_ID }}" >> $GITHUB_OUTPUT
|
||||
@@ -91,13 +99,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run randomizer
|
||||
if: ${{ matrix.spoofed == 'true' }}
|
||||
if: ${{ matrix.spoofed == 'true' && steps.determine.outputs.SKIP != 'true' }}
|
||||
run: |
|
||||
chmod +x randomizer
|
||||
./randomizer
|
||||
|
||||
- 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: |
|
||||
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
|
||||
|
||||
- name: Build with Gradle
|
||||
if: ${{ steps.determine.outputs.SKIP != 'true' }}
|
||||
run: ./gradlew clean assembleRelease
|
||||
|
||||
- name: Upload build artifact
|
||||
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:
|
||||
name: ${{ steps.determine.outputs.title }}
|
||||
path: manager/app/build/outputs/apk/release/*.apk
|
||||
|
||||
- name: Upload mappings
|
||||
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:
|
||||
name: "${{ steps.determine.outputs.title }}-mappings"
|
||||
path: "manager/app/build/outputs/mapping/release/"
|
||||
|
||||
- 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:
|
||||
CHAT_ID: ${{ vars.CHAT_ID }}
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
|
||||
@@ -17,6 +17,7 @@ kernelsu-objs += ksud.o
|
||||
kernelsu-objs += embed_ksud.o
|
||||
kernelsu-objs += seccomp_cache.o
|
||||
kernelsu-objs += file_wrapper.o
|
||||
kernelsu-objs += util.o
|
||||
kernelsu-objs += throne_comm.o
|
||||
kernelsu-objs += sulog.o
|
||||
|
||||
@@ -37,7 +38,7 @@ obj-$(CONFIG_KPM) += kpm/
|
||||
|
||||
REPO_OWNER := SukiSU-Ultra
|
||||
REPO_NAME := SukiSU-Ultra
|
||||
REPO_BRANCH := main
|
||||
REPO_BRANCH := miuix
|
||||
KSU_VERSION_API := 4.0.0
|
||||
|
||||
GIT_BIN := /usr/bin/env PATH="$$PATH":/usr/bin:/usr/local/bin git
|
||||
|
||||
@@ -47,7 +47,7 @@ static void remove_uid_from_arr(uid_t uid)
|
||||
if (allow_list_pointer == 0)
|
||||
return;
|
||||
|
||||
temp_arr = kmalloc(sizeof(allow_list_arr), GFP_KERNEL);
|
||||
temp_arr = kzalloc(sizeof(allow_list_arr), GFP_KERNEL);
|
||||
if (temp_arr == NULL) {
|
||||
pr_err("%s: unable to allocate memory\n", __func__);
|
||||
return;
|
||||
@@ -200,7 +200,7 @@ bool ksu_set_app_profile(struct app_profile *profile, bool persist)
|
||||
}
|
||||
|
||||
// not found, alloc a new node!
|
||||
p = (struct perm_data *)kmalloc(sizeof(struct perm_data), GFP_KERNEL);
|
||||
p = (struct perm_data *)kzalloc(sizeof(struct perm_data), GFP_KERNEL);
|
||||
if (!p) {
|
||||
pr_err("ksu_set_app_profile alloc failed\n");
|
||||
return false;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#define PER_USER_RANGE 100000
|
||||
#define FIRST_APPLICATION_UID 10000
|
||||
#define LAST_APPLICATION_UID 19999
|
||||
#define FIRST_ISOLATED_UID 99000
|
||||
#define LAST_ISOLATED_UID 99999
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
bool ksu_temp_grant_root_once(uid_t uid);
|
||||
void ksu_temp_revoke_root_once(uid_t uid);
|
||||
|
||||
@@ -37,7 +37,7 @@ static struct sdesc *init_sdesc(struct crypto_shash *alg)
|
||||
int size;
|
||||
|
||||
size = sizeof(struct shash_desc) + crypto_shash_descsize(alg);
|
||||
sdesc = kmalloc(size, GFP_KERNEL);
|
||||
sdesc = kzalloc(size, GFP_KERNEL);
|
||||
if (!sdesc)
|
||||
return ERR_PTR(-ENOMEM);
|
||||
sdesc->shash.tfm = alg;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "selinux/selinux.h"
|
||||
#include "syscall_hook_manager.h"
|
||||
#include "sucompat.h"
|
||||
|
||||
#include "sulog.h"
|
||||
|
||||
#if LINUX_VERSION_CODE >= KERNEL_VERSION (6, 7, 0)
|
||||
|
||||
@@ -7,6 +7,7 @@ enum ksu_feature_id {
|
||||
KSU_FEATURE_SU_COMPAT = 0,
|
||||
KSU_FEATURE_KERNEL_UMOUNT = 1,
|
||||
KSU_FEATURE_ENHANCED_SECURITY = 2,
|
||||
KSU_FEATURE_SULOG = 3,
|
||||
|
||||
KSU_FEATURE_MAX
|
||||
};
|
||||
|
||||
@@ -43,24 +43,6 @@ static const struct ksu_feature_handler kernel_umount_handler = {
|
||||
.set_handler = kernel_umount_feature_set,
|
||||
};
|
||||
|
||||
static bool should_umount(struct path *path)
|
||||
{
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current->nsproxy->mnt_ns == init_nsproxy.mnt_ns) {
|
||||
pr_info("ignore global mnt namespace process: %d\n", current_uid().val);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (path->mnt && path->mnt->mnt_sb && path->mnt->mnt_sb->s_type) {
|
||||
const char *fstype = path->mnt->mnt_sb->s_type->name;
|
||||
return strcmp(fstype, "overlay") == 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
extern int path_umount(struct path *path, int flags);
|
||||
|
||||
static void ksu_umount_mnt(struct path *path, int flags)
|
||||
@@ -71,7 +53,7 @@ static void ksu_umount_mnt(struct path *path, int flags)
|
||||
}
|
||||
}
|
||||
|
||||
void try_umount(const char *mnt, bool check_mnt, int flags)
|
||||
void try_umount(const char *mnt, int flags)
|
||||
{
|
||||
struct path path;
|
||||
int err = kern_path(mnt, 0, &path);
|
||||
@@ -85,12 +67,6 @@ void try_umount(const char *mnt, bool check_mnt, int flags)
|
||||
return;
|
||||
}
|
||||
|
||||
// we are only interest in some specific mounts
|
||||
if (check_mnt && !should_umount(&path)) {
|
||||
path_put(&path);
|
||||
return;
|
||||
}
|
||||
|
||||
ksu_umount_mnt(&path, flags);
|
||||
}
|
||||
|
||||
@@ -107,8 +83,14 @@ static void umount_tw_func(struct callback_head *cb)
|
||||
saved = override_creds(tw->old_cred);
|
||||
}
|
||||
|
||||
// fixme: use `collect_mounts` and `iterate_mount` to iterate all mountpoint and
|
||||
// filter the mountpoint whose target is `/data/adb`
|
||||
struct mount_entry *entry;
|
||||
down_read(&mount_list_lock);
|
||||
list_for_each_entry(entry, &mount_list, list) {
|
||||
pr_info("%s: unmounting: %s flags 0x%x\n", __func__, entry->umountable, entry->flags);
|
||||
try_umount(entry->umountable, entry->flags);
|
||||
}
|
||||
up_read(&mount_list_lock);
|
||||
|
||||
ksu_umount_manager_execute_all(tw->old_cred);
|
||||
|
||||
if (saved)
|
||||
@@ -124,7 +106,7 @@ int ksu_handle_umount(uid_t old_uid, uid_t new_uid)
|
||||
{
|
||||
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) {
|
||||
return 0;
|
||||
}
|
||||
@@ -133,18 +115,24 @@ int ksu_handle_umount(uid_t old_uid, uid_t new_uid)
|
||||
return 0;
|
||||
}
|
||||
|
||||
// FIXME: isolated process which directly forks from zygote is not handled
|
||||
if (!is_appuid(new_uid)) {
|
||||
// There are 5 scenarios:
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (!ksu_uid_should_umount(new_uid)) {
|
||||
if (!ksu_uid_should_umount(new_uid) && !is_isolated_process(new_uid)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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());
|
||||
if (!is_zygote_child) {
|
||||
pr_info("handle umount ignore non zygote child: %d\n", current->pid);
|
||||
@@ -156,7 +144,7 @@ int ksu_handle_umount(uid_t old_uid, uid_t new_uid)
|
||||
// umount the target mnt
|
||||
pr_info("handle umount for uid: %d, pid: %d\n", new_uid, current->pid);
|
||||
|
||||
tw = kmalloc(sizeof(*tw), GFP_ATOMIC);
|
||||
tw = kzalloc(sizeof(*tw), GFP_ATOMIC);
|
||||
if (!tw)
|
||||
return 0;
|
||||
|
||||
|
||||
@@ -2,13 +2,24 @@
|
||||
#define __KSU_H_KERNEL_UMOUNT
|
||||
|
||||
#include <linux/types.h>
|
||||
#include <linux/list.h>
|
||||
#include <linux/rwsem.h>
|
||||
|
||||
void ksu_kernel_umount_init(void);
|
||||
void ksu_kernel_umount_exit(void);
|
||||
|
||||
void try_umount(const char *mnt, bool check_mnt, int flags);
|
||||
void try_umount(const char *mnt, int flags);
|
||||
|
||||
// Handler function to be called from setresuid hook
|
||||
int ksu_handle_umount(uid_t old_uid, uid_t new_uid);
|
||||
|
||||
#endif
|
||||
// for the umount list
|
||||
struct mount_entry {
|
||||
char *umountable;
|
||||
unsigned int flags;
|
||||
struct list_head list;
|
||||
};
|
||||
extern struct list_head mount_list;
|
||||
extern struct rw_semaphore mount_list_lock;
|
||||
|
||||
#endif
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "arch.h"
|
||||
#include "klog.h" // IWYU pragma: keep
|
||||
#include "ksud.h"
|
||||
#include "util.h"
|
||||
#include "selinux/selinux.h"
|
||||
#include "throne_tracker.h"
|
||||
|
||||
@@ -88,18 +89,18 @@ void on_post_fs_data(void)
|
||||
is_boot_phase = false;
|
||||
|
||||
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);
|
||||
static void nuke_ext4_sysfs(void)
|
||||
int nuke_ext4_sysfs(const char *mnt)
|
||||
{
|
||||
#ifdef CONFIG_EXT4_FS
|
||||
struct path path;
|
||||
int err = kern_path("/data/adb/modules", 0, &path);
|
||||
int err = kern_path(mnt, 0, &path);
|
||||
if (err) {
|
||||
pr_err("nuke path err: %d\n", err);
|
||||
return;
|
||||
return err;
|
||||
}
|
||||
|
||||
struct super_block *sb = path.dentry->d_inode->i_sb;
|
||||
@@ -107,21 +108,24 @@ static void nuke_ext4_sysfs(void)
|
||||
if (strcmp(name, "ext4") != 0) {
|
||||
pr_info("nuke but module aren't mounted\n");
|
||||
path_put(&path);
|
||||
return;
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
ext4_unregister_sysfs(sb);
|
||||
path_put(&path);
|
||||
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
void on_module_mounted(void){
|
||||
void on_module_mounted(void)
|
||||
{
|
||||
pr_info("on_module_mounted!\n");
|
||||
ksu_module_mounted = true;
|
||||
nuke_ext4_sysfs();
|
||||
}
|
||||
|
||||
void on_boot_completed(void){
|
||||
void on_boot_completed(void)
|
||||
{
|
||||
ksu_boot_completed = true;
|
||||
pr_info("on_boot_completed!\n");
|
||||
track_throne(true);
|
||||
@@ -526,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 filename filename_in, *filename_p;
|
||||
char path[32];
|
||||
long ret;
|
||||
unsigned long addr;
|
||||
const char __user *fn;
|
||||
|
||||
if (!filename_user)
|
||||
return 0;
|
||||
|
||||
addr = untagged_addr((unsigned long)*filename_user);
|
||||
fn = (const char __user *)addr;
|
||||
|
||||
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_p = &filename_in;
|
||||
|
||||
@@ -12,6 +12,8 @@ void on_boot_completed(void);
|
||||
|
||||
bool ksu_is_safe_mode(void);
|
||||
|
||||
int nuke_ext4_sysfs(const char* mnt);
|
||||
|
||||
extern u32 ksu_file_sid;
|
||||
extern bool ksu_module_mounted;
|
||||
extern bool ksu_boot_completed;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <linux/mm.h>
|
||||
#include <linux/slab.h>
|
||||
#include <linux/binfmts.h>
|
||||
|
||||
#include "manual_su.h"
|
||||
#include "ksu.h"
|
||||
#include "allowlist.h"
|
||||
@@ -49,7 +50,7 @@ static char* get_token_from_envp(void)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
env_copy = kmalloc(env_len + 1, GFP_KERNEL);
|
||||
env_copy = kzalloc(env_len + 1, GFP_KERNEL);
|
||||
if (!env_copy) {
|
||||
up_read(&mm->mmap_lock);
|
||||
return NULL;
|
||||
@@ -72,7 +73,7 @@ static char* get_token_from_envp(void)
|
||||
char *token_end = strchr(token_start, '\0');
|
||||
|
||||
if (token_end && (token_end - token_start) == KSU_TOKEN_LENGTH) {
|
||||
token = kmalloc(KSU_TOKEN_LENGTH + 1, GFP_KERNEL);
|
||||
token = kzalloc(KSU_TOKEN_LENGTH + 1, GFP_KERNEL);
|
||||
if (token) {
|
||||
memcpy(token, token_start, KSU_TOKEN_LENGTH);
|
||||
token[KSU_TOKEN_LENGTH] = '\0';
|
||||
|
||||
@@ -354,7 +354,7 @@ static void add_xperm_rule_raw(struct policydb *db, struct type_datum *src,
|
||||
|
||||
if (datum->u.xperms == NULL) {
|
||||
datum->u.xperms =
|
||||
(struct avtab_extended_perms *)(kmalloc(
|
||||
(struct avtab_extended_perms *)(kzalloc(
|
||||
sizeof(xperms), GFP_KERNEL));
|
||||
if (!datum->u.xperms) {
|
||||
pr_err("alloc xperms failed\n");
|
||||
@@ -548,7 +548,7 @@ static bool add_filename_trans(struct policydb *db, const char *s,
|
||||
trans = (struct filename_trans_datum *)kcalloc(1 ,sizeof(*trans),
|
||||
GFP_ATOMIC);
|
||||
struct filename_trans_key *new_key =
|
||||
(struct filename_trans_key *)kmalloc(sizeof(*new_key),
|
||||
(struct filename_trans_key *)kzalloc(sizeof(*new_key),
|
||||
GFP_ATOMIC);
|
||||
*new_key = key;
|
||||
new_key->name = kstrdup(key.name, GFP_ATOMIC);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#include "linux/compiler.h"
|
||||
#include "linux/printk.h"
|
||||
#include <linux/compiler_types.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 <linux/cred.h>
|
||||
#include <linux/fs.h>
|
||||
#include <linux/types.h>
|
||||
#include <linux/uaccess.h>
|
||||
#include <linux/version.h>
|
||||
#include <linux/sched/task_stack.h>
|
||||
#include <linux/ptrace.h>
|
||||
@@ -15,8 +18,7 @@
|
||||
#include "ksud.h"
|
||||
#include "sucompat.h"
|
||||
#include "app_profile.h"
|
||||
#include "syscall_hook_manager.h"
|
||||
|
||||
#include "util.h"
|
||||
|
||||
#include "sulog.h"
|
||||
|
||||
@@ -70,7 +72,7 @@ static char __user *ksud_user_path(void)
|
||||
}
|
||||
|
||||
int ksu_handle_faccessat(int *dfd, const char __user **filename_user, int *mode,
|
||||
int *__unused_flags)
|
||||
int *__unused_flags)
|
||||
{
|
||||
const char su[] = SU_PATH;
|
||||
|
||||
@@ -108,20 +110,6 @@ int ksu_handle_stat(int *dfd, const char __user **filename_user, int *flags)
|
||||
|
||||
char path[sizeof(su) + 1];
|
||||
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));
|
||||
|
||||
if (unlikely(!memcmp(path, su, sizeof(su)))) {
|
||||
@@ -131,7 +119,6 @@ int ksu_handle_stat(int *dfd, const char __user **filename_user, int *flags)
|
||||
pr_info("newfstatat su->sh!\n");
|
||||
*filename_user = sh_user_path();
|
||||
}
|
||||
#endif
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -141,17 +128,14 @@ int ksu_handle_execve_sucompat(const char __user **filename_user,
|
||||
int *__never_use_flags)
|
||||
{
|
||||
const char su[] = SU_PATH;
|
||||
const char __user *fn;
|
||||
char path[sizeof(su) + 1];
|
||||
long ret;
|
||||
unsigned long addr;
|
||||
|
||||
if (unlikely(!filename_user))
|
||||
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
|
||||
bool is_allowed = ksu_is_allow_uid_for_current(current_uid().val);
|
||||
ksu_sulog_report_syscall(current_uid().val, NULL, "execve", path);
|
||||
@@ -166,6 +150,32 @@ int ksu_handle_execve_sucompat(const char __user **filename_user,
|
||||
}
|
||||
#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");
|
||||
*filename_user = ksud_user_path();
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
#include <linux/spinlock.h>
|
||||
|
||||
#include "klog.h"
|
||||
|
||||
#include "sulog.h"
|
||||
#include "ksu.h"
|
||||
#include "feature.h"
|
||||
|
||||
#if __SULOG_GATE
|
||||
|
||||
@@ -24,7 +26,28 @@ static DEFINE_SPINLOCK(dedup_lock);
|
||||
static LIST_HEAD(sulog_queue);
|
||||
static struct workqueue_struct *sulog_workqueue;
|
||||
static struct work_struct sulog_work;
|
||||
static bool sulog_enabled = true;
|
||||
static bool sulog_enabled __read_mostly = true;
|
||||
|
||||
static int sulog_feature_get(u64 *value)
|
||||
{
|
||||
*value = sulog_enabled ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int sulog_feature_set(u64 value)
|
||||
{
|
||||
bool enable = value != 0;
|
||||
sulog_enabled = enable;
|
||||
pr_info("sulog: set to %d\n", enable);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const struct ksu_feature_handler sulog_handler = {
|
||||
.feature_id = KSU_FEATURE_SULOG,
|
||||
.name = "sulog",
|
||||
.get_handler = sulog_feature_get,
|
||||
.set_handler = sulog_feature_set,
|
||||
};
|
||||
|
||||
static void get_timestamp(char *buf, size_t len)
|
||||
{
|
||||
@@ -180,7 +203,7 @@ static void sulog_add_entry(char *log_buf, size_t len, uid_t uid, u8 dedup_type)
|
||||
if (!dedup_should_print(uid, dedup_type, log_buf, len))
|
||||
return;
|
||||
|
||||
entry = kmalloc(sizeof(*entry), GFP_ATOMIC);
|
||||
entry = kzalloc(sizeof(*entry), GFP_ATOMIC);
|
||||
if (!entry)
|
||||
return;
|
||||
|
||||
@@ -303,6 +326,10 @@ void ksu_sulog_report_syscall(uid_t uid, const char *comm, const char *syscall,
|
||||
|
||||
int ksu_sulog_init(void)
|
||||
{
|
||||
if (ksu_register_feature_handler(&sulog_handler)) {
|
||||
pr_err("Failed to register sulog feature handler\n");
|
||||
}
|
||||
|
||||
sulog_workqueue = alloc_workqueue("ksu_sulog", WQ_UNBOUND | WQ_HIGHPRI, 1);
|
||||
if (!sulog_workqueue) {
|
||||
pr_err("sulog: failed to create workqueue\n");
|
||||
@@ -319,6 +346,8 @@ void ksu_sulog_exit(void)
|
||||
struct sulog_entry *entry, *tmp;
|
||||
unsigned long flags;
|
||||
|
||||
ksu_unregister_feature_handler(KSU_FEATURE_SULOG);
|
||||
|
||||
sulog_enabled = false;
|
||||
|
||||
if (sulog_workqueue) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
extern struct timezone sys_tz;
|
||||
|
||||
#define SULOG_PATH "/data/adb/ksu/log/sulog.log"
|
||||
#define SULOG_MAX_SIZE (128 * 1024 * 1024) // 128MB
|
||||
#define SULOG_MAX_SIZE (32 * 1024 * 1024) // 128MB
|
||||
#define SULOG_ENTRY_MAX_LEN 512
|
||||
#define SULOG_COMM_LEN 256
|
||||
#define DEDUP_SECS 10
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#include "supercalls.h"
|
||||
|
||||
#include <linux/anon_inodes.h>
|
||||
#include <linux/capability.h>
|
||||
#include <linux/cred.h>
|
||||
@@ -14,13 +12,14 @@
|
||||
#include <linux/uaccess.h>
|
||||
#include <linux/version.h>
|
||||
|
||||
#include "supercalls.h"
|
||||
#include "arch.h"
|
||||
#include "allowlist.h"
|
||||
#include "feature.h"
|
||||
#include "klog.h" // IWYU pragma: keep
|
||||
#include "ksud.h"
|
||||
#include "kernel_umount.h"
|
||||
#include "manager.h"
|
||||
#include "sulog.h"
|
||||
#include "selinux/selinux.h"
|
||||
#include "objsec.h"
|
||||
#include "file_wrapper.h"
|
||||
@@ -29,6 +28,7 @@
|
||||
#include "dynamic_manager.h"
|
||||
#include "umount_manager.h"
|
||||
|
||||
#include "sulog.h"
|
||||
#ifdef CONFIG_KSU_MANUAL_SU
|
||||
#include "manual_su.h"
|
||||
#endif
|
||||
@@ -481,6 +481,141 @@ static int do_manage_mark(void __user *arg)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int do_nuke_ext4_sysfs(void __user *arg)
|
||||
{
|
||||
struct ksu_nuke_ext4_sysfs_cmd cmd;
|
||||
char mnt[256];
|
||||
long ret;
|
||||
|
||||
if (copy_from_user(&cmd, arg, sizeof(cmd)))
|
||||
return -EFAULT;
|
||||
|
||||
if (!cmd.arg)
|
||||
return -EINVAL;
|
||||
|
||||
memset(mnt, 0, sizeof(mnt));
|
||||
|
||||
ret = strncpy_from_user(mnt, cmd.arg, sizeof(mnt));
|
||||
if (ret < 0) {
|
||||
pr_err("nuke ext4 copy mnt failed: %ld\\n", ret);
|
||||
return -EFAULT; // 或者 return ret;
|
||||
}
|
||||
|
||||
if (ret == sizeof(mnt)) {
|
||||
pr_err("nuke ext4 mnt path too long\\n");
|
||||
return -ENAMETOOLONG;
|
||||
}
|
||||
|
||||
pr_info("do_nuke_ext4_sysfs: %s\n", mnt);
|
||||
|
||||
return nuke_ext4_sysfs(mnt);
|
||||
}
|
||||
|
||||
struct list_head mount_list = LIST_HEAD_INIT(mount_list);
|
||||
DECLARE_RWSEM(mount_list_lock);
|
||||
|
||||
static int add_try_umount(void __user *arg)
|
||||
{
|
||||
struct mount_entry *new_entry, *entry, *tmp;
|
||||
struct ksu_add_try_umount_cmd cmd;
|
||||
char buf[256] = {0};
|
||||
|
||||
if (copy_from_user(&cmd, arg, sizeof cmd))
|
||||
return -EFAULT;
|
||||
|
||||
switch (cmd.mode) {
|
||||
case KSU_UMOUNT_WIPE: {
|
||||
struct mount_entry *entry, *tmp;
|
||||
down_write(&mount_list_lock);
|
||||
list_for_each_entry_safe(entry, tmp, &mount_list, list) {
|
||||
pr_info("wipe_umount_list: removing entry: %s\n", entry->umountable);
|
||||
list_del(&entry->list);
|
||||
kfree(entry->umountable);
|
||||
kfree(entry);
|
||||
}
|
||||
up_write(&mount_list_lock);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
case KSU_UMOUNT_ADD: {
|
||||
long len = strncpy_from_user(buf, (const char __user *)cmd.arg, 256);
|
||||
if (len <= 0)
|
||||
return -EFAULT;
|
||||
|
||||
buf[sizeof(buf) - 1] = '\0';
|
||||
|
||||
new_entry = kzalloc(sizeof(*new_entry), GFP_KERNEL);
|
||||
if (!new_entry)
|
||||
return -ENOMEM;
|
||||
|
||||
new_entry->umountable = kstrdup(buf, GFP_KERNEL);
|
||||
if (!new_entry->umountable) {
|
||||
kfree(new_entry);
|
||||
return -1;
|
||||
}
|
||||
|
||||
down_write(&mount_list_lock);
|
||||
|
||||
// disallow dupes
|
||||
// if this gets too many, we can consider moving this whole task to a kthread
|
||||
list_for_each_entry(entry, &mount_list, list) {
|
||||
if (!strcmp(entry->umountable, buf)) {
|
||||
pr_info("cmd_add_try_umount: %s is already here!\n", buf);
|
||||
up_write(&mount_list_lock);
|
||||
kfree(new_entry->umountable);
|
||||
kfree(new_entry);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// now check flags and add
|
||||
// this also serves as a null check
|
||||
if (cmd.flags)
|
||||
new_entry->flags = cmd.flags;
|
||||
else
|
||||
new_entry->flags = 0;
|
||||
|
||||
// debug
|
||||
list_add(&new_entry->list, &mount_list);
|
||||
up_write(&mount_list_lock);
|
||||
pr_info("cmd_add_try_umount: %s added!\n", buf);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// this is just strcmp'd wipe anyway
|
||||
case KSU_UMOUNT_DEL: {
|
||||
long len = strncpy_from_user(buf, (const char __user *)cmd.arg, sizeof(buf) - 1);
|
||||
if (len <= 0)
|
||||
return -EFAULT;
|
||||
|
||||
buf[sizeof(buf) - 1] = '\0';
|
||||
|
||||
down_write(&mount_list_lock);
|
||||
list_for_each_entry_safe(entry, tmp, &mount_list, list) {
|
||||
if (!strcmp(entry->umountable, buf)) {
|
||||
pr_info("cmd_add_try_umount: entry removed: %s\n", entry->umountable);
|
||||
list_del(&entry->list);
|
||||
kfree(entry->umountable);
|
||||
kfree(entry);
|
||||
}
|
||||
}
|
||||
up_write(&mount_list_lock);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
default: {
|
||||
pr_err("cmd_add_try_umount: invalid operation %u\n", cmd.mode);
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
} // switch(cmd.mode)
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 100. GET_FULL_VERSION - Get full version string
|
||||
static int do_get_full_version(void __user *arg)
|
||||
{
|
||||
@@ -692,7 +827,7 @@ static int do_umount_manager(void __user *arg)
|
||||
|
||||
switch (cmd.operation) {
|
||||
case UMOUNT_OP_ADD: {
|
||||
return ksu_umount_manager_add(cmd.path, cmd.check_mnt, cmd.flags, false);
|
||||
return ksu_umount_manager_add(cmd.path, cmd.flags, false);
|
||||
}
|
||||
case UMOUNT_OP_REMOVE: {
|
||||
return ksu_umount_manager_remove(cmd.path);
|
||||
@@ -728,6 +863,8 @@ static const struct ksu_ioctl_cmd_map ksu_ioctl_handlers[] = {
|
||||
{ .cmd = KSU_IOCTL_SET_FEATURE, .name = "SET_FEATURE", .handler = do_set_feature, .perm_check = manager_or_root },
|
||||
{ .cmd = KSU_IOCTL_GET_WRAPPER_FD, .name = "GET_WRAPPER_FD", .handler = do_get_wrapper_fd, .perm_check = manager_or_root },
|
||||
{ .cmd = KSU_IOCTL_MANAGE_MARK, .name = "MANAGE_MARK", .handler = do_manage_mark, .perm_check = manager_or_root },
|
||||
{ .cmd = KSU_IOCTL_NUKE_EXT4_SYSFS, .name = "NUKE_EXT4_SYSFS", .handler = do_nuke_ext4_sysfs, .perm_check = manager_or_root },
|
||||
{ .cmd = KSU_IOCTL_ADD_TRY_UMOUNT, .name = "ADD_TRY_UMOUNT", .handler = add_try_umount, .perm_check = manager_or_root },
|
||||
{ .cmd = KSU_IOCTL_GET_FULL_VERSION,.name = "GET_FULL_VERSION", .handler = do_get_full_version, .perm_check = always_allow},
|
||||
{ .cmd = KSU_IOCTL_HOOK_TYPE,.name = "GET_HOOK_TYPE", .handler = do_get_hook_type, .perm_check = manager_or_root},
|
||||
{ .cmd = KSU_IOCTL_ENABLE_KPM, .name = "GET_ENABLE_KPM", .handler = do_enable_kpm, .perm_check = manager_or_root},
|
||||
|
||||
@@ -94,6 +94,21 @@ struct ksu_manage_mark_cmd {
|
||||
#define KSU_MARK_UNMARK 3
|
||||
#define KSU_MARK_REFRESH 4
|
||||
|
||||
struct ksu_nuke_ext4_sysfs_cmd {
|
||||
__aligned_u64 arg; // Input: mnt pointer
|
||||
};
|
||||
|
||||
struct ksu_add_try_umount_cmd {
|
||||
__aligned_u64 arg; // char ptr, this is the mountpoint
|
||||
__u32 flags; // this is the flag we use for it
|
||||
__u8 mode; // denotes what to do with it 0:wipe_list 1:add_to_list 2:delete_entry
|
||||
};
|
||||
|
||||
#define KSU_UMOUNT_WIPE 0 // ignore everything and wipe list
|
||||
#define KSU_UMOUNT_ADD 1 // add entry (path + flags)
|
||||
#define KSU_UMOUNT_DEL 2 // delete entry, strcmp
|
||||
|
||||
|
||||
// Other command structures
|
||||
struct ksu_get_full_version_cmd {
|
||||
char version_full[KSU_FULL_VERSION_STRING]; // Output: full version string
|
||||
@@ -147,6 +162,8 @@ struct ksu_manual_su_cmd {
|
||||
#define KSU_IOCTL_SET_FEATURE _IOC(_IOC_WRITE, 'K', 14, 0)
|
||||
#define KSU_IOCTL_GET_WRAPPER_FD _IOC(_IOC_WRITE, 'K', 15, 0)
|
||||
#define KSU_IOCTL_MANAGE_MARK _IOC(_IOC_READ|_IOC_WRITE, 'K', 16, 0)
|
||||
#define KSU_IOCTL_NUKE_EXT4_SYSFS _IOC(_IOC_WRITE, 'K', 17, 0)
|
||||
#define KSU_IOCTL_ADD_TRY_UMOUNT _IOC(_IOC_WRITE, 'K', 18, 0)
|
||||
// Other IOCTL command definitions
|
||||
#define KSU_IOCTL_GET_FULL_VERSION _IOC(_IOC_READ, 'K', 100, 0)
|
||||
#define KSU_IOCTL_HOOK_TYPE _IOC(_IOC_READ, 'K', 101, 0)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "sucompat.h"
|
||||
#include "setuid_hook.h"
|
||||
#include "selinux/selinux.h"
|
||||
#include "util.h"
|
||||
|
||||
// Tracepoint registration count management
|
||||
// == 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)
|
||||
{
|
||||
char path[64];
|
||||
unsigned long addr;
|
||||
const char __user *fn;
|
||||
long ret;
|
||||
|
||||
if (unlikely(!filename_user))
|
||||
return 0;
|
||||
|
||||
addr = untagged_addr((unsigned long)*filename_user);
|
||||
fn = (const char __user *)addr;
|
||||
|
||||
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)) {
|
||||
pr_info("hook_manager: unmark %d exec %s", current->pid, path);
|
||||
|
||||
@@ -268,7 +268,7 @@ FILLDIR_RETURN_TYPE my_actor(struct dir_context *ctx, const char *name,
|
||||
|
||||
if (d_type == DT_DIR && my_ctx->depth > 0 &&
|
||||
(my_ctx->stop && !*my_ctx->stop)) {
|
||||
struct data_path *data = kmalloc(sizeof(struct data_path), GFP_ATOMIC);
|
||||
struct data_path *data = kzalloc(sizeof(struct data_path), GFP_ATOMIC);
|
||||
|
||||
if (!data) {
|
||||
pr_err("Failed to allocate memory for %s\n", dirpath);
|
||||
@@ -303,29 +303,24 @@ FILLDIR_RETURN_TYPE my_actor(struct dir_context *ctx, const char *name,
|
||||
// Check for dynamic sign or multi-manager signatures
|
||||
if (is_multi_manager && (signature_index == DYNAMIC_SIGN_INDEX || signature_index >= 2)) {
|
||||
crown_manager(dirpath, my_ctx->private_data, signature_index);
|
||||
|
||||
struct apk_path_hash *apk_data = kmalloc(sizeof(struct apk_path_hash), GFP_ATOMIC);
|
||||
if (apk_data) {
|
||||
apk_data->hash = hash;
|
||||
apk_data->exists = true;
|
||||
list_add_tail(&apk_data->list, &apk_path_hash_list);
|
||||
}
|
||||
} else if (is_manager_apk(dirpath)) {
|
||||
crown_manager(dirpath, my_ctx->private_data, 0);
|
||||
*my_ctx->stop = 1;
|
||||
}
|
||||
|
||||
struct apk_path_hash *apk_data = kzalloc(sizeof(*apk_data), GFP_ATOMIC);
|
||||
if (apk_data) {
|
||||
apk_data->hash = hash;
|
||||
apk_data->exists = true;
|
||||
list_add_tail(&apk_data->list, &apk_path_hash_list);
|
||||
}
|
||||
|
||||
if (is_manager_apk(dirpath)) {
|
||||
// Manager found, clear APK cache list
|
||||
list_for_each_entry_safe (pos, n, &apk_path_hash_list, list) {
|
||||
list_for_each_entry_safe(pos, n, &apk_path_hash_list, list) {
|
||||
list_del(&pos->list);
|
||||
kfree(pos);
|
||||
}
|
||||
} else {
|
||||
struct apk_path_hash *apk_data = kmalloc(sizeof(struct apk_path_hash), GFP_ATOMIC);
|
||||
if (apk_data) {
|
||||
apk_data->hash = hash;
|
||||
apk_data->exists = true;
|
||||
list_add_tail(&apk_data->list, &apk_path_hash_list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
static struct umount_manager g_umount_mgr = {
|
||||
.entry_count = 0,
|
||||
.max_entries = 64,
|
||||
.max_entries = 512,
|
||||
};
|
||||
|
||||
static void try_umount_path(struct umount_entry *entry)
|
||||
{
|
||||
try_umount(entry->path, entry->check_mnt, entry->flags);
|
||||
try_umount(entry->path, entry->flags);
|
||||
}
|
||||
|
||||
static struct umount_entry *find_entry_locked(const char *path)
|
||||
@@ -33,37 +33,123 @@ static struct umount_entry *find_entry_locked(const char *path)
|
||||
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 {
|
||||
const char *path;
|
||||
bool check_mnt;
|
||||
int flags;
|
||||
} defaults[] = {
|
||||
{ "/odm", true, 0 },
|
||||
{ "/system", true, 0 },
|
||||
{ "/vendor", true, 0 },
|
||||
{ "/product", true, 0 },
|
||||
{ "/system_ext", true, 0 },
|
||||
{ "/data/adb/modules", false, MNT_DETACH },
|
||||
{ "/debug_ramdisk", false, MNT_DETACH },
|
||||
};
|
||||
|
||||
for (int i = 0; i < ARRAY_SIZE(defaults); i++) {
|
||||
ret = ksu_umount_manager_add(defaults[i].path,
|
||||
defaults[i].check_mnt,
|
||||
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;
|
||||
down_read(&mount_list_lock);
|
||||
list_for_each_entry(entry, &mount_list, list) {
|
||||
if (entry->umountable && strcmp(entry->umountable, path) == 0) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -72,7 +158,7 @@ int ksu_umount_manager_init(void)
|
||||
INIT_LIST_HEAD(&g_umount_mgr.entry_list);
|
||||
spin_lock_init(&g_umount_mgr.lock);
|
||||
|
||||
return init_default_entries();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ksu_umount_manager_exit(void)
|
||||
@@ -93,7 +179,7 @@ void ksu_umount_manager_exit(void)
|
||||
pr_info("Umount manager cleaned up\n");
|
||||
}
|
||||
|
||||
int ksu_umount_manager_add(const char *path, bool check_mnt, int flags, bool is_default)
|
||||
int ksu_umount_manager_add(const char *path, int flags, bool is_default)
|
||||
{
|
||||
struct umount_entry *entry;
|
||||
unsigned long irqflags;
|
||||
@@ -106,6 +192,11 @@ int ksu_umount_manager_add(const char *path, bool check_mnt, int flags, bool is_
|
||||
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);
|
||||
|
||||
if (g_umount_mgr.entry_count >= g_umount_mgr.max_entries) {
|
||||
@@ -127,7 +218,6 @@ int ksu_umount_manager_add(const char *path, bool check_mnt, int flags, bool is_
|
||||
}
|
||||
|
||||
strncpy(entry->path, path, sizeof(entry->path) - 1);
|
||||
entry->check_mnt = check_mnt;
|
||||
entry->flags = flags;
|
||||
entry->state = UMOUNT_STATE_IDLE;
|
||||
entry->is_default = is_default;
|
||||
@@ -219,38 +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)
|
||||
{
|
||||
struct umount_entry *entry;
|
||||
struct ksu_umount_entry_info info;
|
||||
unsigned long flags;
|
||||
u32 idx = 0;
|
||||
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) {
|
||||
break;
|
||||
if (idx < max_count) {
|
||||
ret = collect_umount_manager_entries(entries, idx, max_count, &idx);
|
||||
if (ret) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
memset(&info, 0, sizeof(info));
|
||||
strncpy(info.path, entry->path, sizeof(info.path) - 1);
|
||||
info.check_mnt = entry->check_mnt;
|
||||
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;
|
||||
|
||||
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ enum umount_entry_state {
|
||||
struct umount_entry {
|
||||
struct list_head list;
|
||||
char path[256];
|
||||
bool check_mnt;
|
||||
int flags;
|
||||
enum umount_entry_state state;
|
||||
bool is_default;
|
||||
@@ -40,7 +39,6 @@ enum umount_manager_op {
|
||||
struct ksu_umount_manager_cmd {
|
||||
__u32 operation;
|
||||
char path[256];
|
||||
__u8 check_mnt;
|
||||
__s32 flags;
|
||||
__u32 count;
|
||||
__aligned_u64 entries_ptr;
|
||||
@@ -48,7 +46,6 @@ struct ksu_umount_manager_cmd {
|
||||
|
||||
struct ksu_umount_entry_info {
|
||||
char path[256];
|
||||
__u8 check_mnt;
|
||||
__s32 flags;
|
||||
__u8 is_default;
|
||||
__u32 state;
|
||||
@@ -57,7 +54,7 @@ struct ksu_umount_entry_info {
|
||||
|
||||
int ksu_umount_manager_init(void);
|
||||
void ksu_umount_manager_exit(void);
|
||||
int ksu_umount_manager_add(const char *path, bool check_mnt, int flags, bool is_default);
|
||||
int ksu_umount_manager_add(const char *path, int flags, bool is_default);
|
||||
int ksu_umount_manager_remove(const char *path);
|
||||
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);
|
||||
|
||||
76
kernel/util.c
Normal file
76
kernel/util.c
Normal 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
24
kernel/util.h
Normal 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
|
||||
@@ -10,8 +10,6 @@ plugins {
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.lsplugin.apksign)
|
||||
id("kotlin-parcelize")
|
||||
|
||||
|
||||
}
|
||||
|
||||
val managerVersionCode: Int by rootProject.extra
|
||||
@@ -25,7 +23,6 @@ apksign {
|
||||
keyPasswordProperty = "KEY_PASSWORD"
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
|
||||
/**signingConfigs {
|
||||
@@ -117,13 +114,9 @@ dependencies {
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
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.tooling.preview)
|
||||
implementation(libs.androidx.foundation)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
@@ -145,24 +138,14 @@ dependencies {
|
||||
|
||||
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.ext.tables)
|
||||
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
implementation(libs.lsposed.cxx)
|
||||
|
||||
implementation(libs.com.github.topjohnwu.libsu.core)
|
||||
|
||||
implementation(libs.mmrl.platform)
|
||||
compileOnly(libs.mmrl.hidden.api)
|
||||
implementation(libs.mmrl.webui)
|
||||
implementation(libs.mmrl.ui)
|
||||
|
||||
implementation(libs.accompanist.drawablepainter)
|
||||
|
||||
}
|
||||
implementation(libs.miuix)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.capsule)
|
||||
}
|
||||
|
||||
48
manager/app/proguard-rules.pro
vendored
48
manager/app/proguard-rules.pro
vendored
@@ -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.** { *; }
|
||||
@@ -3,40 +3,24 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<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
|
||||
android:name=".KernelSUApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KernelSU"
|
||||
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
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:launchMode="standard"
|
||||
android:documentLaunchMode="intoExisting"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:theme="@style/Theme.KernelSU">
|
||||
android:theme="@style/Theme.KernelSU"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -62,39 +46,6 @@
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent-filter>
|
||||
</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
|
||||
android:name=".ui.webui.WebUIActivity"
|
||||
@@ -103,13 +54,6 @@
|
||||
android:exported="false"
|
||||
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
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
package com.sukisu.zako;
|
||||
|
||||
import android.content.pm.PackageInfo;
|
||||
import java.util.List;
|
||||
import rikka.parcelablelist.ParcelableListSlice;
|
||||
|
||||
interface IKsuInterface {
|
||||
int getPackageCount();
|
||||
List<PackageInfo> getPackages(int start, int maxCount);
|
||||
ParcelableListSlice<PackageInfo> getPackages(int flags);
|
||||
}
|
||||
Binary file not shown.
@@ -15,14 +15,4 @@ add_library(kernelsu
|
||||
|
||||
find_library(log-lib log)
|
||||
|
||||
if(ANDROID_ABI STREQUAL "arm64-v8a")
|
||||
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()
|
||||
target_link_libraries(kernelsu ${log-lib})
|
||||
|
||||
@@ -319,6 +319,14 @@ NativeBridge(setEnhancedSecurityEnabled, jboolean, jboolean enabled) {
|
||||
return set_enhanced_security_enabled(enabled);
|
||||
}
|
||||
|
||||
NativeBridgeNP(isSuLogEnabled, jboolean) {
|
||||
return is_sulog_enabled();
|
||||
}
|
||||
|
||||
NativeBridge(setSuLogEnabled, jboolean, jboolean enabled) {
|
||||
return set_sulog_enabled(enabled);
|
||||
}
|
||||
|
||||
NativeBridge(getUserName, jstring, jint uid) {
|
||||
struct passwd *pw = getpwuid((uid_t) uid);
|
||||
if (pw && pw->pw_name && pw->pw_name[0] != '\0') {
|
||||
@@ -327,11 +335,6 @@ NativeBridge(getUserName, jstring, jint uid) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Check if KPM is enabled
|
||||
NativeBridgeNP(isKPMEnabled, jboolean) {
|
||||
return is_KPM_enable();
|
||||
}
|
||||
|
||||
// Get HOOK type
|
||||
NativeBridgeNP(getHookType, jstring) {
|
||||
char hook_type[32] = { 0 };
|
||||
@@ -411,7 +414,7 @@ NativeBridgeNP(getManagersList, jobject) {
|
||||
LogDebug("getManagersList: count=%d", managerListInfo.count);
|
||||
return obj;
|
||||
}
|
||||
|
||||
#if 0
|
||||
NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) {
|
||||
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
||||
if (!modulePath) {
|
||||
@@ -430,6 +433,7 @@ NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) {
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
NativeBridgeNP(isUidScannerEnabled, jboolean) {
|
||||
return is_uid_scanner_enabled();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "prelude.h"
|
||||
#include "ksu.h"
|
||||
|
||||
#if 0
|
||||
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
||||
|
||||
// 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);
|
||||
|
||||
#endif // __aarch64__ || _M_ARM64 || __arm__ || _M_ARM
|
||||
#endif
|
||||
|
||||
static int fd = -1;
|
||||
|
||||
@@ -231,6 +233,22 @@ bool is_enhanced_security_enabled() {
|
||||
return value != 0;
|
||||
}
|
||||
|
||||
bool set_sulog_enabled(bool enabled) {
|
||||
return set_feature(KSU_FEATURE_SULOG, enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
bool is_sulog_enabled() {
|
||||
uint64_t value = 0;
|
||||
bool supported = false;
|
||||
if (!get_feature(KSU_FEATURE_SULOG, &value, &supported)) {
|
||||
return false;
|
||||
}
|
||||
if (!supported) {
|
||||
return false;
|
||||
}
|
||||
return value != 0;
|
||||
}
|
||||
|
||||
void get_full_version(char* buff) {
|
||||
struct ksu_get_full_version_cmd cmd = {0};
|
||||
if (ksuctl(KSU_IOCTL_GET_FULL_VERSION, &cmd) == 0) {
|
||||
@@ -241,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) {
|
||||
struct ksu_hook_type_cmd cmd = {0};
|
||||
if (ksuctl(KSU_IOCTL_HOOK_TYPE, &cmd) == 0) {
|
||||
@@ -332,6 +342,7 @@ bool clear_uid_scanner_environment(void)
|
||||
return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd);
|
||||
}
|
||||
|
||||
#if 0
|
||||
bool verify_module_signature(const char* input) {
|
||||
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
||||
if (input == NULL) {
|
||||
@@ -388,3 +399,4 @@ bool verify_module_signature(const char* input) {
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -119,7 +119,9 @@ bool clear_dynamic_manager();
|
||||
|
||||
bool get_managers_list(struct manager_list_info* info);
|
||||
|
||||
#if 0
|
||||
bool verify_module_signature(const char* input);
|
||||
#endif
|
||||
|
||||
bool is_uid_scanner_enabled();
|
||||
|
||||
@@ -132,6 +134,7 @@ enum ksu_feature_id {
|
||||
KSU_FEATURE_SU_COMPAT = 0,
|
||||
KSU_FEATURE_KERNEL_UMOUNT = 1,
|
||||
KSU_FEATURE_ENHANCED_SECURITY = 2,
|
||||
KSU_FEATURE_SULOG = 3,
|
||||
};
|
||||
|
||||
// Generic feature API
|
||||
@@ -211,9 +214,12 @@ bool is_kernel_umount_enabled();
|
||||
|
||||
// Enhanced security
|
||||
bool set_enhanced_security_enabled(bool enabled);
|
||||
|
||||
bool is_enhanced_security_enabled();
|
||||
|
||||
// Su log
|
||||
bool set_sulog_enabled(bool enabled);
|
||||
bool is_sulog_enabled();
|
||||
|
||||
// Other command structures
|
||||
struct ksu_get_full_version_cmd {
|
||||
char version_full[KSU_FULL_VERSION_STRING]; // Output: full version string
|
||||
@@ -223,10 +229,6 @@ struct ksu_hook_type_cmd {
|
||||
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 dynamic_manager_user_config config; // Input/Output: dynamic manager config
|
||||
};
|
||||
|
||||
@@ -88,12 +88,6 @@ bool legacy_is_su_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) {
|
||||
if (hook_type == NULL || size == 0) {
|
||||
return false;
|
||||
|
||||
@@ -5,15 +5,7 @@ import android.system.Os
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
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.OkHttpClient
|
||||
import java.io.File
|
||||
@@ -30,24 +22,8 @@ class KernelSUApplication : Application(), ViewModelStoreOwner {
|
||||
super.onCreate()
|
||||
ksuApp = this
|
||||
|
||||
// For faster response when first entering superuser or webui activity
|
||||
val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java]
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
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()
|
||||
)
|
||||
superUserViewModel.loadAppList()
|
||||
|
||||
val webroot = File(dataDir, "webroot")
|
||||
if (!webroot.exists()) {
|
||||
@@ -62,11 +38,12 @@ class KernelSUApplication : Application(), ViewModelStoreOwner {
|
||||
.addInterceptor { block ->
|
||||
block.proceed(
|
||||
block.request().newBuilder()
|
||||
.header("User-Agent", "SukiSU/${BuildConfig.VERSION_CODE}")
|
||||
.header("User-Agent", "KernelSU/${BuildConfig.VERSION_CODE}")
|
||||
.header("Accept-Language", Locale.getDefault().toLanguageTag()).build()
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
|
||||
override val viewModelStore: ViewModelStore
|
||||
get() = appViewModelStore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,23 @@ import android.system.Os
|
||||
*/
|
||||
|
||||
data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) {
|
||||
override fun toString(): String = "$major.$patchLevel.$subLevel"
|
||||
fun isGKI(): Boolean = when {
|
||||
major > 5 -> true
|
||||
major == 5 && patchLevel >= 10 -> true
|
||||
else -> false
|
||||
override fun toString(): String {
|
||||
return "$major.$patchLevel.$subLevel"
|
||||
}
|
||||
|
||||
fun isGKI(): Boolean {
|
||||
|
||||
// kernel 6.x
|
||||
if (major > 5) {
|
||||
return true
|
||||
}
|
||||
|
||||
// kernel 5.10.x
|
||||
if (major == 5) {
|
||||
return patchLevel >= 10
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,27 +17,18 @@ object Natives {
|
||||
// 10977: change groups_count and groups to avoid overflow write
|
||||
// 11071: Fix the issue of failing to set a custom SELinux type.
|
||||
// 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
|
||||
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_GID = 0
|
||||
|
||||
// 获取完整版本号
|
||||
external fun getFullVersion(): String
|
||||
|
||||
fun isVersionLessThan(v1Full: String, v2Full: String): Boolean {
|
||||
fun extractVersionParts(version: String): List<Int> {
|
||||
val match = Regex("""v\d+(\.\d+)*""").find(version)
|
||||
@@ -61,7 +52,6 @@ object Natives {
|
||||
}
|
||||
|
||||
init {
|
||||
System.loadLibrary("zakosign")
|
||||
System.loadLibrary("kernelsu")
|
||||
}
|
||||
|
||||
@@ -118,13 +108,20 @@ object Natives {
|
||||
external fun isEnhancedSecurityEnabled(): Boolean
|
||||
external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean
|
||||
|
||||
external fun isKPMEnabled(): Boolean
|
||||
external fun getHookType(): String
|
||||
/**
|
||||
* Get the user name for the uid.
|
||||
*/
|
||||
external fun getUserName(uid: Int): String?
|
||||
|
||||
/**
|
||||
* Get SUSFS feature status from kernel
|
||||
* @return SusfsFeatureStatus object containing all feature states, or null if failed
|
||||
* Su Log can be enabled/disabled.
|
||||
* 0: disabled
|
||||
* 1: enabled
|
||||
* negative : error
|
||||
*/
|
||||
external fun isSuLogEnabled(): Boolean
|
||||
external fun setSuLogEnabled(enabled: Boolean): Boolean
|
||||
external fun getHookType(): String
|
||||
|
||||
/**
|
||||
* Set dynamic managerature configuration
|
||||
@@ -153,9 +150,6 @@ object Natives {
|
||||
*/
|
||||
external fun getManagersList(): ManagersList?
|
||||
|
||||
// 模块签名验证
|
||||
external fun verifyModuleSignature(modulePath: String): Boolean
|
||||
|
||||
/**
|
||||
* Check if UID scanner is currently enabled
|
||||
* @return true if UID scanner is enabled, false otherwise
|
||||
@@ -176,7 +170,7 @@ object Natives {
|
||||
*/
|
||||
external fun clearUidScannerEnvironment(): Boolean
|
||||
|
||||
external fun getUserName(uid: Int): String?
|
||||
|
||||
|
||||
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
|
||||
private const val NOBODY_UID = 9999
|
||||
@@ -269,4 +263,4 @@ object Natives {
|
||||
|
||||
constructor() : this("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"),
|
||||
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"),
|
||||
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"),
|
||||
UHID(3011, "uhid", "Allow read/write to /dev/uhid node"),
|
||||
READTRACEFS(3012, "readtracefs", "Allow tracefs read"),
|
||||
VIRTUALMACHINE(3013, "virtualmachine", "Allows VMs to tune for performance"),
|
||||
|
||||
EVERYBODY(9997, "everybody", "Shared external storage read/write"),
|
||||
MISC(9998, "misc", "Access to misc storage"),
|
||||
|
||||
@@ -1,75 +1,71 @@
|
||||
package com.sukisu.ultra.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
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 com.topjohnwu.superuser.ipc.RootService
|
||||
import com.sukisu.zako.IKsuInterface
|
||||
import rikka.parcelablelist.ParcelableListSlice
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/10/17.
|
||||
* @author weishu
|
||||
* @date 2023/4/18.
|
||||
*/
|
||||
|
||||
class KsuService : RootService() {
|
||||
|
||||
private 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
|
||||
companion object {
|
||||
private const val TAG = "KsuService"
|
||||
}
|
||||
|
||||
internal inner class Stub : IKsuInterface.Stub() {
|
||||
override fun getPackageCount(): Int = allPackages.size
|
||||
|
||||
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 {
|
||||
return Stub()
|
||||
}
|
||||
|
||||
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 getInstalledPackagesAsUser(userId: Int): List<PackageInfo> {
|
||||
private fun getInstalledPackagesAll(flags: Int): ArrayList<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 {
|
||||
val pm = packageManager
|
||||
val m = pm.javaClass.getDeclaredMethod(
|
||||
val pm: PackageManager = packageManager
|
||||
val method = pm.javaClass.getDeclaredMethod(
|
||||
"getInstalledPackagesAsUser",
|
||||
Int::class.java,
|
||||
Int::class.java
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
m.invoke(pm, 0, userId) as List<PackageInfo>
|
||||
method.invoke(pm, flags, userId) as List<PackageInfo>
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "getInstalledPackagesAsUser", e)
|
||||
emptyList()
|
||||
Log.e(TAG, "err", e)
|
||||
ArrayList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserHandle.getUserIdCompat(): Int {
|
||||
return try {
|
||||
javaClass.getDeclaredField("identifier").apply { isAccessible = true }.getInt(this)
|
||||
} catch (_: NoSuchFieldException) {
|
||||
javaClass.getDeclaredMethod("getIdentifier").invoke(this) as Int
|
||||
} catch (e: Throwable) {
|
||||
Log.e("KsuService", "getUserIdCompat", e)
|
||||
0
|
||||
private inner class Stub : IKsuInterface.Stub() {
|
||||
override fun getPackages(flags: Int): ParcelableListSlice<PackageInfo> {
|
||||
val list = getInstalledPackagesAll(flags)
|
||||
Log.i(TAG, "getPackages: ${list.size}")
|
||||
return ParcelableListSlice(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,307 +1,217 @@
|
||||
package com.sukisu.ultra.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
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.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.foundation.layout.*
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.lifecycle.lifecycleScope
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
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.destinations.ExecuteModuleActionScreenDestination
|
||||
import com.ramcosta.composedestinations.spec.NavHostGraphSpec
|
||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ui.screen.BottomBarDestination
|
||||
import com.sukisu.ultra.ui.theme.KernelSUTheme
|
||||
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 dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import kotlinx.coroutines.launch
|
||||
import com.sukisu.ultra.ui.activity.component.BottomBar
|
||||
import com.sukisu.ultra.ui.activity.util.*
|
||||
import com.sukisu.ultra.Natives
|
||||
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() {
|
||||
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?) {
|
||||
try {
|
||||
// 应用自定义 DPI
|
||||
DisplayUtils.applyCustomDpi(this)
|
||||
|
||||
// Enable edge to edge
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
val isManager = Natives.isManager
|
||||
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)
|
||||
|
||||
val isManager = Natives.isManager
|
||||
if (isManager && !Natives.requireNewKernel()) {
|
||||
install()
|
||||
}
|
||||
|
||||
// 使用标记控制初始化流程
|
||||
if (!isInitialized) {
|
||||
initializeViewModels()
|
||||
initializeData()
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
// 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) }
|
||||
DisposableEffect(prefs, darkMode) {
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
) { darkMode },
|
||||
navigationBarStyle = SystemBarStyle.auto(
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
android.graphics.Color.TRANSPARENT
|
||||
) { darkMode },
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@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")
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
when (key) {
|
||||
"color_mode" -> colorMode = prefs.getInt("color_mode", 0)
|
||||
"key_color" -> keyColorInt = prefs.getInt("key_color", 0)
|
||||
}
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
}
|
||||
|
||||
setContent {
|
||||
KernelSUTheme {
|
||||
val navController = rememberNavController()
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
|
||||
KernelSUTheme(colorMode = colorMode, keyColor = keyColor) {
|
||||
val navController = rememberNavController()
|
||||
val navigator = navController.rememberDestinationsNavigator()
|
||||
val initialIntent = remember { intent }
|
||||
|
||||
val bottomBarRoutes = remember {
|
||||
BottomBarDestination.entries.map { it.direction.route }.toSet()
|
||||
}
|
||||
Scaffold {
|
||||
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(
|
||||
show = showConfirmationDialog.value,
|
||||
zipFiles = pendingZipFiles.value,
|
||||
onConfirm = { confirmedFiles ->
|
||||
showConfirmationDialog.value = false
|
||||
UltraActivityUtils.navigateToFlashScreen(this, confirmedFiles, navigator)
|
||||
},
|
||||
onDismiss = {
|
||||
showConfirmationDialog.value = false
|
||||
pendingZipFiles.value = emptyList()
|
||||
finish()
|
||||
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition =
|
||||
{
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it / 5 },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
|
||||
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition =
|
||||
{
|
||||
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) {
|
||||
ExecuteModuleActionScreenDestination.route -> false
|
||||
else -> true
|
||||
}
|
||||
val LocalPagerState = compositionLocalOf<PagerState> { error("No pager state") }
|
||||
val LocalHandlePageChange = compositionLocalOf<(Int) -> Unit> { error("No handle page change") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
initPlatform()
|
||||
}
|
||||
@Composable
|
||||
@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(
|
||||
LocalSnackbarHost provides snackBarHostState
|
||||
) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
AnimatedBottomBar.AnimatedBottomBarWrapper(
|
||||
showBottomBar = showBottomBar,
|
||||
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))
|
||||
}
|
||||
}
|
||||
BackHandler {
|
||||
if (pagerState.currentPage != 0) {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(0)
|
||||
}
|
||||
} else {
|
||||
activity?.finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
|
||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
|
||||
// If navigating from the home page (bottom navigation page) to a detail page, slide out to the left
|
||||
if (initialState.destination.route in bottomBarRoutes && targetState.destination.route !in bottomBarRoutes) {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut()
|
||||
} else {
|
||||
// Otherwise (switching between bottom navigation pages), use fade out
|
||||
fadeOut(animationSpec = tween(340))
|
||||
}
|
||||
}
|
||||
|
||||
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
|
||||
// If returning to the home page (bottom navigation page), slide in from the left
|
||||
if (targetState.destination.route in bottomBarRoutes) {
|
||||
slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn()
|
||||
} else {
|
||||
// Otherwise (e.g., returning between multiple detail pages), use default fade in
|
||||
fadeIn(animationSpec = tween(340))
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalPagerState provides pagerState,
|
||||
LocalHandlePageChange provides handlePageChange
|
||||
) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
BottomBar(hazeState, hazeStyle)
|
||||
},
|
||||
) { innerPadding ->
|
||||
HorizontalPager(
|
||||
modifier = Modifier.hazeSource(state = hazeState),
|
||||
state = pagerState,
|
||||
beyondViewportPageCount = 2,
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
when (it) {
|
||||
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
|
||||
1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
|
||||
2 -> ModulePager(navController, innerPadding.calculateBottomPadding())
|
||||
3 -> SettingPager(navController, innerPadding.calculateBottomPadding())
|
||||
}
|
||||
}
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,51 +1,59 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.graphics.text.LineBreaker
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.text.Layout
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
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.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.utils.NoCopySpannableFactory
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
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
|
||||
|
||||
private const val TAG = "DialogComponent"
|
||||
|
||||
interface ConfirmDialogVisuals : Parcelable {
|
||||
val title: String
|
||||
val content: String
|
||||
val content: String?
|
||||
val isMarkdown: Boolean
|
||||
val confirm: String?
|
||||
val dismiss: String?
|
||||
@@ -54,7 +62,7 @@ interface ConfirmDialogVisuals : Parcelable {
|
||||
@Parcelize
|
||||
private data class ConfirmDialogVisualsImpl(
|
||||
override val title: String,
|
||||
override val content: String,
|
||||
override val content: String?,
|
||||
override val isMarkdown: Boolean,
|
||||
override val confirm: String?,
|
||||
override val dismiss: String?,
|
||||
@@ -86,16 +94,15 @@ interface ConfirmDialogHandle : DialogHandle {
|
||||
|
||||
fun showConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
content: String? = null,
|
||||
markdown: Boolean = false,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
)
|
||||
|
||||
suspend fun awaitConfirm(
|
||||
|
||||
title: String,
|
||||
content: String,
|
||||
content: String? = null,
|
||||
markdown: Boolean = false,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
@@ -159,7 +166,10 @@ interface ConfirmCallback {
|
||||
val isEmpty: Boolean get() = onConfirm == null && onDismiss == null
|
||||
|
||||
companion object {
|
||||
operator fun invoke(onConfirmProvider: () -> NullableCallback, onDismissProvider: () -> NullableCallback): ConfirmCallback {
|
||||
operator fun invoke(
|
||||
onConfirmProvider: () -> NullableCallback,
|
||||
onDismissProvider: () -> NullableCallback
|
||||
): ConfirmCallback {
|
||||
return object : ConfirmCallback {
|
||||
override val onConfirm: NullableCallback
|
||||
get() = onConfirmProvider()
|
||||
@@ -250,7 +260,7 @@ private class ConfirmDialogHandleImpl(
|
||||
|
||||
override fun showConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
content: String?,
|
||||
markdown: Boolean,
|
||||
confirm: String?,
|
||||
dismiss: String?
|
||||
@@ -263,7 +273,7 @@ private class ConfirmDialogHandleImpl(
|
||||
|
||||
override suspend fun awaitConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
content: String?,
|
||||
markdown: Boolean,
|
||||
confirm: 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
|
||||
fun rememberLoadingDialog(): LoadingDialogHandle {
|
||||
val visible = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val visible = remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (visible.value) {
|
||||
LoadingDialog()
|
||||
}
|
||||
LoadingDialog(visible)
|
||||
|
||||
return remember {
|
||||
LoadingDialogHandleImpl(visible, coroutineScope)
|
||||
@@ -343,7 +342,8 @@ private fun rememberConfirmDialog(visuals: ConfirmDialogVisuals, callback: Confi
|
||||
ConfirmDialog(
|
||||
handle.visuals,
|
||||
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
|
||||
fun rememberCustomDialog(composable: @Composable (dismiss: () -> Unit) -> Unit): DialogHandle {
|
||||
val visible = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
if (visible.value) {
|
||||
composable { visible.value = false }
|
||||
}
|
||||
return remember {
|
||||
CustomDialogHandleImpl(visible, coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingDialog() {
|
||||
Dialog(
|
||||
private fun LoadingDialog(showDialog: MutableState<Boolean>) {
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
onDismissRequest = {},
|
||||
properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
content = {
|
||||
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
|
||||
private fun MarkdownContent(content: String) {
|
||||
val contentColor = LocalContentColor.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(
|
||||
state = scrollState,
|
||||
flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
|
||||
private fun ConfirmDialog(
|
||||
visuals: ConfirmDialogVisuals,
|
||||
confirm: () -> Unit,
|
||||
dismiss: () -> Unit,
|
||||
showDialog: MutableState<Boolean>
|
||||
) {
|
||||
SuperDialog(
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
|
||||
show = showDialog,
|
||||
title = visuals.title,
|
||||
onDismissRequest = {
|
||||
dismiss()
|
||||
showDialog.value = false
|
||||
},
|
||||
content = {
|
||||
Layout(
|
||||
content = {
|
||||
visuals.content?.let {
|
||||
if (visuals.isMarkdown) {
|
||||
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())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,4 @@ fun KeyEventBlocker(predicate: (KeyEvent) -> Boolean) {
|
||||
LaunchedEffect(Unit) {
|
||||
requester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ksuApp
|
||||
|
||||
@Composable
|
||||
fun KsuIsValid(
|
||||
@@ -14,4 +13,4 @@ fun KsuIsValid(
|
||||
if (ksuVersion != null) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 = "" }
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = { }
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
package com.sukisu.ultra.ui.component.profile
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.sukisu.ultra.Natives
|
||||
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
|
||||
fun AppProfileConfig(
|
||||
@@ -21,13 +24,15 @@ fun AppProfileConfig(
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (!fixedName) {
|
||||
OutlinedTextField(
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
value = profile.name,
|
||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
||||
EditText(
|
||||
title = stringResource(R.string.profile_name),
|
||||
textValue = remember { mutableStateOf(profile.name) },
|
||||
onTextValueChange = { onProfileChange(profile.copy(name = it)) },
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
SwitchItem(
|
||||
|
||||
SuperSwitch(
|
||||
title = stringResource(R.string.profile_umount_modules),
|
||||
summary = stringResource(R.string.profile_umount_modules_summary),
|
||||
checked = if (enabled) {
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
package com.sukisu.ultra.ui.component.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.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.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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.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.R
|
||||
import com.sukisu.ultra.profile.Capabilities
|
||||
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 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
|
||||
fun RootProfileConfig(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -36,94 +48,49 @@ fun RootProfileConfig(
|
||||
profile: Natives.Profile,
|
||||
onProfileChange: (Natives.Profile) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
if (!fixedName) {
|
||||
OutlinedTextField(
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
TextField(
|
||||
label = stringResource(R.string.profile_name),
|
||||
value = profile.name,
|
||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val currentNamespace = when (profile.namespace) {
|
||||
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 = {
|
||||
SuperEditArrow(
|
||||
title = "UID",
|
||||
defaultValue = profile.uid,
|
||||
) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
uid = it,
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
UidPanel(uid = profile.gid, label = "gid", onUidChange = {
|
||||
}
|
||||
|
||||
SuperEditArrow(
|
||||
title = "GID",
|
||||
defaultValue = profile.gid,
|
||||
) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
gid = it,
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e ->
|
||||
e.mapNotNull { g ->
|
||||
Groups.entries.find { it.gid == g }
|
||||
}
|
||||
}
|
||||
|
||||
GroupsPanel(selectedGroups) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
@@ -155,15 +122,15 @@ fun RootProfileConfig(
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) {
|
||||
val selectGroupsDialog = rememberCustomDialog { dismiss: () -> Unit ->
|
||||
val groups = Groups.entries.toTypedArray().sortedWith(
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
val groups = remember {
|
||||
Groups.entries.toTypedArray().sortedWith(
|
||||
compareBy<Groups> { if (selected.contains(it)) 0 else 1 }
|
||||
.then(compareBy {
|
||||
when (it) {
|
||||
@@ -174,308 +141,255 @@ fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>)
|
||||
}
|
||||
})
|
||||
.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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val currentSelection = remember { mutableStateOf(selected.toSet()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
selectGroupsDialog.show()
|
||||
}
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.profile_groups))
|
||||
FlowRow {
|
||||
selected.forEach { group ->
|
||||
AssistChip(
|
||||
modifier = Modifier.padding(3.dp),
|
||||
onClick = { /*TODO*/ },
|
||||
label = { Text(group.display) })
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = stringResource(R.string.profile_groups),
|
||||
summary = "${currentSelection.value.size} / 32",
|
||||
insideMargin = DpSize(0.dp, 24.dp),
|
||||
onDismissRequest = { showDialog.value = false }
|
||||
) {
|
||||
Column(modifier = Modifier.heightIn(max = 500.dp)) {
|
||||
LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
|
||||
items(groups) { group ->
|
||||
SuperCheckbox(
|
||||
title = group.display,
|
||||
summary = group.desc,
|
||||
insideMargin = PaddingValues(horizontal = 30.dp, vertical = 16.dp),
|
||||
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
|
||||
fun CapsPanel(
|
||||
selected: Collection<Capabilities>,
|
||||
closeSelection: (selection: Set<Capabilities>) -> Unit
|
||||
) {
|
||||
val selectCapabilitiesDialog = rememberCustomDialog { dismiss ->
|
||||
val caps = Capabilities.entries.toTypedArray().sortedWith(
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
val caps = remember {
|
||||
Capabilities.entries.toTypedArray().sortedWith(
|
||||
compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 }
|
||||
.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(
|
||||
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_capabilities),
|
||||
),
|
||||
selection = ListSelection.Multiple(
|
||||
showCheckBoxes = true,
|
||||
options = options
|
||||
) { indecies, _ ->
|
||||
// Handle selection
|
||||
selection.clear()
|
||||
indecies.forEach { index ->
|
||||
val group = caps[index]
|
||||
selection.add(group)
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = stringResource(R.string.profile_capabilities),
|
||||
insideMargin = DpSize(0.dp, 24.dp),
|
||||
onDismissRequest = { showDialog.value = false },
|
||||
content = {
|
||||
Column(modifier = Modifier.heightIn(max = 500.dp)) {
|
||||
LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
|
||||
items(caps) { cap ->
|
||||
SuperCheckbox(
|
||||
title = cap.display,
|
||||
summary = cap.desc,
|
||||
insideMargin = PaddingValues(horizontal = 30.dp, vertical = 16.dp),
|
||||
checkboxLocation = CheckboxLocation.Right,
|
||||
checked = currentSelection.value.contains(cap),
|
||||
holdDownState = currentSelection.value.contains(cap),
|
||||
onCheckedChange = { isChecked ->
|
||||
val newSelection = currentSelection.value.toMutableSet()
|
||||
if (isChecked) {
|
||||
newSelection.add(cap)
|
||||
} else {
|
||||
newSelection.remove(cap)
|
||||
}
|
||||
currentSelection.value = newSelection
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
selectCapabilitiesDialog.show()
|
||||
}
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.profile_capabilities))
|
||||
FlowRow {
|
||||
selected.forEach { group ->
|
||||
AssistChip(
|
||||
modifier = Modifier.padding(3.dp),
|
||||
onClick = { /*TODO*/ },
|
||||
label = { Text(group.display) })
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDialog.value = false
|
||||
currentSelection.value = selected.toSet()
|
||||
},
|
||||
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_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
|
||||
private fun SELinuxPanel(
|
||||
profile: Natives.Profile,
|
||||
onSELinuxChange: (domain: String, rules: String) -> Unit
|
||||
) {
|
||||
val editSELinuxDialog = rememberCustomDialog { dismiss ->
|
||||
var domain by remember { mutableStateOf(profile.context) }
|
||||
var rules by remember { mutableStateOf(profile.rules) }
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
val inputOptions = listOf(
|
||||
InputTextField(
|
||||
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!")
|
||||
}
|
||||
)
|
||||
)
|
||||
var domain by remember { mutableStateOf(profile.context) }
|
||||
var rules by remember { mutableStateOf(profile.rules) }
|
||||
|
||||
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(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
InputDialog(
|
||||
state = rememberUseCaseState(
|
||||
visible = true,
|
||||
onFinishedRequest = {
|
||||
onSELinuxChange(domain, rules)
|
||||
},
|
||||
onCloseRequest = {
|
||||
dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.profile_selinux_context),
|
||||
),
|
||||
selection = InputSelection(
|
||||
input = inputOptions,
|
||||
onPositiveClick = { result ->
|
||||
// Handle selection
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = stringResource(R.string.profile_selinux_context),
|
||||
onDismissRequest = { showDialog.value = false }
|
||||
) {
|
||||
Column(modifier = Modifier.heightIn(max = 500.dp)) {
|
||||
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||
TextField(
|
||||
value = domain,
|
||||
onValueChange = { domain = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
label = stringResource(id = R.string.profile_selinux_domain),
|
||||
borderColor = if (isDomainValid) {
|
||||
colorScheme.primary
|
||||
} else {
|
||||
Color.Red.copy(alpha = if (isSystemInDarkTheme()) 0.3f else 0.6f)
|
||||
},
|
||||
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 = {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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
|
||||
SuperArrow(
|
||||
title = stringResource(R.string.profile_selinux_context),
|
||||
summary = profile.context,
|
||||
onClick = { showDialog.value = true }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,105 +1,94 @@
|
||||
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.automirrored.filled.ReadMore
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.icons.rounded.Create
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.util.listAppProfileTemplates
|
||||
import com.sukisu.ultra.ui.util.setSepolicy
|
||||
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
|
||||
* @date 2023/10/21.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TemplateConfig(
|
||||
modifier: Modifier = Modifier,
|
||||
profile: Natives.Profile,
|
||||
onViewTemplate: (id: String) -> Unit = {},
|
||||
onManageTemplate: () -> Unit = {},
|
||||
onProfileChange: (Natives.Profile) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var template by rememberSaveable {
|
||||
mutableStateOf(profile.rootTemplate ?: "")
|
||||
}
|
||||
val profileTemplates = listAppProfileTemplates()
|
||||
val noTemplates = profileTemplates.isEmpty()
|
||||
|
||||
ListItem(headlineContent = {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.profile_template)) },
|
||||
value = template.ifEmpty { "None" },
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
if (noTemplates) {
|
||||
IconButton(
|
||||
onClick = onManageTemplate
|
||||
) {
|
||||
Icon(Icons.Filled.Create, null)
|
||||
}
|
||||
} else if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
||||
if (noTemplates) {
|
||||
SuperArrow(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.app_profile_template_create),
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Rounded.Create,
|
||||
null,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
tint = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
},
|
||||
onClick = onManageTemplate,
|
||||
)
|
||||
} else {
|
||||
var template by rememberSaveable { mutableStateOf(profile.rootTemplate ?: profileTemplates[0]) }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
SuperDropdown(
|
||||
title = stringResource(R.string.profile_template),
|
||||
items = profileTemplates,
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.os.Environment
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.sukisu.ultra.R
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.FlashState
|
||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelState
|
||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelWorker
|
||||
import com.sukisu.ultra.ui.kernelFlash.state.*
|
||||
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.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.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -60,12 +65,17 @@ private object KernelFlashStateHolder {
|
||||
var currentKpmPatchEnabled: Boolean = false
|
||||
var currentKpmUndoPatch: Boolean = false
|
||||
var isFlashing = false
|
||||
|
||||
fun clear() {
|
||||
currentState = null
|
||||
currentUri = null
|
||||
currentSlot = null
|
||||
currentKpmPatchEnabled = false
|
||||
currentKpmUndoPatch = false
|
||||
isFlashing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kernel刷写界面
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun KernelFlashScreen(
|
||||
@@ -76,15 +86,7 @@ fun KernelFlashScreen(
|
||||
kpmUndoPatch: Boolean = false
|
||||
) {
|
||||
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 scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var logText by rememberSaveable { mutableStateOf("") }
|
||||
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -109,19 +111,27 @@ fun KernelFlashScreen(
|
||||
}
|
||||
|
||||
val flashState by horizonKernelState.state.collectAsState()
|
||||
val logSavedString = stringResource(R.string.log_saved)
|
||||
val activity = LocalActivity.current
|
||||
|
||||
val onFlashComplete = {
|
||||
showFloatAction = true
|
||||
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 (shouldAutoExit) {
|
||||
scope.launch {
|
||||
if (isFromExternalIntent) {
|
||||
delay(1500)
|
||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
||||
(context as? ComponentActivity)?.finish()
|
||||
KernelFlashStateHolder.clear()
|
||||
activity.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,36 +181,30 @@ fun KernelFlashScreen(
|
||||
|
||||
val onBack: () -> Unit = {
|
||||
if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) {
|
||||
// 清理全局状态
|
||||
if (flashState.isCompleted || flashState.error.isNotEmpty()) {
|
||||
KernelFlashStateHolder.currentState = null
|
||||
KernelFlashStateHolder.currentUri = null
|
||||
KernelFlashStateHolder.currentSlot = null
|
||||
KernelFlashStateHolder.currentKpmPatchEnabled = false
|
||||
KernelFlashStateHolder.currentKpmUndoPatch = false
|
||||
KernelFlashStateHolder.isFlashing = false
|
||||
KernelFlashStateHolder.clear()
|
||||
}
|
||||
navigator.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(shouldAutoExit) {
|
||||
// 清理状态
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (shouldAutoExit) {
|
||||
KernelFlashStateHolder.currentState = null
|
||||
KernelFlashStateHolder.currentUri = null
|
||||
KernelFlashStateHolder.currentSlot = null
|
||||
KernelFlashStateHolder.currentKpmPatchEnabled = false
|
||||
KernelFlashStateHolder.currentKpmUndoPatch = false
|
||||
KernelFlashStateHolder.isFlashing = false
|
||||
if (flashState.isCompleted || flashState.error.isNotEmpty()) {
|
||||
KernelFlashStateHolder.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
BackHandler {
|
||||
onBack()
|
||||
}
|
||||
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
@@ -215,15 +219,13 @@ fun KernelFlashScreen(
|
||||
"KernelSU_kernel_flash_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
snackBarHost.showSnackbar(logSavedString.format(file.absolutePath))
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (showFloatAction) {
|
||||
ExtendedFloatingActionButton(
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -231,34 +233,22 @@ fun KernelFlashScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.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
|
||||
)
|
||||
modifier = Modifier.padding(bottom = 20.dp, end = 20.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Refresh,
|
||||
contentDescription = stringResource(id = R.string.reboot)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) { innerPadding ->
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
|
||||
popupHost = { }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
.padding(it)
|
||||
.scrollEndHaptic(),
|
||||
) {
|
||||
FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch)
|
||||
Box(
|
||||
@@ -273,9 +263,8 @@ fun KernelFlashScreen(
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = logText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -288,24 +277,21 @@ private fun FlashProgressIndicator(
|
||||
kpmPatchEnabled: Boolean = false,
|
||||
kpmUndoPatch: Boolean = false
|
||||
) {
|
||||
val progressColor = when {
|
||||
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
|
||||
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
val statusColor = when {
|
||||
flashState.error.isNotEmpty() -> colorScheme.error
|
||||
flashState.isCompleted -> colorScheme.primary
|
||||
else -> colorScheme.primary
|
||||
}
|
||||
|
||||
val progress = animateFloatAsState(
|
||||
targetValue = flashState.progress,
|
||||
targetValue = flashState.progress.coerceIn(0f, 1f),
|
||||
label = "FlashProgress"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -323,9 +309,9 @@ private fun FlashProgressIndicator(
|
||||
flashState.isCompleted -> stringResource(R.string.flash_success)
|
||||
else -> stringResource(R.string.flashing)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = progressColor
|
||||
fontSize = MiuixTheme.textStyles.title4.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = statusColor
|
||||
)
|
||||
|
||||
when {
|
||||
@@ -333,14 +319,14 @@ private fun FlashProgressIndicator(
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = colorScheme.error
|
||||
)
|
||||
}
|
||||
flashState.isCompleted -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -348,128 +334,87 @@ private fun FlashProgressIndicator(
|
||||
|
||||
// KPM状态显示
|
||||
if (kpmPatchEnabled || kpmUndoPatch) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode)
|
||||
else stringResource(R.string.kpm_patch_mode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (flashState.currentStep.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = flashState.currentStep,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.value },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp),
|
||||
color = progressColor,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
progress = progress.value,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
if (flashState.error.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.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))
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = flashState.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = colorScheme.onErrorContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
colorScheme.errorContainer
|
||||
)
|
||||
.padding(8.dp)
|
||||
.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
flashState: FlashState,
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
onSave: () -> Unit = {}
|
||||
) {
|
||||
val statusColor = when {
|
||||
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
|
||||
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
SmallTopAppBar(
|
||||
title = stringResource(
|
||||
when {
|
||||
flashState.error.isNotEmpty() -> R.string.flash_failed
|
||||
flashState.isCompleted -> R.string.flash_success
|
||||
else -> R.string.kernel_flashing
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = onSave) {
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
contentDescription = stringResource(id = R.string.save_log),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
actions = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = onSave
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Save,
|
||||
contentDescription = stringResource(id = R.string.save_log),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.app.Activity
|
||||
@@ -6,11 +6,12 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
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.rootAvailable
|
||||
import com.sukisu.ultra.utils.AssetsUtil
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -74,10 +75,6 @@ class HorizonKernelState {
|
||||
fun completeFlashing() {
|
||||
_state.update { it.copy(isCompleted = true, progress = 1f) }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
_state.value = FlashState()
|
||||
}
|
||||
}
|
||||
|
||||
class HorizonKernelWorker(
|
||||
@@ -157,7 +154,12 @@ class HorizonKernelWorker(
|
||||
if (isAbDevice && slot != null) {
|
||||
state.updateStep(context.getString(R.string.horizon_getting_original_slot))
|
||||
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.updateProgress(0.74f)
|
||||
@@ -308,7 +310,12 @@ class HorizonKernelWorker(
|
||||
}
|
||||
|
||||
// 查找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()) {
|
||||
throw IOException(context.getString(R.string.kpm_image_file_not_found))
|
||||
}
|
||||
@@ -398,11 +405,16 @@ class HorizonKernelWorker(
|
||||
|
||||
// 检查设备是否为AB分区设备
|
||||
private fun isAbDevice(): Boolean {
|
||||
val abUpdate = runCommandGetOutput("getprop ro.build.ab_update")
|
||||
if (!abUpdate.toBoolean()) return false
|
||||
return try {
|
||||
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")
|
||||
return slotSuffix.isNotEmpty()
|
||||
val slotSuffix = ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim()
|
||||
slotSuffix.isNotEmpty()
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
@@ -429,7 +441,12 @@ class HorizonKernelWorker(
|
||||
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
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 version = kernelVersion.let { versionRegex.find(it) }?.value ?: ""
|
||||
val toolName = if (version.isNotEmpty()) {
|
||||
@@ -447,7 +464,9 @@ class HorizonKernelWorker(
|
||||
val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
|
||||
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
|
||||
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() {
|
||||
@@ -517,8 +536,4 @@ class HorizonKernelWorker(
|
||||
process.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runCommandGetOutput(cmd: String): String {
|
||||
return Shell.cmd(cmd).exec().out.joinToString("\n").trim()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.sukisu.ultra.utils
|
||||
package com.sukisu.ultra.ui.kernelFlash.util
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.sukisu.ultra.network
|
||||
package com.sukisu.ultra.ui.kernelFlash.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.sukisu.ultra.ui.util.getRootShell
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
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 CONNECTION_TIMEOUT = 15000 // 15秒连接超时
|
||||
private const val READ_TIMEOUT = 30000 // 30秒读取超时
|
||||
private const val CONNECTION_TIMEOUT = 10000
|
||||
private const val READ_TIMEOUT = 20000
|
||||
|
||||
// 最大重试次数
|
||||
private const val MAX_RETRY_COUNT = 3
|
||||
@@ -48,47 +50,26 @@ class RemoteToolsDownloader(
|
||||
|
||||
|
||||
suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) {
|
||||
val results = mutableMapOf<String, DownloadResult>()
|
||||
|
||||
listener?.onLog("Starting to prepare KPM tool files...")
|
||||
File(workDir).mkdirs()
|
||||
|
||||
try {
|
||||
// 确保工作目录存在
|
||||
File(workDir).mkdirs()
|
||||
// 并行下载两个工具文件
|
||||
val results = mapOf(
|
||||
"kptools" to async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) },
|
||||
"kpimg" to async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
|
||||
).mapValues { it.value.await() }
|
||||
|
||||
// 并行下载两个工具文件
|
||||
val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) }
|
||||
val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
|
||||
|
||||
// 等待所有下载完成
|
||||
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)
|
||||
}
|
||||
// 设置 kptools 执行权限
|
||||
File(workDir, "kptools").takeIf { it.exists() }?.let { file ->
|
||||
setExecutablePermission(file.absolutePath)
|
||||
listener?.onLog("Set kptools execution permission")
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -96,43 +77,38 @@ class RemoteToolsDownloader(
|
||||
remoteUrl: String?,
|
||||
listener: DownloadProgressListener?
|
||||
): DownloadResult = withContext(Dispatchers.IO) {
|
||||
|
||||
val targetFile = File(workDir, fileName)
|
||||
|
||||
if (remoteUrl == null) {
|
||||
return@withContext useLocalVersion(fileName, targetFile, listener)
|
||||
}
|
||||
|
||||
// 尝试从远程下载
|
||||
listener?.onLog("Downloading $fileName from remote repository...")
|
||||
|
||||
var lastError = ""
|
||||
|
||||
// 重试机制
|
||||
var lastError = ""
|
||||
repeat(MAX_RETRY_COUNT) { attempt ->
|
||||
try {
|
||||
val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener)
|
||||
if (result.success) {
|
||||
listener?.onSuccess(fileName, true)
|
||||
return@withContext result
|
||||
} else {
|
||||
lastError = result.errorMessage ?: "Unknown error"
|
||||
}
|
||||
lastError = result.errorMessage ?: "Unknown error"
|
||||
|
||||
} catch (e: Exception) {
|
||||
lastError = e.message ?: "Network exception"
|
||||
lastError = "Network exception"
|
||||
Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e)
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRY_COUNT - 1) {
|
||||
listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...")
|
||||
delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L))
|
||||
}
|
||||
if (attempt < MAX_RETRY_COUNT - 1) {
|
||||
listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...")
|
||||
delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L))
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败,回退到本地版本
|
||||
listener?.onError(fileName, "Remote download failed: $lastError")
|
||||
listener?.onLog("$fileName remote download failed, falling back to local version...")
|
||||
|
||||
useLocalVersion(fileName, targetFile, listener)
|
||||
}
|
||||
|
||||
@@ -142,15 +118,10 @@ class RemoteToolsDownloader(
|
||||
targetFile: File,
|
||||
listener: DownloadProgressListener?
|
||||
): DownloadResult = withContext(Dispatchers.IO) {
|
||||
|
||||
var connection: HttpURLConnection? = null
|
||||
|
||||
try {
|
||||
val url = URL(remoteUrl)
|
||||
connection = url.openConnection() as HttpURLConnection
|
||||
|
||||
// 设置连接参数
|
||||
connection.apply {
|
||||
connection = (URL(remoteUrl).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = CONNECTION_TIMEOUT
|
||||
readTimeout = READ_TIMEOUT
|
||||
requestMethod = "GET"
|
||||
@@ -159,22 +130,17 @@ class RemoteToolsDownloader(
|
||||
setRequestProperty("Connection", "close")
|
||||
}
|
||||
|
||||
// 建立连接
|
||||
connection.connect()
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
return@withContext DownloadResult(
|
||||
false,
|
||||
isRemoteSource = false,
|
||||
errorMessage = "HTTP error code: $responseCode"
|
||||
errorMessage = "HTTP error code: ${connection.responseCode}"
|
||||
)
|
||||
}
|
||||
|
||||
val fileLength = connection.contentLength
|
||||
Log.d(TAG, "$fileName remote file size: $fileLength bytes")
|
||||
|
||||
// 创建临时文件
|
||||
val tempFile = File(targetFile.absolutePath + ".tmp")
|
||||
|
||||
// 下载文件
|
||||
@@ -182,40 +148,34 @@ class RemoteToolsDownloader(
|
||||
FileOutputStream(tempFile).use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
var totalBytes = 0
|
||||
var bytesRead: Int
|
||||
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
// 检查协程是否被取消
|
||||
while (true) {
|
||||
ensureActive()
|
||||
val bytesRead = input.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalBytes += bytesRead
|
||||
|
||||
// 更新下载进度
|
||||
if (fileLength > 0) {
|
||||
listener?.onProgress(fileName, totalBytes, fileLength)
|
||||
}
|
||||
}
|
||||
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// 验证下载的文件
|
||||
// 验证并移动文件
|
||||
if (!validateDownloadedFile(tempFile, fileName)) {
|
||||
tempFile.delete()
|
||||
return@withContext DownloadResult(
|
||||
success = false,
|
||||
false,
|
||||
isRemoteSource = false,
|
||||
errorMessage = "File verification failed"
|
||||
)
|
||||
}
|
||||
|
||||
// 移动临时文件到目标位置
|
||||
if (targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
|
||||
targetFile.delete()
|
||||
if (!tempFile.renameTo(targetFile)) {
|
||||
tempFile.delete()
|
||||
return@withContext DownloadResult(
|
||||
@@ -227,7 +187,6 @@ class RemoteToolsDownloader(
|
||||
|
||||
Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes")
|
||||
listener?.onLog("$fileName remote download successful")
|
||||
|
||||
DownloadResult(true, isRemoteSource = true)
|
||||
|
||||
} catch (e: SocketTimeoutException) {
|
||||
@@ -235,16 +194,10 @@ class RemoteToolsDownloader(
|
||||
DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout")
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "$fileName network IO exception", e)
|
||||
DownloadResult(false,
|
||||
isRemoteSource = false,
|
||||
errorMessage = "Network connection exception: ${e.message}"
|
||||
)
|
||||
DownloadResult(false, isRemoteSource = false, errorMessage = "Network exception: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "$fileName exception occurred during download", e)
|
||||
DownloadResult(false,
|
||||
isRemoteSource = false,
|
||||
errorMessage = "Download exception: ${e.message}"
|
||||
)
|
||||
DownloadResult(false, isRemoteSource = false, errorMessage = "Download exception: ${e.message}")
|
||||
} finally {
|
||||
connection?.disconnect()
|
||||
}
|
||||
@@ -255,61 +208,42 @@ class RemoteToolsDownloader(
|
||||
targetFile: File,
|
||||
listener: DownloadProgressListener?
|
||||
): DownloadResult = withContext(Dispatchers.IO) {
|
||||
|
||||
try {
|
||||
com.sukisu.ultra.utils.AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath)
|
||||
AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath)
|
||||
|
||||
if (!targetFile.exists()) {
|
||||
val errorMsg = "Local $fileName file extraction failed"
|
||||
if (!targetFile.exists() || !validateDownloadedFile(targetFile, fileName)) {
|
||||
val errorMsg = if (!targetFile.exists()) {
|
||||
"Local $fileName file extraction failed"
|
||||
} else {
|
||||
"Local $fileName file verification failed"
|
||||
}
|
||||
listener?.onError(fileName, errorMsg)
|
||||
return@withContext DownloadResult(false,
|
||||
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
|
||||
)
|
||||
return@withContext DownloadResult(false, isRemoteSource = false, errorMessage = errorMsg)
|
||||
}
|
||||
|
||||
Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes")
|
||||
listener?.onLog("$fileName local version loaded successfully")
|
||||
listener?.onSuccess(fileName, false)
|
||||
|
||||
DownloadResult(true, isRemoteSource = false)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "$fileName local version loading failed", e)
|
||||
val errorMsg = "Local version loading failed: ${e.message}"
|
||||
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 {
|
||||
if (!file.exists()) {
|
||||
Log.w(TAG, "$fileName file does not exist")
|
||||
if (!file.exists() || file.length() < MIN_FILE_SIZE) {
|
||||
Log.w(TAG, "$fileName file validation failed: exists=${file.exists()}, size=${file.length()}")
|
||||
return false
|
||||
}
|
||||
|
||||
val fileSize = file.length()
|
||||
if (fileSize < MIN_FILE_SIZE) {
|
||||
Log.w(TAG, "$fileName file is too small: $fileSize bytes")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return try {
|
||||
file.inputStream().use { input ->
|
||||
val header = ByteArray(4)
|
||||
val bytesRead = input.read(header)
|
||||
|
||||
if (bytesRead < 4) {
|
||||
if (input.read(header) < 4) {
|
||||
Log.w(TAG, "$fileName file header read incomplete")
|
||||
return false
|
||||
}
|
||||
@@ -324,20 +258,24 @@ class RemoteToolsDownloader(
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF")
|
||||
return true
|
||||
Log.d(TAG, "$fileName file verification passed, size: ${file.length()} bytes, ELF: $isELF")
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$fileName file verification exception", e)
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun setExecutablePermission(filePath: String) {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "chmod a+rx $filePath"))
|
||||
process.waitFor()
|
||||
Log.d(TAG, "Set execution permission for $filePath")
|
||||
val shell = getRootShell()
|
||||
if (ShellUtils.fastCmdResult(shell, "chmod a+rx $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) {
|
||||
Log.w(TAG, "Failed to set execution permission: $filePath", e)
|
||||
try {
|
||||
@@ -351,11 +289,9 @@ class RemoteToolsDownloader(
|
||||
|
||||
fun cleanup() {
|
||||
try {
|
||||
File(workDir).listFiles()?.forEach { file ->
|
||||
if (file.name.endsWith(".tmp")) {
|
||||
file.delete()
|
||||
Log.d(TAG, "Cleaned temporary file: ${file.name}")
|
||||
}
|
||||
File(workDir).listFiles()?.filter { it.name.endsWith(".tmp") }?.forEach { file ->
|
||||
file.delete()
|
||||
Log.d(TAG, "Cleaned temporary file: ${file.name}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to clean temporary files", e)
|
||||
207
manager/app/src/main/java/com/sukisu/ultra/ui/screen/About.kt
Normal file
207
manager/app/src/main/java/com/sukisu/ultra/ui/screen/About.kt
Normal 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()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
}
|
||||
@@ -1,50 +1,86 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import android.widget.Toast
|
||||
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.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.text.font.FontFamily
|
||||
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.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
||||
import com.sukisu.ultra.ui.util.runModuleAction
|
||||
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.launch
|
||||
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.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
var tempText : String
|
||||
var tempText: String
|
||||
val logContent = rememberSaveable { StringBuilder() }
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
var isActionRunning by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
BackHandler(enabled = isActionRunning) {
|
||||
// Disable back button if action is running
|
||||
}
|
||||
var actionResult: Boolean
|
||||
val hazeState = remember { HazeState() }
|
||||
val hazeStyle = HazeStyle(
|
||||
backgroundColor = colorScheme.surface,
|
||||
tint = HazeTint(colorScheme.surface.copy(0.8f))
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (text.isNotEmpty()) {
|
||||
@@ -65,83 +101,110 @@ fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String
|
||||
onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
}
|
||||
)
|
||||
).let {
|
||||
actionResult = it
|
||||
}
|
||||
}
|
||||
isActionRunning = false
|
||||
if (actionResult) navigator.popBackStack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
isActionRunning = isActionRunning,
|
||||
onBack = dropUnlessResumed {
|
||||
navigator.popBackStack()
|
||||
},
|
||||
onSave = {
|
||||
if (!isActionRunning) {
|
||||
scope.launch {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
val date = format.format(Date())
|
||||
val file = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"KernelSU_module_action_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
|
||||
}
|
||||
scope.launch {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
val date = format.format(Date())
|
||||
val file = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"KernelSU_module_action_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
Toast.makeText(context, "Log saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
hazeState = hazeState,
|
||||
hazeStyle = hazeStyle,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!isActionRunning) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(text = stringResource(R.string.close)) },
|
||||
icon = { Icon(Icons.Filled.Close, contentDescription = null) },
|
||||
onClick = {
|
||||
navigator.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
||||
popupHost = { },
|
||||
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.padding(innerPadding)
|
||||
.scrollEndHaptic()
|
||||
.hazeSource(state = hazeState)
|
||||
.padding(
|
||||
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
end = innerPadding.calculateStartPadding(layoutDirection),
|
||||
)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
LaunchedEffect(text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||
Text(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = text,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontSize = 12.sp,
|
||||
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
|
||||
private fun TopBar(isActionRunning: Boolean, onSave: () -> Unit = {}) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.action)) },
|
||||
actions = {
|
||||
private fun TopBar(
|
||||
onBack: () -> Unit = {},
|
||||
onSave: () -> Unit = {},
|
||||
hazeState: HazeState,
|
||||
hazeStyle: HazeStyle,
|
||||
) {
|
||||
SmallTopAppBar(
|
||||
modifier = Modifier.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
},
|
||||
title = stringResource(R.string.action),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onSave,
|
||||
enabled = !isActionRunning
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
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),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,257 +1,136 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.border
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.graphics.Color
|
||||
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.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.dropUnlessResumed
|
||||
import kotlinx.coroutines.delay
|
||||
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.ModuleScreenDestination
|
||||
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.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import androidx.core.content.edit
|
||||
import com.sukisu.ultra.ui.util.module.ModuleOperationUtils
|
||||
import com.sukisu.ultra.ui.util.module.ModuleUtils
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/5/31.
|
||||
* @author weishu
|
||||
* @date 2023/1/1.
|
||||
*/
|
||||
|
||||
enum class FlashingStatus {
|
||||
FLASHING,
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
|
||||
private var currentFlashingStatus = mutableStateOf(FlashingStatus.FLASHING)
|
||||
|
||||
// 添加模块安装状态跟踪
|
||||
data class ModuleInstallStatus(
|
||||
val totalModules: Int = 0,
|
||||
val currentModule: Int = 0,
|
||||
val currentModuleName: String = "",
|
||||
val failedModules: MutableList<String> = mutableListOf(),
|
||||
val verifiedModules: MutableList<String> = mutableListOf() // 添加已验证模块列表
|
||||
)
|
||||
|
||||
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
|
||||
// Lets you flash modules sequentially when mutiple zipUris are selected
|
||||
fun flashModulesSequentially(
|
||||
uris: List<Uri>,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
): FlashResult {
|
||||
for (uri in uris) {
|
||||
flashModule(uri, onStdout, onStderr).apply {
|
||||
if (code != 0) {
|
||||
return FlashResult(code, err, showReboot)
|
||||
}
|
||||
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 tempText: String
|
||||
val logContent = rememberSaveable { StringBuilder() }
|
||||
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 scrollState = rememberScrollState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val viewModel: ModuleViewModel = viewModel()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
var flashing by rememberSaveable {
|
||||
mutableStateOf(FlashingStatus.FLASHING)
|
||||
}
|
||||
|
||||
// 处理更新模块安装
|
||||
LaunchedEffect(flashIt) {
|
||||
if (flashIt !is FlashIt.FlashModuleUpdate) return@LaunchedEffect
|
||||
if (hasUpdateExecuted || hasUpdateCompleted || text.isNotEmpty()) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (text.isNotEmpty()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hasUpdateExecuted = true
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
setFlashingStatus(FlashingStatus.FLASHING)
|
||||
|
||||
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 = {
|
||||
flashIt(flashIt, onStdout = {
|
||||
tempText = "$it\n"
|
||||
if (tempText.startsWith("[H[J")) { // clear command
|
||||
text = tempText.substring(6)
|
||||
@@ -261,156 +140,41 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
logContent.append(it).append("\n")
|
||||
}, onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 安装但排除更新模块
|
||||
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 ->
|
||||
}).apply {
|
||||
if (code != 0) {
|
||||
text += "$errorCodeString $code.\n$checkLogString\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()
|
||||
text += "Error code: $code.\n $err Please save and check the log.\n"
|
||||
}
|
||||
if (showReboot) {
|
||||
text += "\n\n\n"
|
||||
showFloatAction = true
|
||||
}
|
||||
|
||||
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("[H[J")) { // 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()
|
||||
}
|
||||
flashing = if (code == 0) FlashingStatus.SUCCESS else FlashingStatus.FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
onBack()
|
||||
// 如果是从外部打开的模块安装,延迟1秒后自动退出
|
||||
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(
|
||||
topBar = {
|
||||
TopBar(
|
||||
currentFlashingStatus.value,
|
||||
currentStatus,
|
||||
onBack = onBack,
|
||||
flashing,
|
||||
onBack = dropUnlessResumed { navigator.popBackStack() },
|
||||
onSave = {
|
||||
scope.launch {
|
||||
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"
|
||||
)
|
||||
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 = {
|
||||
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 = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -436,25 +207,22 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
shadowElevation = 0.dp,
|
||||
content = {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(id = R.string.reboot)
|
||||
Icons.Rounded.Refresh,
|
||||
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) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
popupHost = { },
|
||||
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
@@ -462,307 +230,107 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
.scrollEndHaptic()
|
||||
.padding(
|
||||
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
end = innerPadding.calculateStartPadding(layoutDirection),
|
||||
)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
if (flashIt is FlashIt.FlashModules) {
|
||||
ModuleInstallProgressBar(
|
||||
currentIndex = flashIt.currentIndex + 1,
|
||||
totalCount = flashIt.uris.size,
|
||||
currentModuleName = currentStatus.currentModuleName,
|
||||
status = currentFlashingStatus.value,
|
||||
failedModules = currentStatus.failedModules
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LaunchedEffect(text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
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(Modifier.height(innerPadding.calculateTopPadding()))
|
||||
Text(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 失败模块列表
|
||||
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
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
12.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
|
||||
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
|
||||
sealed class FlashIt : Parcelable {
|
||||
data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) : FlashIt()
|
||||
data class FlashModule(val uri: Uri) : 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 FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) :
|
||||
FlashIt()
|
||||
|
||||
// 模块更新刷写
|
||||
fun flashModuleUpdate(
|
||||
uri: Uri,
|
||||
onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
) {
|
||||
flashModule(uri, onFinish, onStdout, onStderr)
|
||||
data class FlashModules(val uris: List<Uri>) : FlashIt()
|
||||
|
||||
data object FlashRestore : FlashIt()
|
||||
|
||||
data object FlashUninstall : FlashIt()
|
||||
}
|
||||
|
||||
fun flashIt(
|
||||
flashIt: FlashIt,
|
||||
onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
) {
|
||||
when (flashIt) {
|
||||
): FlashResult {
|
||||
return when (flashIt) {
|
||||
is FlashIt.FlashBoot -> installBoot(
|
||||
flashIt.boot,
|
||||
flashIt.lkm,
|
||||
flashIt.ota,
|
||||
flashIt.partition,
|
||||
onFinish,
|
||||
onStdout,
|
||||
onStderr
|
||||
)
|
||||
is FlashIt.FlashModule -> flashModule(flashIt.uri, onFinish, onStdout, onStderr)
|
||||
|
||||
is FlashIt.FlashModules -> {
|
||||
if (flashIt.uris.isEmpty() || flashIt.currentIndex >= flashIt.uris.size) {
|
||||
onFinish(false, 0)
|
||||
return
|
||||
}
|
||||
|
||||
val currentUri = flashIt.uris[flashIt.currentIndex]
|
||||
onStdout("\n")
|
||||
|
||||
flashModule(currentUri, onFinish, onStdout, onStderr)
|
||||
flashModulesSequentially(flashIt.uris, onStdout, onStderr)
|
||||
}
|
||||
is FlashIt.FlashModuleUpdate -> {
|
||||
onFinish(false, 0)
|
||||
}
|
||||
FlashIt.FlashRestore -> restoreBoot(onFinish, onStdout, onStderr)
|
||||
FlashIt.FlashUninstall -> uninstallPermanently(onFinish, onStdout, onStderr)
|
||||
|
||||
FlashIt.FlashRestore -> restoreBoot(onStdout, onStderr)
|
||||
|
||||
FlashIt.FlashUninstall -> uninstallPermanently(onStdout, onStderr)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun FlashScreenPreview() {
|
||||
FlashScreen(EmptyDestinationsNavigator, FlashIt.FlashUninstall)
|
||||
}
|
||||
private fun TopBar(
|
||||
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
@@ -1,49 +1,47 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Process.myUid
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.saveable.rememberSaveable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
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.format.DateTimeFormatter
|
||||
import android.os.Process.myUid
|
||||
import androidx.core.content.edit
|
||||
|
||||
private val SPACING_SMALL = 4.dp
|
||||
private val SPACING_MEDIUM = 8.dp
|
||||
@@ -104,12 +102,9 @@ private fun loadExcludedSubTypes(context: Context): Set<LogExclType> {
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun LogViewerScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
fun LogViewer(navigator: DestinationsNavigator) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -141,10 +136,8 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
|
||||
entry.details.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.uid.contains(searchQuery, ignoreCase = true)
|
||||
|
||||
// 排除本应用
|
||||
if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false
|
||||
|
||||
// 排除 SYSCALL 子类型
|
||||
if (entry.type == LogType.SYSCALL) {
|
||||
val detail = entry.details
|
||||
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
|
||||
}
|
||||
|
||||
// 普通类型筛选
|
||||
val matchesFilter = filterType == null || entry.type == filterType
|
||||
matchesFilter && matchesSearch
|
||||
}
|
||||
}
|
||||
|
||||
val loadingDialog = rememberLoadingDialog()
|
||||
val confirmDialog = rememberConfirmDialog()
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val loadPage: (Int, Boolean) -> Unit = { page, forceRefresh ->
|
||||
scope.launch {
|
||||
@@ -216,39 +207,65 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
LogViewerTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
onBackClick = { navigator.navigateUp() },
|
||||
showSearchBar = showSearchBar,
|
||||
searchQuery = searchQuery,
|
||||
onSearchQueryChange = { searchQuery = it },
|
||||
onSearchToggle = { showSearchBar = !showSearchBar },
|
||||
onRefresh = onManualRefresh,
|
||||
onClearLogs = {
|
||||
scope.launch {
|
||||
val result = confirmDialog.awaitConfirm(
|
||||
title = context.getString(R.string.log_viewer_clear_logs),
|
||||
content = context.getString(R.string.log_viewer_clear_logs_confirm)
|
||||
TopAppBar(
|
||||
title = stringResource(R.string.log_viewer_title),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { navigator.navigateUp() },
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Back,
|
||||
contentDescription = stringResource(R.string.log_viewer_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
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 ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.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(
|
||||
filterType = filterType,
|
||||
onFilterTypeSelected = { filterType = it },
|
||||
@@ -264,7 +281,6 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
)
|
||||
|
||||
// 日志列表
|
||||
if (isLoading && logEntries.isEmpty()) {
|
||||
Box(
|
||||
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
|
||||
private fun LogControlPanel(
|
||||
filterType: LogType?,
|
||||
@@ -305,31 +384,17 @@ private fun LogControlPanel(
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
elevation = getCardElevation()
|
||||
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM)
|
||||
) {
|
||||
Column {
|
||||
// 标题栏(点击展开/收起)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { isExpanded = !isExpanded }
|
||||
.padding(SPACING_LARGE),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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
|
||||
)
|
||||
}
|
||||
SuperArrow(
|
||||
title = stringResource(R.string.log_viewer_settings),
|
||||
onClick = { isExpanded = !isExpanded },
|
||||
summary = if (isExpanded)
|
||||
stringResource(R.string.log_viewer_collapse)
|
||||
else
|
||||
stringResource(R.string.log_viewer_expand)
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
@@ -337,77 +402,80 @@ private fun LogControlPanel(
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = SPACING_LARGE)
|
||||
modifier = Modifier.padding(horizontal = SPACING_MEDIUM)
|
||||
) {
|
||||
// 类型过滤
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_filter_type),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
style = MiuixTheme.textStyles.subtitle,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) {
|
||||
Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_MEDIUM))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_MEDIUM)) {
|
||||
item {
|
||||
FilterChip(
|
||||
onClick = { onFilterTypeSelected(null) },
|
||||
label = { Text(stringResource(R.string.log_viewer_all_types)) },
|
||||
selected = filterType == null
|
||||
text = stringResource(R.string.log_viewer_all_types),
|
||||
selected = filterType == null,
|
||||
onClick = { onFilterTypeSelected(null) }
|
||||
)
|
||||
}
|
||||
items(LogType.entries.toTypedArray()) { type ->
|
||||
FilterChip(
|
||||
onClick = { onFilterTypeSelected(if (filterType == type) null else type) },
|
||||
label = { Text(type.displayName) },
|
||||
selected = filterType == type,
|
||||
leadingIcon = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(type.color, RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.background(type.color, RoundedCornerShape(3.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 = stringResource(R.string.log_viewer_exclude_subtypes),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
style = MiuixTheme.textStyles.subtitle,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) {
|
||||
Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_MEDIUM))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_MEDIUM)) {
|
||||
items(LogExclType.entries.toTypedArray()) { excl ->
|
||||
val label = if (excl == LogExclType.CURRENT_APP)
|
||||
stringResource(R.string.log_viewer_exclude_current_app)
|
||||
else excl.displayName
|
||||
|
||||
FilterChip(
|
||||
onClick = { onExcludeToggle(excl) },
|
||||
label = { Text(label) },
|
||||
selected = excl in excludedSubTypes,
|
||||
leadingIcon = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(excl.color, RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.background(excl.color, RoundedCornerShape(3.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(SPACING_SMALL)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_SMALL)) {
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
if (pageInfo.totalPages > 0) {
|
||||
Text(
|
||||
@@ -417,20 +485,20 @@ private fun LogControlPanel(
|
||||
pageInfo.totalPages,
|
||||
pageInfo.totalLogs
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) {
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
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,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||
verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)
|
||||
verticalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
|
||||
) {
|
||||
items(entries) { entry ->
|
||||
LogEntryCard(entry = entry)
|
||||
}
|
||||
|
||||
// 加载更多按钮或加载指示器
|
||||
if (pageInfo.hasMore) {
|
||||
item {
|
||||
Box(
|
||||
@@ -475,12 +542,6 @@ private fun LogList(
|
||||
onClick = onLoadMore,
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -496,8 +557,8 @@ private fun LogList(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_all_logs_loaded),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -510,14 +571,11 @@ private fun LogEntryCard(entry: LogEntry) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { expanded = !expanded }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(SPACING_MEDIUM)
|
||||
modifier = Modifier.padding(SPACING_LARGE)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -535,14 +593,14 @@ private fun LogEntryCard(entry: LogEntry) {
|
||||
)
|
||||
Text(
|
||||
text = entry.type.displayName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
style = MiuixTheme.textStyles.subtitle,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = entry.timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
|
||||
@@ -554,19 +612,19 @@ private fun LogEntryCard(entry: LogEntry) {
|
||||
) {
|
||||
Text(
|
||||
text = "UID: ${entry.uid}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
Text(
|
||||
text = "PID: ${entry.pid}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = entry.comm,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -576,8 +634,8 @@ private fun LogEntryCard(entry: LogEntry) {
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
Text(
|
||||
text = entry.details,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -590,19 +648,19 @@ private fun LogEntryCard(entry: LogEntry) {
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_raw_log),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
style = MiuixTheme.textStyles.subtitle,
|
||||
color = MiuixTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
Text(
|
||||
text = entry.rawLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -623,141 +681,30 @@ private fun EmptyLogState(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
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 = stringResource(
|
||||
if (hasLogs) R.string.log_viewer_no_matching_logs
|
||||
else R.string.log_viewer_no_logs
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
style = MiuixTheme.textStyles.headline2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
Button(onClick = onRefresh) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Button(
|
||||
onClick = onRefresh
|
||||
) {
|
||||
Text(stringResource(R.string.log_viewer_refresh))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@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 {
|
||||
private suspend fun checkForNewLogs(lastHash: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val shell = getRootShell()
|
||||
val logPath = "/data/adb/ksu/log/sulog.log"
|
||||
|
||||
val result = runCmd(shell, "stat -c '%Y %s' $logPath 2>/dev/null || echo '0 0'")
|
||||
val currentHash = result.trim()
|
||||
|
||||
currentHash != lastHash && currentHash != "0 0"
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
@@ -774,8 +721,6 @@ private suspend fun loadLogsWithPagination(
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val shell = getRootShell()
|
||||
|
||||
// 获取文件信息
|
||||
val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'")
|
||||
val currentHash = statResult.trim()
|
||||
|
||||
@@ -786,7 +731,6 @@ private suspend fun loadLogsWithPagination(
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// 获取总行数
|
||||
val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'")
|
||||
val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0
|
||||
|
||||
@@ -797,11 +741,9 @@ private suspend fun loadLogsWithPagination(
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// 限制最大日志数量
|
||||
val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS)
|
||||
val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE
|
||||
|
||||
// 计算要读取的行数范围
|
||||
val startLine = if (page == 0) {
|
||||
maxOf(1, totalLines - effectiveTotal + 1)
|
||||
} else {
|
||||
@@ -860,6 +802,7 @@ private fun parseLogEntries(logContent: String): List<LogEntry> {
|
||||
|
||||
return entries.reversed()
|
||||
}
|
||||
|
||||
private fun utcToLocal(utc: String): String {
|
||||
return try {
|
||||
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? {
|
||||
// 解析格式: [timestamp] TYPE: UID=xxx COMM=xxx ...
|
||||
val timestampRegex = """\[(.*?)]""".toRegex()
|
||||
val timestampMatch = timestampRegex.find(line) ?: return null
|
||||
val timestamp = utcToLocal(timestampMatch.groupValues[1])
|
||||
@@ -895,7 +837,6 @@ private fun parseLogLine(line: String): LogEntry? {
|
||||
val comm: String = extractValue(details, "COMM") ?: ""
|
||||
val pid: String = extractValue(details, "PID") ?: ""
|
||||
|
||||
// 构建详细信息字符串
|
||||
val detailsStr = when (type) {
|
||||
LogType.SU_GRANT -> {
|
||||
val method: String = extractValue(details, "METHOD") ?: ""
|
||||
@@ -938,4 +879,23 @@ private fun parseLogLine(line: String): LogEntry? {
|
||||
private fun extractValue(text: String, key: String): String? {
|
||||
val regex = """$key=(\S+)""".toRegex()
|
||||
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
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,65 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
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.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.icons.outlined.Fingerprint
|
||||
import androidx.compose.material.icons.outlined.Group
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.geometry.Offset
|
||||
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.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
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.sp
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.compose.dropUnlessResumed
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.result.ResultRecipient
|
||||
import com.ramcosta.composedestinations.result.getOr
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
||||
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 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
|
||||
* @date 2023/10/20.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun AppProfileTemplateScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean>
|
||||
) {
|
||||
val viewModel = viewModel<TemplateViewModel>()
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
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(
|
||||
topBar = {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = context.getSystemService<ClipboardManager>()
|
||||
val showToast = fun(msg: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
@@ -85,20 +183,20 @@ fun AppProfileTemplateScreen(
|
||||
scope.launch { viewModel.fetchTemplates(true) }
|
||||
},
|
||||
onImport = {
|
||||
scope.launch {
|
||||
val clipboardText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString()
|
||||
if (clipboardText.isNullOrEmpty()) {
|
||||
clipboardManager.getText()?.text?.let {
|
||||
if (it.isEmpty()) {
|
||||
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 = {
|
||||
@@ -107,176 +205,300 @@ fun AppProfileTemplateScreen(
|
||||
{
|
||||
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 = {
|
||||
ExtendedFloatingActionButton(
|
||||
FloatingActionButton(
|
||||
containerColor = colorScheme.primary,
|
||||
shadowElevation = 0.dp,
|
||||
onClick = {
|
||||
navigator.navigate(
|
||||
TemplateEditorScreenDestination(
|
||||
TemplateViewModel.TemplateInfo(),
|
||||
false
|
||||
)
|
||||
navigator.navigate(TemplateEditorScreenDestination(TemplateViewModel.TemplateInfo(), false)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
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 ->
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
isRefreshing = viewModel.isRefreshing,
|
||||
onRefresh = {
|
||||
scope.launch { viewModel.fetchTemplates() }
|
||||
var isRefreshing by rememberSaveable { mutableStateOf(false) }
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
contentPadding = remember {
|
||||
PaddingValues(bottom = 16.dp + 56.dp + 16.dp /* Scaffold Fab Spacing + Fab container height */)
|
||||
}
|
||||
.height(getWindowSize().height.dp)
|
||||
.scrollEndHaptic()
|
||||
.overScrollVertical()
|
||||
.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 ->
|
||||
TemplateItem(navigator, app)
|
||||
}
|
||||
item {
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TemplateItem(
|
||||
navigator: DestinationsNavigator,
|
||||
template: TemplateViewModel.TemplateInfo
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
navigator.navigate(TemplateEditorScreenDestination(template, !template.local))
|
||||
},
|
||||
headlineContent = { Text(template.name) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Card(
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onClick = {
|
||||
navigator.navigate(TemplateEditorScreenDestination(template, !template.local)) {
|
||||
popUpTo(TemplateEditorScreenDestination) {
|
||||
inclusive = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
showIndication = true,
|
||||
pressFeedbackType = PressFeedbackType.Sink
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "${template.id}${if (template.author.isEmpty()) "" else "@${template.author}"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
text = template.name,
|
||||
fontWeight = FontWeight(550),
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
Text(template.description)
|
||||
FlowRow {
|
||||
LabelText(label = "UID: ${template.uid}")
|
||||
LabelText(label = "GID: ${template.gid}")
|
||||
LabelText(label = template.context)
|
||||
if (template.local) {
|
||||
LabelText(label = "local")
|
||||
} else {
|
||||
LabelText(label = "remote")
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (template.local) {
|
||||
Text(
|
||||
text = "LOCAL",
|
||||
color = colorScheme.onTertiaryContainer,
|
||||
fontWeight = FontWeight(750),
|
||||
style = MiuixTheme.textStyles.footnote1
|
||||
)
|
||||
} 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
|
||||
private fun TopBar(
|
||||
onBack: () -> Unit,
|
||||
onSync: () -> Unit = {},
|
||||
onImport: () -> 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(
|
||||
title = {
|
||||
Text(stringResource(R.string.settings_profile_template))
|
||||
modifier = Modifier.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
color = Color.Transparent,
|
||||
title = stringResource(R.string.settings_profile_template),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSync) {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = onSync
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Sync,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_sync)
|
||||
imageVector = MiuixIcons.Useful.Refresh,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_sync),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = {
|
||||
showDropdown = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ImportExport,
|
||||
contentDescription = stringResource(id = R.string.app_profile_import_export)
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
||||
showDropdown = false
|
||||
}) {
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id = R.string.app_profile_import_from_clipboard))
|
||||
}, onClick = {
|
||||
onImport()
|
||||
showDropdown = false
|
||||
})
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id = R.string.app_profile_export_to_clipboard))
|
||||
}, onClick = {
|
||||
onExport()
|
||||
showDropdown = false
|
||||
})
|
||||
val showTopPopup = remember { mutableStateOf(false) }
|
||||
ListPopup(
|
||||
show = showTopPopup,
|
||||
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||
alignment = PopupPositionProvider.Align.TopRight,
|
||||
onDismissRequest = {
|
||||
showTopPopup.value = false
|
||||
}
|
||||
) {
|
||||
ListPopupColumn {
|
||||
val items = listOf(
|
||||
stringResource(id = R.string.app_profile_import_from_clipboard),
|
||||
stringResource(id = R.string.app_profile_export_to_clipboard)
|
||||
)
|
||||
items.forEachIndexed { index, text ->
|
||||
DropdownItem(
|
||||
text = text,
|
||||
optionSize = items.size,
|
||||
index = index,
|
||||
onSelectedIndexChange = { selectedIndex ->
|
||||
if (selectedIndex == 0) {
|
||||
onImport()
|
||||
} 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
|
||||
)
|
||||
}
|
||||
|
||||
@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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,47 +2,75 @@ package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
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.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.dropUnlessResumed
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
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.R
|
||||
import com.sukisu.ultra.ui.component.EditText
|
||||
import com.sukisu.ultra.ui.component.profile.RootProfileConfig
|
||||
import com.sukisu.ultra.ui.util.deleteAppProfileTemplate
|
||||
import com.sukisu.ultra.ui.util.getAppProfileTemplate
|
||||
import com.sukisu.ultra.ui.util.setAppProfileTemplate
|
||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
||||
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
|
||||
* @date 2023/10/20.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun TemplateEditorScreen(
|
||||
navigator: ResultBackNavigator<Boolean>,
|
||||
initialTemplate: TemplateViewModel.TemplateInfo,
|
||||
@@ -56,7 +84,12 @@ fun TemplateEditorScreen(
|
||||
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 {
|
||||
navigator.navigateBack(result = !readOnly)
|
||||
@@ -64,15 +97,9 @@ fun TemplateEditorScreen(
|
||||
|
||||
Scaffold(
|
||||
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 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
|
||||
|
||||
TopBar(
|
||||
@@ -84,7 +111,7 @@ fun TemplateEditorScreen(
|
||||
stringResource(R.string.app_profile_template_edit)
|
||||
},
|
||||
readOnly = readOnly,
|
||||
summary = titleSummary,
|
||||
isCreation = isCreation,
|
||||
onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) },
|
||||
onDelete = {
|
||||
if (deleteAppProfileTemplate(template.id)) {
|
||||
@@ -92,106 +119,146 @@ fun TemplateEditorScreen(
|
||||
}
|
||||
},
|
||||
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)) {
|
||||
navigator.navigateBack(result = true)
|
||||
} else {
|
||||
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 ->
|
||||
Column(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.height(getWindowSize().height.dp)
|
||||
.scrollEndHaptic()
|
||||
.overScrollVertical()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.hazeSource(state = hazeState)
|
||||
.pointerInteropFilter {
|
||||
// disable click and ripple if readOnly
|
||||
readOnly
|
||||
}
|
||||
},
|
||||
contentPadding = innerPadding,
|
||||
overscrollEffect = null
|
||||
) {
|
||||
if (isCreation) {
|
||||
var errorHint by remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
|
||||
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
|
||||
TextEdit(
|
||||
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)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
var errorHint by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_name),
|
||||
text = template.name
|
||||
) { value ->
|
||||
template.copy(name = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_name),
|
||||
text = template.name
|
||||
) { value ->
|
||||
template.copy(name = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_description),
|
||||
text = template.description
|
||||
) { value ->
|
||||
template.copy(description = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_id),
|
||||
text = template.id,
|
||||
isError = errorHint
|
||||
) { value ->
|
||||
errorHint = value.isNotEmpty() && (isTemplateExist(value) || !isValidTemplateId(value))
|
||||
template = template.copy(id = value)
|
||||
}
|
||||
TextEdit(
|
||||
label = stringResource(R.string.module_author),
|
||||
text = template.author
|
||||
) { value ->
|
||||
template.copy(author = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_description),
|
||||
text = template.description
|
||||
) { value ->
|
||||
template.copy(description = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return Natives.Profile().copy(rootTemplate = templateInfo.id,
|
||||
return Natives.Profile().copy(
|
||||
rootTemplate = templateInfo.id,
|
||||
uid = templateInfo.uid,
|
||||
gid = templateInfo.gid,
|
||||
groups = templateInfo.groups,
|
||||
@@ -213,6 +280,10 @@ fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean {
|
||||
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 {
|
||||
if (!isTemplateValid(template)) {
|
||||
return false
|
||||
@@ -227,50 +298,67 @@ fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean =
|
||||
return setAppProfileTemplate(template.id, json.toString())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
title: String,
|
||||
readOnly: Boolean,
|
||||
summary: String = "",
|
||||
isCreation: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onDelete: () -> Unit = {},
|
||||
onSave: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
scrollBehavior: ScrollBehavior,
|
||||
hazeState: HazeState,
|
||||
hazeStyle: HazeStyle,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(title)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}, navigationIcon = {
|
||||
modifier = Modifier.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
},
|
||||
color = Color.Transparent,
|
||||
title = title,
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
}, actions = {
|
||||
if (readOnly) {
|
||||
return@TopAppBar
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.DeleteForever,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_delete)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onSave) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_save)
|
||||
imageVector = MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -279,35 +367,22 @@ private fun TopBar(
|
||||
private fun TextEdit(
|
||||
label: String,
|
||||
text: String,
|
||||
errorHint: String = "",
|
||||
isError: Boolean = false,
|
||||
onValueChange: (String) -> Unit = {}
|
||||
) {
|
||||
ListItem(headlineContent = {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
suffix = {
|
||||
if (errorHint.isNotBlank()) {
|
||||
Text(
|
||||
text = if (isError) errorHint else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
}),
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
})
|
||||
val editText = remember { mutableStateOf(text) }
|
||||
EditText(
|
||||
title = label.uppercase(),
|
||||
textValue = editText,
|
||||
onTextValueChange = { newText ->
|
||||
editText.value = newText
|
||||
onValueChange(newText)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
isError = isError,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isValidTemplateId(id: String): Boolean {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.material.icons.outlined.Folder
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
@@ -23,13 +24,30 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
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_MEDIUM = 8.dp
|
||||
@@ -37,17 +55,13 @@ private val SPACING_LARGE = 16.dp
|
||||
|
||||
data class UmountPathEntry(
|
||||
val path: String,
|
||||
val checkMnt: Boolean,
|
||||
val flags: Int,
|
||||
val isDefault: Boolean
|
||||
val flags: Int
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
fun UmountManager(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val confirmDialog = rememberConfirmDialog()
|
||||
@@ -75,59 +89,64 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.umount_path_manager)) },
|
||||
title = stringResource(R.string.umount_path_manager),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Back,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { loadPaths() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Refresh,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(
|
||||
alpha = CardConfig.cardAlpha
|
||||
)
|
||||
)
|
||||
color = Color.Transparent,
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { showAddDialog = true }
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.height(getWindowSize().height.dp)
|
||||
.scrollEndHaptic()
|
||||
.overScrollVertical()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(SPACING_LARGE),
|
||||
colors = getCardColors(MaterialTheme.colorScheme.primaryContainer),
|
||||
elevation = getCardElevation()
|
||||
.padding(SPACING_LARGE)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(SPACING_LARGE)
|
||||
Row(
|
||||
modifier = Modifier.padding(SPACING_LARGE),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Text(
|
||||
text = stringResource(R.string.umount_path_restart_notice),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
text = stringResource(R.string.umount_path_restart_notice)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,7 +156,7 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
top.yukonga.miuix.kmp.basic.CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
@@ -153,14 +172,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
val success = removeUmountPath(entry.path)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.umount_path_removed)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.umount_path_removed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
loadPaths()
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.operation_failed)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.operation_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,14 +213,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
val success = clearCustomUmountPaths()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.custom_paths_cleared)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.custom_paths_cleared),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
loadPaths()
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.operation_failed)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.operation_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,9 +233,7 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.DeleteForever, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Text(stringResource(R.string.clear_custom_paths))
|
||||
Text(text = stringResource(R.string.clear_custom_paths))
|
||||
}
|
||||
|
||||
Button(
|
||||
@@ -217,22 +242,24 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
val success = applyUmountConfigToKernel()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.config_applied)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.config_applied),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.operation_failed)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.operation_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Text(stringResource(R.string.apply_config))
|
||||
Text(text = stringResource(R.string.apply_config))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,22 +270,26 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
if (showAddDialog) {
|
||||
AddUmountPathDialog(
|
||||
onDismiss = { showAddDialog = false },
|
||||
onConfirm = { path, checkMnt, flags ->
|
||||
onConfirm = { path, flags ->
|
||||
showAddDialog = false
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val success = addUmountPath(path, checkMnt, flags)
|
||||
val success = addUmountPath(path, flags)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
saveUmountConfig()
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.umount_path_added)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.umount_path_added),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
loadPaths()
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.operation_failed)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.operation_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,9 +309,7 @@ fun UmountPathCard(
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
elevation = getCardElevation()
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -289,12 +318,9 @@ fun UmountPathCard(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
imageVector = Icons.Outlined.Folder,
|
||||
contentDescription = null,
|
||||
tint = if (entry.isDefault)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.secondary,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
@@ -302,48 +328,36 @@ fun UmountPathCard(
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.path,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
text = entry.path
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
Text(
|
||||
text = buildString {
|
||||
append(context.getString(R.string.check_mount_type))
|
||||
append(": ")
|
||||
append(if (entry.checkMnt) context.getString(R.string.yes) else context.getString(R.string.no))
|
||||
append(" | ")
|
||||
append(context.getString(R.string.flags))
|
||||
append(": ")
|
||||
append(entry.flags.toUmountFlagName(context))
|
||||
if (entry.isDefault) {
|
||||
append(" | ")
|
||||
append(context.getString(R.string.default_entry))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
|
||||
if (!entry.isDefault) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (confirmDialog.awaitConfirm(
|
||||
title = context.getString(R.string.confirm_delete),
|
||||
content = context.getString(R.string.confirm_delete_umount_path, entry.path)
|
||||
) == ConfirmResult.Confirmed) {
|
||||
onDelete()
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (confirmDialog.awaitConfirm(
|
||||
title = context.getString(R.string.confirm_delete),
|
||||
content = context.getString(R.string.confirm_delete_umount_path, entry.path)
|
||||
) == ConfirmResult.Confirmed) {
|
||||
onDelete()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Delete,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,69 +366,73 @@ fun UmountPathCard(
|
||||
@Composable
|
||||
fun AddUmountPathDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String, Boolean, Int) -> Unit
|
||||
onConfirm: (String, Int) -> Unit
|
||||
) {
|
||||
var path by rememberSaveable { mutableStateOf("") }
|
||||
var checkMnt by rememberSaveable { mutableStateOf(false) }
|
||||
var flags by rememberSaveable { mutableStateOf("-1") }
|
||||
val showDialog = remember { mutableStateOf(true) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.add_umount_path)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = path,
|
||||
onValueChange = { path = it },
|
||||
label = { Text(stringResource(R.string.mount_path)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = stringResource(R.string.add_umount_path),
|
||||
onDismissRequest = {
|
||||
showDialog.value = false
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = path,
|
||||
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))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checkMnt,
|
||||
onCheckedChange = { checkMnt = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(SPACING_SMALL))
|
||||
Text(stringResource(R.string.check_mount_type_overlay))
|
||||
}
|
||||
TextField(
|
||||
value = flags,
|
||||
onValueChange = { flags = it },
|
||||
label = stringResource(R.string.umount_flags),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
|
||||
OutlinedTextField(
|
||||
value = flags,
|
||||
onValueChange = { flags = it },
|
||||
label = { Text(stringResource(R.string.umount_flags)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = { Text(stringResource(R.string.umount_flags_hint)) }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Text(
|
||||
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(
|
||||
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 = {
|
||||
val flagsInt = flags.toIntOrNull() ?: -1
|
||||
onConfirm(path, checkMnt, flagsInt)
|
||||
showDialog.value = false
|
||||
onConfirm(path, flagsInt)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
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> {
|
||||
@@ -423,18 +441,16 @@ private fun parseUmountPaths(output: String): List<UmountPathEntry> {
|
||||
|
||||
return lines.drop(2).mapNotNull { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
if (parts.size >= 4) {
|
||||
if (parts.size >= 2) {
|
||||
UmountPathEntry(
|
||||
path = parts[0],
|
||||
checkMnt = parts[1].equals("true", ignoreCase = true),
|
||||
flags = parts[2].toIntOrNull() ?: -1,
|
||||
isDefault = parts[3].equals("Yes", ignoreCase = true)
|
||||
flags = parts[1].toIntOrNull() ?: -1
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toUmountFlagName(context: android.content.Context): String {
|
||||
private fun Int.toUmountFlagName(context: Context): String {
|
||||
return when (this) {
|
||||
-1 -> context.getString(R.string.mnt_detach)
|
||||
else -> this.toString()
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user