Compare commits
71 Commits
ksud-susfs
...
miuix-dev
| 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 |
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -106,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;
|
||||
}
|
||||
@@ -115,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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -92,7 +93,7 @@ void on_post_fs_data(void)
|
||||
}
|
||||
|
||||
extern void ext4_unregister_sysfs(struct super_block *sb);
|
||||
int nuke_ext4_sysfs(const char* mnt)
|
||||
int nuke_ext4_sysfs(const char *mnt)
|
||||
{
|
||||
#ifdef CONFIG_EXT4_FS
|
||||
struct path path;
|
||||
@@ -117,12 +118,14 @@ int nuke_ext4_sysfs(const char* mnt)
|
||||
#endif
|
||||
}
|
||||
|
||||
void on_module_mounted(void){
|
||||
void on_module_mounted(void)
|
||||
{
|
||||
pr_info("on_module_mounted!\n");
|
||||
ksu_module_mounted = true;
|
||||
}
|
||||
|
||||
void on_boot_completed(void){
|
||||
void on_boot_completed(void)
|
||||
{
|
||||
ksu_boot_completed = true;
|
||||
pr_info("on_boot_completed!\n");
|
||||
track_throne(true);
|
||||
@@ -527,12 +530,25 @@ static int sys_execve_handler_pre(struct kprobe *p, struct pt_regs *regs)
|
||||
struct user_arg_ptr argv = { .ptr.native = __argv };
|
||||
struct 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;
|
||||
|
||||
@@ -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,7 +18,7 @@
|
||||
#include "ksud.h"
|
||||
#include "sucompat.h"
|
||||
#include "app_profile.h"
|
||||
#include "syscall_hook_manager.h"
|
||||
#include "util.h"
|
||||
|
||||
#include "sulog.h"
|
||||
|
||||
@@ -107,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)))) {
|
||||
@@ -130,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;
|
||||
}
|
||||
@@ -140,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);
|
||||
@@ -165,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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
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)
|
||||
@@ -33,35 +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;
|
||||
int flags;
|
||||
} defaults[] = {
|
||||
{ "/odm", 0 },
|
||||
{ "/system", 0 },
|
||||
{ "/vendor", 0 },
|
||||
{ "/product", 0 },
|
||||
{ "/system_ext", 0 },
|
||||
{ "/data/adb/modules", MNT_DETACH },
|
||||
{ "/debug_ramdisk", MNT_DETACH },
|
||||
};
|
||||
|
||||
for (int i = 0; i < ARRAY_SIZE(defaults); i++) {
|
||||
ret = ksu_umount_manager_add(defaults[i].path,
|
||||
defaults[i].flags,
|
||||
true); // is_default = true
|
||||
if (ret) {
|
||||
pr_err("Failed to add default entry: %s, ret=%d\n",
|
||||
defaults[i].path, ret);
|
||||
return ret;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -70,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)
|
||||
@@ -104,6 +192,11 @@ int ksu_umount_manager_add(const char *path, int flags, bool is_default)
|
||||
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) {
|
||||
@@ -216,37 +309,23 @@ void ksu_umount_manager_execute_all(const struct cred *cred)
|
||||
|
||||
int ksu_umount_manager_get_entries(struct ksu_umount_entry_info __user *entries, u32 *count)
|
||||
{
|
||||
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);
|
||||
|
||||
list_for_each_entry(entry, &g_umount_mgr.entry_list, list) {
|
||||
if (idx >= max_count) {
|
||||
break;
|
||||
ret = collect_mount_list_entries(entries, max_count, &idx);
|
||||
if (ret) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
memset(&info, 0, sizeof(info));
|
||||
strncpy(info.path, entry->path, sizeof(info.path) - 1);
|
||||
info.flags = entry->flags;
|
||||
info.is_default = entry->is_default;
|
||||
info.state = entry->state;
|
||||
info.ref_count = entry->ref_count;
|
||||
|
||||
if (copy_to_user(&entries[idx], &info, sizeof(info))) {
|
||||
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
|
||||
return -EFAULT;
|
||||
if (idx < max_count) {
|
||||
ret = collect_umount_manager_entries(entries, idx, max_count, &idx);
|
||||
if (ret) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
|
||||
*count = idx;
|
||||
|
||||
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
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})
|
||||
|
||||
@@ -335,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 };
|
||||
@@ -419,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) {
|
||||
@@ -438,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;
|
||||
|
||||
@@ -257,14 +259,6 @@ void get_full_version(char* buff) {
|
||||
}
|
||||
}
|
||||
|
||||
bool is_KPM_enable(void) {
|
||||
struct ksu_enable_kpm_cmd cmd = {};
|
||||
if (ksuctl(KSU_IOCTL_ENABLE_KPM, &cmd) == 0 && cmd.enabled) {
|
||||
return true;
|
||||
}
|
||||
return legacy_is_KPM_enable();
|
||||
}
|
||||
|
||||
void get_hook_type(char *buff) {
|
||||
struct ksu_hook_type_cmd cmd = {0};
|
||||
if (ksuctl(KSU_IOCTL_HOOK_TYPE, &cmd) == 0) {
|
||||
@@ -348,6 +342,7 @@ bool clear_uid_scanner_environment(void)
|
||||
return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd);
|
||||
}
|
||||
|
||||
#if 0
|
||||
bool verify_module_signature(const char* input) {
|
||||
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
||||
if (input == NULL) {
|
||||
@@ -404,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();
|
||||
|
||||
@@ -227,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,6 +108,11 @@ object Natives {
|
||||
external fun isEnhancedSecurityEnabled(): Boolean
|
||||
external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean
|
||||
|
||||
/**
|
||||
* Get the user name for the uid.
|
||||
*/
|
||||
external fun getUserName(uid: Int): String?
|
||||
|
||||
/**
|
||||
* Su Log can be enabled/disabled.
|
||||
* 0: disabled
|
||||
@@ -126,15 +121,8 @@ object Natives {
|
||||
*/
|
||||
external fun isSuLogEnabled(): Boolean
|
||||
external fun setSuLogEnabled(enabled: Boolean): Boolean
|
||||
|
||||
external fun isKPMEnabled(): Boolean
|
||||
external fun getHookType(): String
|
||||
|
||||
/**
|
||||
* Get SUSFS feature status from kernel
|
||||
* @return SusfsFeatureStatus object containing all feature states, or null if failed
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set dynamic managerature configuration
|
||||
* @param size APK signature size
|
||||
@@ -162,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
|
||||
@@ -185,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
|
||||
|
||||
@@ -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 }
|
||||
companion object {
|
||||
private const val TAG = "KsuService"
|
||||
}
|
||||
|
||||
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
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return Stub()
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder = Stub()
|
||||
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
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateApi")
|
||||
private fun getInstalledPackagesAsUser(userId: Int): List<PackageInfo> {
|
||||
return try {
|
||||
val pm = packageManager
|
||||
val m = pm.javaClass.getDeclaredMethod(
|
||||
"getInstalledPackagesAsUser",
|
||||
Int::class.java,
|
||||
Int::class.java
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
m.invoke(pm, 0, userId) as List<PackageInfo>
|
||||
private fun getInstalledPackagesAsUser(flags: Int, userId: Int): List<PackageInfo> {
|
||||
return try {
|
||||
val pm: PackageManager = packageManager
|
||||
val method = pm.javaClass.getDeclaredMethod(
|
||||
"getInstalledPackagesAsUser",
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
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()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = 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) }
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isManager && !Natives.requireNewKernel()) install()
|
||||
|
||||
setContent {
|
||||
KernelSUTheme {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
KernelSUTheme(colorMode = colorMode, keyColor = keyColor) {
|
||||
val navController = rememberNavController()
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
|
||||
|
||||
val bottomBarRoutes = remember {
|
||||
BottomBarDestination.entries.map { it.direction.route }.toSet()
|
||||
}
|
||||
|
||||
val navigator = navController.rememberDestinationsNavigator()
|
||||
val initialIntent = remember { intent }
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
initPlatform()
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarHost provides snackBarHostState
|
||||
) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
AnimatedBottomBar.AnimatedBottomBarWrapper(
|
||||
showBottomBar = showBottomBar,
|
||||
content = { BottomBar(navController) }
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0)
|
||||
) { innerPadding ->
|
||||
Scaffold {
|
||||
DestinationsNavHost(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
navGraph = NavGraphs.root as NavHostGraphSpec,
|
||||
modifier = Modifier,
|
||||
navGraph = NavGraphs.root,
|
||||
navController = navController,
|
||||
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
|
||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
|
||||
// If the target is a detail page (not a bottom navigation page), slide in from the right
|
||||
if (targetState.destination.route !in bottomBarRoutes) {
|
||||
slideInHorizontally(initialOffsetX = { it })
|
||||
} else {
|
||||
// Otherwise (switching between bottom navigation pages), use fade in
|
||||
fadeIn(animationSpec = tween(340))
|
||||
}
|
||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition =
|
||||
{
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
|
||||
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 exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition =
|
||||
{
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -it / 5 },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
|
||||
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 popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition =
|
||||
{
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it / 5 },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition =
|
||||
{
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
HandleZipFileIntent(initialIntent, navigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val LocalPagerState = compositionLocalOf<PagerState> { error("No pager state") }
|
||||
val LocalHandlePageChange = compositionLocalOf<(Int) -> Unit> { error("No handle page change") }
|
||||
|
||||
@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) }
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
if (pagerState.currentPage != 0) {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(0)
|
||||
}
|
||||
} else {
|
||||
activity?.finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
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
|
||||
},
|
||||
title = {
|
||||
Text(text = visuals.title)
|
||||
},
|
||||
text = {
|
||||
content = {
|
||||
Layout(
|
||||
content = {
|
||||
visuals.content?.let {
|
||||
if (visuals.isMarkdown) {
|
||||
MarkdownContent(content = visuals.content)
|
||||
MarkdownContent(content = visuals.content!!)
|
||||
} else {
|
||||
Text(text = visuals.content)
|
||||
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)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.padding(top = 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
|
||||
}
|
||||
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
text = visuals.dismiss ?: stringResource(id = android.R.string.cancel),
|
||||
onClick = {
|
||||
dismiss()
|
||||
showDialog.value = false
|
||||
},
|
||||
update = {
|
||||
Markwon.create(it.context).setMarkdown(it, content)
|
||||
it.setTextColor(contentColor.toArgb())
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -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 }
|
||||
SuperEditArrow(
|
||||
title = "UID",
|
||||
defaultValue = profile.uid,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.profile_namespace)) },
|
||||
value = currentNamespace,
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
||||
},
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_inherited)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_global)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_individual)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
*/
|
||||
|
||||
UidPanel(uid = profile.uid, label = "uid", onUidChange = {
|
||||
onProfileChange(
|
||||
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)
|
||||
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(
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
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)
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
|
||||
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) })
|
||||
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(
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
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)
|
||||
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)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
|
||||
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) })
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SELinuxPanel(
|
||||
profile: Natives.Profile,
|
||||
onSELinuxChange: (domain: String, rules: String) -> Unit
|
||||
) {
|
||||
val editSELinuxDialog = rememberCustomDialog { dismiss ->
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
var domain by remember { mutableStateOf(profile.context) }
|
||||
var rules by remember { mutableStateOf(profile.rules) }
|
||||
|
||||
val inputOptions = listOf(
|
||||
InputTextField(
|
||||
text = domain,
|
||||
header = InputHeader(
|
||||
title = stringResource(id = R.string.profile_selinux_domain),
|
||||
),
|
||||
type = InputTextFieldType.OUTLINED,
|
||||
required = true,
|
||||
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) }
|
||||
|
||||
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
|
||||
),
|
||||
resultListener = {
|
||||
domain = it ?: ""
|
||||
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)
|
||||
},
|
||||
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!")
|
||||
singleLine = false
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
InputDialog(
|
||||
state = rememberUseCaseState(
|
||||
visible = true,
|
||||
onFinishedRequest = {
|
||||
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
|
||||
},
|
||||
onCloseRequest = {
|
||||
dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
text = stringResource(R.string.confirm),
|
||||
enabled = isDomainValid && isRulesValid,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SuperArrow(
|
||||
title = stringResource(R.string.profile_selinux_context),
|
||||
),
|
||||
selection = InputSelection(
|
||||
input = inputOptions,
|
||||
onPositiveClick = { result ->
|
||||
// Handle selection
|
||||
},
|
||||
summary = profile.context,
|
||||
onClick = { showDialog.value = true }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,83 +1,74 @@
|
||||
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)
|
||||
},
|
||||
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
|
||||
)
|
||||
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"))) {
|
||||
},
|
||||
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 = tid,
|
||||
rootTemplate = template,
|
||||
rootUseDefault = false,
|
||||
uid = templateInfo.uid,
|
||||
gid = templateInfo.gid,
|
||||
@@ -88,18 +79,16 @@ fun TemplateConfig(
|
||||
)
|
||||
)
|
||||
}
|
||||
expanded = false
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
onViewTemplate(tid)
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ReadMore, null)
|
||||
}
|
||||
}
|
||||
onClick = {
|
||||
expanded = !expanded
|
||||
},
|
||||
maxHeight = 280.dp
|
||||
)
|
||||
SuperArrow(
|
||||
title = stringResource(R.string.app_profile_template_view),
|
||||
onClick = { onViewTemplate(template) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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秒后退出
|
||||
if (shouldAutoExit) {
|
||||
scope.launch {
|
||||
// 如果是从外部打开的内核刷写,延迟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
|
||||
|
||||
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 = {
|
||||
modifier = Modifier.padding(bottom = 20.dp, end = 20.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
Icons.Rounded.Refresh,
|
||||
contentDescription = stringResource(id = R.string.reboot)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(id = R.string.reboot))
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
expanded = true
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
||||
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(
|
||||
SmallTopAppBar(
|
||||
title = 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) {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = onSave) {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = onSave
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
imageVector = MiuixIcons.Useful.Save,
|
||||
contentDescription = stringResource(id = R.string.save_log),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
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...")
|
||||
|
||||
try {
|
||||
// 确保工作目录存在
|
||||
File(workDir).mkdirs()
|
||||
|
||||
// 并行下载两个工具文件
|
||||
val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) }
|
||||
val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
|
||||
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() }
|
||||
|
||||
// 等待所有下载完成
|
||||
results["kptools"] = kptoolsDeferred.await()
|
||||
results["kpimg"] = kpimgDeferred.await()
|
||||
|
||||
// 检查kptools执行权限
|
||||
val kptoolsFile = File(workDir, "kptools")
|
||||
if (kptoolsFile.exists()) {
|
||||
setExecutablePermission(kptoolsFile.absolutePath)
|
||||
// 设置 kptools 执行权限
|
||||
File(workDir, "kptools").takeIf { it.exists() }?.let { file ->
|
||||
setExecutablePermission(file.absolutePath)
|
||||
listener?.onLog("Set kptools execution permission")
|
||||
}
|
||||
|
||||
val successCount = results.values.count { it.success }
|
||||
val remoteCount = results.values.count { it.success && it.isRemoteSource }
|
||||
|
||||
listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception occurred while downloading tools", e)
|
||||
listener?.onLog("Exception occurred during tool download: ${e.message}")
|
||||
|
||||
if (!results.containsKey("kptools")) {
|
||||
results["kptools"] = downloadSingleTool("kptools", null, listener)
|
||||
}
|
||||
if (!results.containsKey("kpimg")) {
|
||||
results["kpimg"] = downloadSingleTool("kpimg", null, listener)
|
||||
}
|
||||
}
|
||||
|
||||
results.toMap()
|
||||
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"
|
||||
|
||||
}
|
||||
} 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败,回退到本地版本
|
||||
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()
|
||||
}
|
||||
|
||||
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"
|
||||
listener?.onError(fileName, errorMsg)
|
||||
return@withContext DownloadResult(false,
|
||||
isRemoteSource = false,
|
||||
errorMessage = errorMsg
|
||||
)
|
||||
if (!targetFile.exists() || !validateDownloadedFile(targetFile, fileName)) {
|
||||
val errorMsg = if (!targetFile.exists()) {
|
||||
"Local $fileName file extraction failed"
|
||||
} else {
|
||||
"Local $fileName file verification failed"
|
||||
}
|
||||
|
||||
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()
|
||||
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,12 +289,10 @@ class RemoteToolsDownloader(
|
||||
|
||||
fun cleanup() {
|
||||
try {
|
||||
File(workDir).listFiles()?.forEach { file ->
|
||||
if (file.name.endsWith(".tmp")) {
|
||||
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,17 +101,20 @@ 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())
|
||||
@@ -84,62 +123,86 @@ fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String
|
||||
"KernelSU_module_action_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!isActionRunning) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(text = stringResource(R.string.close)) },
|
||||
icon = { Icon(Icons.Filled.Close, contentDescription = null) },
|
||||
onClick = {
|
||||
navigator.popBackStack()
|
||||
}
|
||||
)
|
||||
Toast.makeText(context, "Log saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
||||
hazeState = hazeState,
|
||||
hazeStyle = hazeStyle,
|
||||
)
|
||||
},
|
||||
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
|
||||
)
|
||||
// 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)
|
||||
}
|
||||
|
||||
if (verifiedModule != null) {
|
||||
val updatedVerifiedModules = current.verifiedModules.toMutableList()
|
||||
updatedVerifiedModules.add(verifiedModule)
|
||||
moduleInstallStatus.value = moduleInstallStatus.value.copy(
|
||||
verifiedModules = updatedVerifiedModules
|
||||
)
|
||||
}
|
||||
}
|
||||
return FlashResult(0, "", true)
|
||||
}
|
||||
|
||||
fun setModuleVerificationStatus(uri: Uri, isVerified: Boolean) {
|
||||
moduleVerificationMap[uri] = isVerified
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val shouldAutoExit = remember {
|
||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
sharedPref.getBoolean("auto_exit_after_flash", false)
|
||||
}
|
||||
|
||||
// 是否通过从外部启动的模块安装
|
||||
val isExternalInstall = remember {
|
||||
when (flashIt) {
|
||||
is FlashIt.FlashModule -> {
|
||||
(context as? ComponentActivity)?.intent?.let { intent ->
|
||||
intent.action == Intent.ACTION_VIEW || intent.action == Intent.ACTION_SEND
|
||||
} ?: false
|
||||
}
|
||||
is FlashIt.FlashModules -> {
|
||||
(context as? ComponentActivity)?.intent?.let { intent ->
|
||||
intent.action == Intent.ACTION_VIEW || intent.action == Intent.ACTION_SEND
|
||||
} ?: false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
) {
|
||||
if (flashIt is FlashIt.FlashModules) {
|
||||
ModuleInstallProgressBar(
|
||||
currentIndex = flashIt.currentIndex + 1,
|
||||
totalCount = flashIt.uris.size,
|
||||
currentModuleName = currentStatus.currentModuleName,
|
||||
status = currentFlashingStatus.value,
|
||||
failedModules = currentStatus.failedModules
|
||||
.scrollEndHaptic()
|
||||
.padding(
|
||||
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
end = innerPadding.calculateStartPadding(layoutDirection),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(scrollState)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
LaunchedEffect(text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontSize = 12.sp,
|
||||
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"
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
12.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
|
||||
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 = 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
|
||||
)
|
||||
|
||||
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
|
||||
flashModulesSequentially(flashIt.uris, onStdout, onStderr)
|
||||
}
|
||||
|
||||
val currentUri = flashIt.uris[flashIt.currentIndex]
|
||||
onStdout("\n")
|
||||
FlashIt.FlashRestore -> restoreBoot(onStdout, onStderr)
|
||||
|
||||
flashModule(currentUri, onFinish, onStdout, onStderr)
|
||||
}
|
||||
is FlashIt.FlashModuleUpdate -> {
|
||||
onFinish(false, 0)
|
||||
}
|
||||
FlashIt.FlashRestore -> restoreBoot(onFinish, onStdout, onStderr)
|
||||
FlashIt.FlashUninstall -> uninstallPermanently(onFinish, 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)
|
||||
)
|
||||
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)
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { 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
|
||||
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)
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
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 = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(type.color, RoundedCornerShape(4.dp))
|
||||
.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 = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(excl.color, RoundedCornerShape(4.dp))
|
||||
.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") ?: ""
|
||||
@@ -939,3 +880,22 @@ 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,21 +183,21 @@ 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(
|
||||
clipboardText,
|
||||
{
|
||||
it, {
|
||||
showToast(context.getString(R.string.app_profile_template_import_success))
|
||||
viewModel.fetchTemplates(false)
|
||||
},
|
||||
showToast
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onExport = {
|
||||
scope.launch {
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
},
|
||||
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)
|
||||
) { innerPadding ->
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
isRefreshing = viewModel.isRefreshing,
|
||||
onRefresh = {
|
||||
scope.launch { viewModel.fetchTemplates() }
|
||||
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
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
popupHost = { },
|
||||
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
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))
|
||||
Card(
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onClick = {
|
||||
navigator.navigate(TemplateEditorScreenDestination(template, !template.local)) {
|
||||
popUpTo(TemplateEditorScreenDestination) {
|
||||
inclusive = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
headlineContent = { Text(template.name) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
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)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (template.local) {
|
||||
LabelText(label = "local")
|
||||
} else {
|
||||
LabelText(label = "remote")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
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)
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
||||
showDropdown = false
|
||||
}) {
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id = R.string.app_profile_import_from_clipboard))
|
||||
}, onClick = {
|
||||
items.forEachIndexed { index, text ->
|
||||
DropdownItem(
|
||||
text = text,
|
||||
optionSize = items.size,
|
||||
index = index,
|
||||
onSelectedIndexChange = { selectedIndex ->
|
||||
if (selectedIndex == 0) {
|
||||
onImport()
|
||||
showDropdown = false
|
||||
})
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id = R.string.app_profile_export_to_clipboard))
|
||||
}, onClick = {
|
||||
} else {
|
||||
onExport()
|
||||
showDropdown = false
|
||||
})
|
||||
}
|
||||
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,49 +119,54 @@ 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),
|
||||
@@ -150,6 +182,30 @@ fun TemplateEditorScreen(
|
||||
template = this
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_description),
|
||||
text = template.description
|
||||
@@ -165,7 +221,8 @@ fun TemplateEditorScreen(
|
||||
}
|
||||
}
|
||||
|
||||
RootProfileConfig(fixedName = true,
|
||||
RootProfileConfig(
|
||||
fixedName = true,
|
||||
profile = toNativeProfile(template),
|
||||
onProfileChange = {
|
||||
template.copy(
|
||||
@@ -185,13 +242,23 @@ fun TemplateEditorScreen(
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
val editText = remember { mutableStateOf(text) }
|
||||
EditText(
|
||||
title = label.uppercase(),
|
||||
textValue = editText,
|
||||
onTextValueChange = { newText ->
|
||||
editText.value = newText
|
||||
onValueChange(newText)
|
||||
},
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
}),
|
||||
onValueChange = onValueChange
|
||||
isError = isError,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private fun isValidTemplateId(id: String): Boolean {
|
||||
|
||||
@@ -1,22 +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
|
||||
@@ -24,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
|
||||
@@ -38,16 +55,13 @@ private val SPACING_LARGE = 16.dp
|
||||
|
||||
data class UmountPathEntry(
|
||||
val path: String,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,14 +278,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
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,8 +328,7 @@ 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(
|
||||
@@ -311,17 +336,11 @@ fun UmountPathCard(
|
||||
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 {
|
||||
@@ -335,14 +354,13 @@ fun UmountPathCard(
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
imageVector = MiuixIcons.Useful.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -352,50 +370,69 @@ fun AddUmountPathDialog(
|
||||
) {
|
||||
var path by rememberSaveable { mutableStateOf("") }
|
||||
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(
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = stringResource(R.string.add_umount_path),
|
||||
onDismissRequest = {
|
||||
showDialog.value = false
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = path,
|
||||
onValueChange = { path = it },
|
||||
label = { Text(stringResource(R.string.mount_path)) },
|
||||
label = stringResource(R.string.mount_path),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
|
||||
OutlinedTextField(
|
||||
TextField(
|
||||
value = flags,
|
||||
onValueChange = { flags = it },
|
||||
label = { Text(stringResource(R.string.umount_flags)) },
|
||||
label = stringResource(R.string.umount_flags),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = { Text(stringResource(R.string.umount_flags_hint)) }
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
|
||||
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
|
||||
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> {
|
||||
@@ -404,11 +441,10 @@ private fun parseUmountPaths(output: String): List<UmountPathEntry> {
|
||||
|
||||
return lines.drop(2).mapNotNull { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
if (parts.size >= 3) {
|
||||
if (parts.size >= 2) {
|
||||
UmountPathEntry(
|
||||
path = parts[0],
|
||||
flags = parts[1].toIntOrNull() ?: -1,
|
||||
isDefault = parts[2].equals("Yes", ignoreCase = true)
|
||||
flags = parts[1].toIntOrNull() ?: -1
|
||||
)
|
||||
} else null
|
||||
}
|
||||
@@ -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
@@ -1,928 +0,0 @@
|
||||
package com.sukisu.ultra.ui.susfs.component
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
|
||||
import com.sukisu.ultra.ui.susfs.util.SuSFSManager.isSusVersion158
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
|
||||
/**
|
||||
* SUS路径内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun SusPathsContent(
|
||||
susPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddPath: () -> Unit,
|
||||
onAddAppPath: () -> Unit,
|
||||
onRemovePath: (String) -> Unit,
|
||||
onEditPath: ((String) -> Unit)? = null,
|
||||
forceRefreshApps: Boolean = false
|
||||
) {
|
||||
val superUserApps = SuperUserViewModel.apps
|
||||
val superUserIsRefreshing = remember { SuperUserViewModel().isRefreshing }
|
||||
|
||||
LaunchedEffect(superUserIsRefreshing, superUserApps.size) {
|
||||
if (!superUserIsRefreshing && superUserApps.isNotEmpty()) {
|
||||
AppInfoCache.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(forceRefreshApps) {
|
||||
if (forceRefreshApps) {
|
||||
AppInfoCache.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
val (appPathGroups, otherPaths) = remember(susPaths) {
|
||||
val appPathRegex = Regex(".*/Android/data/([^/]+)/?.*")
|
||||
val uidPathRegex = Regex("/sys/fs/cgroup/uid_([0-9]+)")
|
||||
val appPathMap = mutableMapOf<String, MutableList<String>>()
|
||||
val uidToPackageMap = mutableMapOf<String, String>()
|
||||
val others = mutableListOf<String>()
|
||||
|
||||
// 构建UID到包名的映射
|
||||
SuperUserViewModel.apps.forEach { app ->
|
||||
try {
|
||||
val uid = app.packageInfo.applicationInfo?.uid
|
||||
uidToPackageMap[uid.toString()] = app.packageName
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
susPaths.forEach { path ->
|
||||
val appDataMatch = appPathRegex.find(path)
|
||||
val uidMatch = uidPathRegex.find(path)
|
||||
|
||||
when {
|
||||
appDataMatch != null -> {
|
||||
val packageName = appDataMatch.groupValues[1]
|
||||
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
|
||||
}
|
||||
uidMatch != null -> {
|
||||
val uid = uidMatch.groupValues[1]
|
||||
val packageName = uidToPackageMap[uid]
|
||||
if (packageName != null) {
|
||||
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
|
||||
} else {
|
||||
others.add(path)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
others.add(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sortedAppGroups = appPathMap.toList()
|
||||
.sortedBy { it.first }
|
||||
.map { (packageName, paths) -> packageName to paths.sorted() }
|
||||
|
||||
Pair(sortedAppGroups, others.sorted())
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 应用路径分组
|
||||
if (appPathGroups.isNotEmpty()) {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.app_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Apps,
|
||||
count = appPathGroups.size
|
||||
)
|
||||
}
|
||||
|
||||
items(appPathGroups) { (packageName, paths) ->
|
||||
AppPathGroupCard(
|
||||
packageName = packageName,
|
||||
paths = paths,
|
||||
onDeleteGroup = {
|
||||
paths.forEach { path -> onRemovePath(path) }
|
||||
},
|
||||
onEditGroup = if (onEditPath != null) {
|
||||
{
|
||||
onEditPath(paths.first())
|
||||
}
|
||||
} else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他路径
|
||||
if (otherPaths.isNotEmpty()) {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.other_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Folder,
|
||||
count = otherPaths.size
|
||||
)
|
||||
}
|
||||
|
||||
items(otherPaths) { path ->
|
||||
PathItemCard(
|
||||
path = path,
|
||||
icon = Icons.Default.Folder,
|
||||
onDelete = { onRemovePath(path) },
|
||||
onEdit = if (onEditPath != null) { { onEditPath(path) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (susPaths.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_paths_configured)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddPath,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add_custom_path))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onAddAppPath,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Apps,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add_app_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SUS循环路径内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun SusLoopPathsContent(
|
||||
susLoopPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddLoopPath: () -> Unit,
|
||||
onRemoveLoopPath: (String) -> Unit,
|
||||
onEditLoopPath: ((String) -> Unit)? = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 说明卡片
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.sus_loop_paths_description_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sus_loop_paths_description_text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_loop_path_restriction_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (susLoopPaths.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_loop_paths_configured)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.loop_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Loop,
|
||||
count = susLoopPaths.size
|
||||
)
|
||||
}
|
||||
|
||||
items(susLoopPaths.toList()) { path ->
|
||||
PathItemCard(
|
||||
path = path,
|
||||
icon = Icons.Default.Loop,
|
||||
onDelete = { onRemoveLoopPath(path) },
|
||||
onEdit = if (onEditLoopPath != null) { { onEditLoopPath(path) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddLoopPath,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add_loop_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SUS Maps内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun SusMapsContent(
|
||||
susMaps: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddSusMap: () -> Unit,
|
||||
onRemoveSusMap: (String) -> Unit,
|
||||
onEditSusMap: ((String) -> Unit)? = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 说明卡片
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.sus_maps_description_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sus_maps_description_text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sus_maps_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sus_maps_debug_info),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (susMaps.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_sus_maps_configured)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.sus_maps_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Security,
|
||||
count = susMaps.size
|
||||
)
|
||||
}
|
||||
|
||||
items(susMaps.toList()) { map ->
|
||||
PathItemCard(
|
||||
path = map,
|
||||
icon = Icons.Default.Security,
|
||||
onDelete = { onRemoveSusMap(map) },
|
||||
onEdit = if (onEditSusMap != null) { { onEditSusMap(map) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddSusMap,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SUS挂载内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun SusMountsContent(
|
||||
susMounts: Set<String>,
|
||||
hideSusMountsForAllProcs: Boolean,
|
||||
isSusVersion158: Boolean,
|
||||
isLoading: Boolean,
|
||||
onAddMount: () -> Unit,
|
||||
onRemoveMount: (String) -> Unit,
|
||||
onEditMount: ((String) -> Unit)? = null,
|
||||
onToggleHideSusMountsForAllProcs: (Boolean) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (isSusVersion158) {
|
||||
item {
|
||||
SusMountHidingControlCard(
|
||||
hideSusMountsForAllProcs = hideSusMountsForAllProcs,
|
||||
isLoading = isLoading,
|
||||
onToggleHiding = onToggleHideSusMountsForAllProcs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (susMounts.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_mounts_configured)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(susMounts.toList()) { mount ->
|
||||
PathItemCard(
|
||||
path = mount,
|
||||
icon = Icons.Default.Storage,
|
||||
onDelete = { onRemoveMount(mount) },
|
||||
onEdit = if (onEditMount != null) { { onEditMount(mount) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddMount,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试卸载内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun TryUmountContent(
|
||||
tryUmounts: Set<String>,
|
||||
umountForZygoteIsoService: Boolean,
|
||||
isLoading: Boolean,
|
||||
onAddUmount: () -> Unit,
|
||||
onRemoveUmount: (String) -> Unit,
|
||||
onEditUmount: ((String) -> Unit)? = null,
|
||||
onToggleUmountForZygoteIsoService: (Boolean) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (isSusVersion158()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Security,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.umount_zygote_iso_service),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.umount_zygote_iso_service_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = umountForZygoteIsoService,
|
||||
onCheckedChange = onToggleUmountForZygoteIsoService,
|
||||
enabled = !isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tryUmounts.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_umounts_configured)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(tryUmounts.toList()) { umountEntry ->
|
||||
val parts = umountEntry.split("|")
|
||||
val path = if (parts.isNotEmpty()) parts[0] else umountEntry
|
||||
val mode = if (parts.size > 1) parts[1] else "0"
|
||||
val modeText = if (mode == "0")
|
||||
stringResource(R.string.susfs_umount_mode_normal_short)
|
||||
else
|
||||
stringResource(R.string.susfs_umount_mode_detach_short)
|
||||
|
||||
PathItemCard(
|
||||
path = path,
|
||||
icon = Icons.Default.Storage,
|
||||
additionalInfo = stringResource(R.string.susfs_umount_mode_display, modeText, mode),
|
||||
onDelete = { onRemoveUmount(umountEntry) },
|
||||
onEdit = if (onEditUmount != null) { { onEditUmount(umountEntry) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddUmount,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kstat配置内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun KstatConfigContent(
|
||||
kstatConfigs: Set<String>,
|
||||
addKstatPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddKstatStatically: () -> Unit,
|
||||
onAddKstat: () -> Unit,
|
||||
onRemoveKstatConfig: (String) -> Unit,
|
||||
onEditKstatConfig: ((String) -> Unit)? = null,
|
||||
onRemoveAddKstat: (String) -> Unit,
|
||||
onEditAddKstat: ((String) -> Unit)? = null,
|
||||
onUpdateKstat: (String) -> Unit,
|
||||
onUpdateKstatFullClone: (String) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_add_statically),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_add),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_update),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_update_full_clone),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kstatConfigs.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.static_kstat_config),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
items(kstatConfigs.toList()) { config ->
|
||||
KstatConfigItemCard(
|
||||
config = config,
|
||||
onDelete = { onRemoveKstatConfig(config) },
|
||||
onEdit = if (onEditKstatConfig != null) { { onEditKstatConfig(config) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (addKstatPaths.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_path_management),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
items(addKstatPaths.toList()) { path ->
|
||||
AddKstatPathItemCard(
|
||||
path = path,
|
||||
onDelete = { onRemoveAddKstat(path) },
|
||||
onEdit = if (onEditAddKstat != null) { { onEditAddKstat(path) } } else null,
|
||||
onUpdate = { onUpdateKstat(path) },
|
||||
onUpdateFullClone = { onUpdateKstatFullClone(path) },
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (kstatConfigs.isEmpty() && addKstatPaths.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.no_kstat_config_message)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddKstat,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onAddKstatStatically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径设置内容组件
|
||||
*/
|
||||
@SuppressLint("SdCardPath")
|
||||
@Composable
|
||||
fun PathSettingsContent(
|
||||
androidDataPath: String,
|
||||
onAndroidDataPathChange: (String) -> Unit,
|
||||
sdcardPath: String,
|
||||
onSdcardPathChange: (String) -> Unit,
|
||||
isLoading: Boolean,
|
||||
onSetAndroidDataPath: () -> Unit,
|
||||
onSetSdcardPath: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = androidDataPath,
|
||||
onValueChange = onAndroidDataPathChange,
|
||||
label = { Text(stringResource(R.string.susfs_android_data_path_label)) },
|
||||
placeholder = { Text("/sdcard/Android/data") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onSetAndroidDataPath,
|
||||
enabled = !isLoading && androidDataPath.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.susfs_set_android_data_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = sdcardPath,
|
||||
onValueChange = onSdcardPathChange,
|
||||
label = { Text(stringResource(R.string.susfs_sdcard_path_label)) },
|
||||
placeholder = { Text("/sdcard") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onSetSdcardPath,
|
||||
enabled = !isLoading && sdcardPath.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.susfs_set_sdcard_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用功能状态内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun EnabledFeaturesContent(
|
||||
enabledFeatures: List<SuSFSManager.EnabledFeature>,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_enabled_features_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledFeatures.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_features_found)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(enabledFeatures) { feature ->
|
||||
FeatureStatusCard(
|
||||
feature = feature,
|
||||
onRefresh = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.sukisu.ultra.ui.susfs.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.sukisu.ultra.R
|
||||
import top.yukonga.miuix.kmp.basic.Button
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.CardDefaults
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import top.yukonga.miuix.kmp.extra.SuperDropdown
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
|
||||
sealed class DialogField {
|
||||
data class TextField(
|
||||
val value: String,
|
||||
val onValueChange: (String) -> Unit,
|
||||
val labelRes: Int,
|
||||
val enabled: Boolean = true,
|
||||
val modifier: Modifier = Modifier.fillMaxWidth()
|
||||
) : DialogField()
|
||||
|
||||
data class Dropdown(
|
||||
val titleRes: Int,
|
||||
val summary: String,
|
||||
val items: List<String>,
|
||||
val selectedIndex: Int,
|
||||
val onSelectedIndexChange: (Int) -> Unit,
|
||||
val enabled: Boolean = true
|
||||
) : DialogField()
|
||||
|
||||
data class CustomContent(
|
||||
val content: @Composable ColumnScope.() -> Unit
|
||||
) : DialogField()
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用多功能对话框组件
|
||||
*
|
||||
* @param showDialog 是否显示对话框
|
||||
* @param onDismiss 关闭对话框回调
|
||||
* @param onConfirm 确认回调,返回是否应该关闭对话框
|
||||
* @param titleRes 标题资源ID
|
||||
* @param isLoading 是否正在加载
|
||||
* @param fields 对话框字段列表
|
||||
* @param confirmTextRes 确认按钮文本资源ID,默认为"添加"
|
||||
* @param cancelTextRes 取消按钮文本资源ID,默认为"取消"
|
||||
* @param isConfirmEnabled 确认按钮是否启用,默认为true
|
||||
* @param scrollable 内容是否可滚动,默认为false
|
||||
* @param onReset 重置回调,用于清空字段
|
||||
*/
|
||||
@Composable
|
||||
fun UniversalDialog(
|
||||
showDialog: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Boolean,
|
||||
titleRes: Int,
|
||||
isLoading: Boolean = false,
|
||||
fields: List<DialogField>,
|
||||
confirmTextRes: Int = R.string.add,
|
||||
cancelTextRes: Int = R.string.cancel,
|
||||
isConfirmEnabled: Boolean = true,
|
||||
scrollable: Boolean = false,
|
||||
onReset: (() -> Unit)? = null
|
||||
) {
|
||||
val showDialogState = remember { mutableStateOf(showDialog) }
|
||||
|
||||
LaunchedEffect(showDialog) {
|
||||
showDialogState.value = showDialog
|
||||
}
|
||||
|
||||
if (showDialogState.value) {
|
||||
SuperDialog(
|
||||
show = showDialogState,
|
||||
title = stringResource(titleRes),
|
||||
onDismissRequest = {
|
||||
onDismiss()
|
||||
onReset?.invoke()
|
||||
},
|
||||
content = {
|
||||
val contentModifier = if (scrollable) {
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp)
|
||||
} else {
|
||||
Modifier.padding(horizontal = 24.dp)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = contentModifier,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
fields.forEach { field ->
|
||||
when (field) {
|
||||
is DialogField.TextField -> {
|
||||
TextField(
|
||||
value = field.value,
|
||||
onValueChange = field.onValueChange,
|
||||
label = stringResource(field.labelRes),
|
||||
useLabelAsPlaceholder = true,
|
||||
modifier = field.modifier,
|
||||
enabled = field.enabled && !isLoading
|
||||
)
|
||||
}
|
||||
is DialogField.Dropdown -> {
|
||||
SuperDropdown(
|
||||
title = stringResource(field.titleRes),
|
||||
summary = field.summary,
|
||||
items = field.items,
|
||||
selectedIndex = field.selectedIndex,
|
||||
onSelectedIndexChange = field.onSelectedIndexChange,
|
||||
enabled = field.enabled && !isLoading
|
||||
)
|
||||
}
|
||||
is DialogField.CustomContent -> {
|
||||
field.content.invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onReset?.invoke()
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(vertical = 8.dp),
|
||||
cornerRadius = 8.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(cancelTextRes)
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (onConfirm()) {
|
||||
onDismiss()
|
||||
onReset?.invoke()
|
||||
}
|
||||
},
|
||||
enabled = isConfirmEnabled && !isLoading,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(vertical = 8.dp),
|
||||
cornerRadius = 8.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(confirmTextRes)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DescriptionCard(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
warning: String? = null,
|
||||
additionalInfo: String? = null
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.defaultColors(
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
cornerRadius = 8.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
warning?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.secondary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
additionalInfo?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.onSurfaceVariantSummary.copy(alpha = 0.8f),
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
showDialog: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
titleRes: Int,
|
||||
messageRes: Int,
|
||||
isLoading: Boolean = false
|
||||
) {
|
||||
UniversalDialog(
|
||||
showDialog = showDialog,
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = {
|
||||
onConfirm()
|
||||
true
|
||||
},
|
||||
titleRes = titleRes,
|
||||
isLoading = isLoading,
|
||||
fields = listOf(
|
||||
DialogField.CustomContent {
|
||||
Text(
|
||||
text = stringResource(messageRes),
|
||||
style = MiuixTheme.textStyles.body2
|
||||
)
|
||||
}
|
||||
),
|
||||
confirmTextRes = R.string.confirm,
|
||||
cancelTextRes = R.string.cancel,
|
||||
isConfirmEnabled = !isLoading
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyStateCard(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.defaultColors(
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.15f)
|
||||
),
|
||||
cornerRadius = 8.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionHeader(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: ImageVector,
|
||||
count: Int
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.defaultColors(
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.25f)
|
||||
),
|
||||
cornerRadius = 8.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MiuixTheme.textStyles.body1,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
subtitle?.let {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(colorScheme.primaryContainer)
|
||||
.padding(horizontal = 10.dp, vertical = 5.dp)
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
style = MiuixTheme.textStyles.body2.copy(fontSize = 12.sp),
|
||||
color = colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package com.sukisu.ultra.ui.susfs.content
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.component.BackupRestoreComponent
|
||||
import com.sukisu.ultra.ui.susfs.component.DescriptionCard
|
||||
import com.sukisu.ultra.ui.susfs.component.ResetButton
|
||||
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
|
||||
import com.sukisu.ultra.ui.util.isAbDevice
|
||||
import top.yukonga.miuix.kmp.basic.*
|
||||
import top.yukonga.miuix.kmp.extra.SuperDropdown
|
||||
import top.yukonga.miuix.kmp.extra.SuperSwitch
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
|
||||
@Composable
|
||||
fun BasicSettingsContent(
|
||||
unameValue: String,
|
||||
onUnameValueChange: (String) -> Unit,
|
||||
buildTimeValue: String,
|
||||
onBuildTimeValueChange: (String) -> Unit,
|
||||
executeInPostFsData: Boolean,
|
||||
onExecuteInPostFsDataChange: (Boolean) -> Unit,
|
||||
autoStartEnabled: Boolean,
|
||||
canEnableAutoStart: Boolean,
|
||||
isLoading: Boolean,
|
||||
onAutoStartToggle: (Boolean) -> Unit,
|
||||
onShowSlotInfo: () -> Unit,
|
||||
context: Context,
|
||||
enableHideBl: Boolean,
|
||||
onEnableHideBlChange: (Boolean) -> Unit,
|
||||
enableCleanupResidue: Boolean,
|
||||
onEnableCleanupResidueChange: (Boolean) -> Unit,
|
||||
enableAvcLogSpoofing: Boolean,
|
||||
onEnableAvcLogSpoofingChange: (Boolean) -> Unit,
|
||||
hideSusMountsForAllProcs: Boolean,
|
||||
onHideSusMountsForAllProcsChange: (Boolean) -> Unit,
|
||||
umountForZygoteIsoService: Boolean,
|
||||
onUmountForZygoteIsoServiceChange: (Boolean) -> Unit,
|
||||
onReset: (() -> Unit)? = null,
|
||||
onApply: (() -> Unit)? = null,
|
||||
onConfigReload: () -> Unit
|
||||
) {
|
||||
val isAbDevice = produceState(initialValue = false) {
|
||||
value = isAbDevice()
|
||||
}.value
|
||||
|
||||
// 执行位置选择
|
||||
val locationItems = listOf(
|
||||
stringResource(R.string.susfs_execution_location_service),
|
||||
stringResource(R.string.susfs_execution_location_post_fs_data)
|
||||
)
|
||||
|
||||
// 说明卡片
|
||||
DescriptionCard(
|
||||
title = stringResource(R.string.susfs_config_description),
|
||||
description = stringResource(R.string.susfs_config_description_text)
|
||||
)
|
||||
|
||||
// Uname输入框
|
||||
TextField(
|
||||
value = unameValue,
|
||||
onValueChange = onUnameValueChange,
|
||||
label = stringResource(R.string.susfs_uname_label),
|
||||
useLabelAsPlaceholder = true,
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
// 构建时间伪装输入框
|
||||
TextField(
|
||||
value = buildTimeValue,
|
||||
onValueChange = onBuildTimeValueChange,
|
||||
label = stringResource(R.string.susfs_build_time_label),
|
||||
useLabelAsPlaceholder = true,
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
SuperDropdown(
|
||||
title = stringResource(R.string.susfs_execution_location_label),
|
||||
summary = if (executeInPostFsData) {
|
||||
stringResource(R.string.susfs_execution_location_post_fs_data)
|
||||
} else {
|
||||
stringResource(R.string.susfs_execution_location_service)
|
||||
},
|
||||
items = locationItems,
|
||||
selectedIndex = if (executeInPostFsData) 1 else 0,
|
||||
onSelectedIndexChange = { index ->
|
||||
onExecuteInPostFsDataChange(index == 1)
|
||||
},
|
||||
enabled = !isLoading,
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 当前值显示
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_current_value, SuSFSManager.getUnameValue(context)),
|
||||
style = MiuixTheme.textStyles.body2.copy(fontSize = 13.sp),
|
||||
color = colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_current_build_time, SuSFSManager.getBuildTimeValue(context)),
|
||||
style = MiuixTheme.textStyles.body2.copy(fontSize = 13.sp),
|
||||
color = colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_current_execution_location, if (SuSFSManager.getExecuteInPostFsData(context)) "Post-FS-Data" else "Service"),
|
||||
style = MiuixTheme.textStyles.body2.copy(fontSize = 13.sp),
|
||||
color = colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 应用按钮
|
||||
if (onApply != null) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Button(
|
||||
onClick = { onApply() },
|
||||
enabled = !isLoading && (unameValue.isNotBlank() || buildTimeValue.isNotBlank()),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp),
|
||||
cornerRadius = 8.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_apply)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
// 开机自启动开关
|
||||
SuperSwitch(
|
||||
title = stringResource(R.string.susfs_autostart_title),
|
||||
summary = if (canEnableAutoStart) {
|
||||
stringResource(R.string.susfs_autostart_description)
|
||||
} else {
|
||||
stringResource(R.string.susfs_autostart_requirement)
|
||||
},
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Default.AutoMode,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
contentDescription = stringResource(R.string.susfs_autostart_title),
|
||||
tint = if (canEnableAutoStart) colorScheme.onBackground else colorScheme.onSurfaceVariantSummary
|
||||
)
|
||||
},
|
||||
checked = autoStartEnabled,
|
||||
onCheckedChange = onAutoStartToggle,
|
||||
enabled = !isLoading && canEnableAutoStart
|
||||
)
|
||||
|
||||
// 隐藏BL脚本开关
|
||||
SuperSwitch(
|
||||
title = stringResource(R.string.hide_bl_script),
|
||||
summary = stringResource(R.string.hide_bl_script_description),
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Default.Security,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
contentDescription = stringResource(R.string.hide_bl_script),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
},
|
||||
checked = enableHideBl,
|
||||
onCheckedChange = onEnableHideBlChange,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
// 清理残留脚本开关
|
||||
SuperSwitch(
|
||||
title = stringResource(R.string.cleanup_residue),
|
||||
summary = stringResource(R.string.cleanup_residue_description),
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Default.CleaningServices,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
contentDescription = stringResource(R.string.cleanup_residue),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
},
|
||||
checked = enableCleanupResidue,
|
||||
onCheckedChange = onEnableCleanupResidueChange,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
// AVC日志欺骗开关
|
||||
SuperSwitch(
|
||||
title = stringResource(R.string.avc_log_spoofing),
|
||||
summary = stringResource(R.string.avc_log_spoofing_description),
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Default.VisibilityOff,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
contentDescription = stringResource(R.string.avc_log_spoofing),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
},
|
||||
checked = enableAvcLogSpoofing,
|
||||
onCheckedChange = onEnableAvcLogSpoofingChange,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
// 对所有进程隐藏SuS挂载开关
|
||||
SuperSwitch(
|
||||
title = stringResource(R.string.susfs_hide_mounts_for_all_procs_label),
|
||||
summary = if (hideSusMountsForAllProcs) {
|
||||
stringResource(R.string.susfs_hide_mounts_for_all_procs_enabled_description)
|
||||
} else {
|
||||
stringResource(R.string.susfs_hide_mounts_for_all_procs_disabled_description)
|
||||
},
|
||||
leftAction = {
|
||||
Icon(
|
||||
if (hideSusMountsForAllProcs) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
contentDescription = stringResource(R.string.susfs_hide_mounts_for_all_procs_label),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
},
|
||||
checked = hideSusMountsForAllProcs,
|
||||
onCheckedChange = onHideSusMountsForAllProcsChange,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
// 卸载 Zygote 隔离服务开关
|
||||
SuperSwitch(
|
||||
title = stringResource(R.string.umount_zygote_iso_service),
|
||||
summary = stringResource(R.string.umount_zygote_iso_service_description),
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Default.Security,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
contentDescription = stringResource(R.string.umount_zygote_iso_service),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
},
|
||||
checked = umountForZygoteIsoService,
|
||||
onCheckedChange = onUmountForZygoteIsoServiceChange,
|
||||
enabled = !isLoading
|
||||
)
|
||||
}
|
||||
|
||||
// 槽位信息按钮
|
||||
if (isAbDevice) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_slot_info_title),
|
||||
style = MiuixTheme.textStyles.title3,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_slot_info_description),
|
||||
style = MiuixTheme.textStyles.body2.copy(fontSize = 13.sp),
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
Button(
|
||||
onClick = onShowSlotInfo,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(vertical = 8.dp),
|
||||
cornerRadius = 8.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_slot_info_title)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackupRestoreComponent(
|
||||
isLoading = isLoading,
|
||||
onLoadingChange = { },
|
||||
onConfigReload = onConfigReload
|
||||
)
|
||||
|
||||
// 重置按钮
|
||||
if (onReset != null) {
|
||||
ResetButton(
|
||||
title = stringResource(R.string.susfs_reset_confirm_title),
|
||||
onClick = onReset
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.sukisu.ultra.ui.susfs.content
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
|
||||
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
|
||||
import com.sukisu.ultra.ui.susfs.component.FeatureStatusCard
|
||||
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
|
||||
import top.yukonga.miuix.kmp.basic.*
|
||||
import top.yukonga.miuix.kmp.basic.CardDefaults
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
|
||||
@Composable
|
||||
fun EnabledFeaturesContent(
|
||||
enabledFeatures: List<SuSFSManager.EnabledFeature>,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.defaultColors(
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
cornerRadius = 8.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_enabled_features_description),
|
||||
style = MiuixTheme.textStyles.body2,
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledFeatures.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_features_found)
|
||||
)
|
||||
} else {
|
||||
enabledFeatures.forEach { feature ->
|
||||
FeatureStatusCard(
|
||||
feature = feature,
|
||||
onRefresh = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BottomActionButtons(
|
||||
primaryButtonText = stringResource(R.string.refresh),
|
||||
onPrimaryClick = onRefresh
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.sukisu.ultra.ui.susfs.content
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.component.AddKstatPathItemCard
|
||||
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
|
||||
import com.sukisu.ultra.ui.susfs.component.DescriptionCard
|
||||
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
|
||||
import com.sukisu.ultra.ui.susfs.component.KstatConfigItemCard
|
||||
import com.sukisu.ultra.ui.susfs.component.SectionHeader
|
||||
|
||||
@Composable
|
||||
fun KstatConfigContent(
|
||||
kstatConfigs: Set<String>,
|
||||
addKstatPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddKstatStatically: () -> Unit,
|
||||
onAddKstat: () -> Unit,
|
||||
onRemoveKstatConfig: (String) -> Unit,
|
||||
onEditKstatConfig: ((String) -> Unit)? = null,
|
||||
onRemoveAddKstat: (String) -> Unit,
|
||||
onEditAddKstat: ((String) -> Unit)? = null,
|
||||
onUpdateKstat: (String) -> Unit,
|
||||
onUpdateKstatFullClone: (String) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
DescriptionCard(
|
||||
title = stringResource(R.string.kstat_config_description_title),
|
||||
description = stringResource(R.string.kstat_config_description_add_statically) + "\n" +
|
||||
stringResource(R.string.kstat_config_description_add) + "\n" +
|
||||
stringResource(R.string.kstat_config_description_update) + "\n" +
|
||||
stringResource(R.string.kstat_config_description_update_full_clone)
|
||||
)
|
||||
|
||||
if (kstatConfigs.isNotEmpty()) {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.static_kstat_config),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Settings,
|
||||
count = kstatConfigs.size
|
||||
)
|
||||
kstatConfigs.toList().forEach { config ->
|
||||
KstatConfigItemCard(
|
||||
config = config,
|
||||
onDelete = { onRemoveKstatConfig(config) },
|
||||
onEdit = if (onEditKstatConfig != null) {
|
||||
{ onEditKstatConfig(config) }
|
||||
} else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (addKstatPaths.isNotEmpty()) {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.kstat_path_management),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Folder,
|
||||
count = addKstatPaths.size
|
||||
)
|
||||
addKstatPaths.toList().forEach { path ->
|
||||
AddKstatPathItemCard(
|
||||
path = path,
|
||||
onDelete = { onRemoveAddKstat(path) },
|
||||
onEdit = if (onEditAddKstat != null) {
|
||||
{ onEditAddKstat(path) }
|
||||
} else null,
|
||||
onUpdate = { onUpdateKstat(path) },
|
||||
onUpdateFullClone = { onUpdateKstatFullClone(path) },
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (kstatConfigs.isEmpty() && addKstatPaths.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.no_kstat_config_message)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BottomActionButtons(
|
||||
primaryButtonText = stringResource(R.string.add),
|
||||
onPrimaryClick = onAddKstat,
|
||||
secondaryButtonText = stringResource(R.string.add),
|
||||
onSecondaryClick = onAddKstatStatically,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.sukisu.ultra.ui.susfs.content
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.component.ResetButton
|
||||
import top.yukonga.miuix.kmp.basic.*
|
||||
|
||||
@SuppressLint("SdCardPath")
|
||||
@Composable
|
||||
fun PathSettingsContent(
|
||||
androidDataPath: String,
|
||||
onAndroidDataPathChange: (String) -> Unit,
|
||||
sdcardPath: String,
|
||||
onSdcardPathChange: (String) -> Unit,
|
||||
isLoading: Boolean,
|
||||
onSetAndroidDataPath: () -> Unit,
|
||||
onSetSdcardPath: () -> Unit,
|
||||
onReset: (() -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
TextField(
|
||||
value = androidDataPath,
|
||||
onValueChange = onAndroidDataPathChange,
|
||||
label = stringResource(R.string.susfs_android_data_path_label),
|
||||
useLabelAsPlaceholder = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cornerRadius = 16.dp,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = sdcardPath,
|
||||
onValueChange = onSdcardPathChange,
|
||||
label = stringResource(R.string.susfs_sdcard_path_label),
|
||||
useLabelAsPlaceholder = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cornerRadius = 16.dp,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onSetAndroidDataPath()
|
||||
onSetSdcardPath()
|
||||
},
|
||||
enabled = !isLoading && androidDataPath.isNotBlank() && sdcardPath.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
cornerRadius = 16.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_apply)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onReset != null) {
|
||||
ResetButton(
|
||||
title = stringResource(R.string.susfs_reset_path_title),
|
||||
onClick = onReset
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.sukisu.ultra.ui.susfs.content
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Loop
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
|
||||
import com.sukisu.ultra.ui.susfs.component.DescriptionCard
|
||||
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
|
||||
import com.sukisu.ultra.ui.susfs.component.PathItemCard
|
||||
import com.sukisu.ultra.ui.susfs.component.ResetButton
|
||||
import com.sukisu.ultra.ui.susfs.component.SectionHeader
|
||||
|
||||
@Composable
|
||||
fun SusLoopPathsContent(
|
||||
susLoopPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddLoopPath: () -> Unit,
|
||||
onRemoveLoopPath: (String) -> Unit,
|
||||
onEditLoopPath: ((String) -> Unit)? = null,
|
||||
onReset: (() -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 说明卡片
|
||||
DescriptionCard(
|
||||
title = stringResource(R.string.sus_loop_paths_description_title),
|
||||
description = stringResource(R.string.sus_loop_paths_description_text),
|
||||
warning = stringResource(R.string.susfs_loop_path_restriction_warning)
|
||||
)
|
||||
|
||||
if (susLoopPaths.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_loop_paths_configured)
|
||||
)
|
||||
} else {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.loop_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Loop,
|
||||
count = susLoopPaths.size
|
||||
)
|
||||
|
||||
susLoopPaths.toList().forEach { path ->
|
||||
PathItemCard(
|
||||
path = path,
|
||||
icon = Icons.Default.Loop,
|
||||
onDelete = { onRemoveLoopPath(path) },
|
||||
onEdit = if (onEditLoopPath != null) {
|
||||
{ onEditLoopPath(path) }
|
||||
} else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BottomActionButtons(
|
||||
primaryButtonText = stringResource(R.string.add_loop_path),
|
||||
onPrimaryClick = onAddLoopPath,
|
||||
isLoading = isLoading
|
||||
)
|
||||
|
||||
if (onReset != null && susLoopPaths.isNotEmpty()) {
|
||||
ResetButton(
|
||||
title = stringResource(R.string.susfs_reset_loop_paths_title),
|
||||
onClick = onReset
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.sukisu.ultra.ui.susfs.content
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
|
||||
import com.sukisu.ultra.ui.susfs.component.DescriptionCard
|
||||
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
|
||||
import com.sukisu.ultra.ui.susfs.component.PathItemCard
|
||||
import com.sukisu.ultra.ui.susfs.component.ResetButton
|
||||
import com.sukisu.ultra.ui.susfs.component.SectionHeader
|
||||
|
||||
@Composable
|
||||
fun SusMapsContent(
|
||||
susMaps: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddSusMap: () -> Unit,
|
||||
onRemoveSusMap: (String) -> Unit,
|
||||
onEditSusMap: ((String) -> Unit)? = null,
|
||||
onReset: (() -> Unit)? = null
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 说明卡片
|
||||
DescriptionCard(
|
||||
title = stringResource(R.string.sus_maps_description_title),
|
||||
description = stringResource(R.string.sus_maps_description_text),
|
||||
warning = stringResource(R.string.sus_maps_warning),
|
||||
additionalInfo = stringResource(R.string.sus_maps_debug_info)
|
||||
)
|
||||
|
||||
if (susMaps.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_sus_maps_configured)
|
||||
)
|
||||
} else {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.sus_maps_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Security,
|
||||
count = susMaps.size
|
||||
)
|
||||
|
||||
susMaps.toList().forEach { map ->
|
||||
PathItemCard(
|
||||
path = map,
|
||||
icon = Icons.Default.Security,
|
||||
onDelete = { onRemoveSusMap(map) },
|
||||
onEdit = if (onEditSusMap != null) {
|
||||
{ onEditSusMap(map) }
|
||||
} else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BottomActionButtons(
|
||||
primaryButtonText = stringResource(R.string.add),
|
||||
onPrimaryClick = onAddSusMap,
|
||||
isLoading = isLoading
|
||||
)
|
||||
|
||||
if (onReset != null && susMaps.isNotEmpty()) {
|
||||
ResetButton(
|
||||
title = stringResource(R.string.susfs_reset_sus_maps_title),
|
||||
onClick = onReset
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.sukisu.ultra.ui.susfs.content
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.component.AppInfoCache
|
||||
import com.sukisu.ultra.ui.susfs.component.AppPathGroupCard
|
||||
import com.sukisu.ultra.ui.susfs.component.BottomActionButtons
|
||||
import com.sukisu.ultra.ui.susfs.component.EmptyStateCard
|
||||
import com.sukisu.ultra.ui.susfs.component.PathItemCard
|
||||
import com.sukisu.ultra.ui.susfs.component.ResetButton
|
||||
import com.sukisu.ultra.ui.susfs.component.SectionHeader
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
|
||||
@Composable
|
||||
fun SusPathsContent(
|
||||
susPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddPath: () -> Unit,
|
||||
onAddAppPath: () -> Unit,
|
||||
onRemovePath: (String) -> Unit,
|
||||
onEditPath: ((String) -> Unit)? = null,
|
||||
forceRefreshApps: Boolean = false,
|
||||
onReset: (() -> Unit)? = null
|
||||
) {
|
||||
var superUserApps by remember { mutableStateOf(SuperUserViewModel.getAppsSafely()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { SuperUserViewModel.apps }
|
||||
.distinctUntilChanged()
|
||||
.collect { _ ->
|
||||
superUserApps = SuperUserViewModel.getAppsSafely()
|
||||
if (superUserApps.isNotEmpty()) {
|
||||
try {
|
||||
AppInfoCache.clearCache()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(forceRefreshApps) {
|
||||
if (forceRefreshApps) {
|
||||
try {
|
||||
AppInfoCache.clearCache()
|
||||
} catch (_: Exception) {
|
||||
// Ignore cache clear errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val (appPathGroups, otherPaths) = remember(susPaths, superUserApps) {
|
||||
val appPathRegex = Regex(".*/Android/data/([^/]+)/?.*")
|
||||
val uidPathRegex = Regex("/sys/fs/cgroup(?:/[^/]+)*/uid_([0-9]+)")
|
||||
val appPathMap = mutableMapOf<String, MutableList<String>>()
|
||||
val uidToPackageMap = mutableMapOf<String, String>()
|
||||
val others = mutableListOf<String>()
|
||||
|
||||
// 构建UID到包名的映射
|
||||
try {
|
||||
superUserApps.forEach { app: SuperUserViewModel.AppInfo ->
|
||||
try {
|
||||
val uid = app.packageInfo.applicationInfo?.uid
|
||||
if (uid != null) {
|
||||
uidToPackageMap[uid.toString()] = app.packageName
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Ignore individual app errors
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Ignore mapping errors
|
||||
}
|
||||
|
||||
susPaths.forEach { path ->
|
||||
val appDataMatch = appPathRegex.find(path)
|
||||
val uidMatch = uidPathRegex.find(path)
|
||||
|
||||
when {
|
||||
appDataMatch != null -> {
|
||||
val packageName = appDataMatch.groupValues[1]
|
||||
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
|
||||
}
|
||||
uidMatch != null -> {
|
||||
val uid = uidMatch.groupValues[1]
|
||||
val packageName = uidToPackageMap[uid]
|
||||
if (packageName != null) {
|
||||
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
|
||||
} else {
|
||||
others.add(path)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
others.add(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sortedAppGroups = appPathMap.toList()
|
||||
.sortedBy { it.first }
|
||||
.map { (packageName, paths) -> packageName to paths.sorted() }
|
||||
|
||||
Pair(sortedAppGroups, others.sorted())
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 应用路径分组
|
||||
if (appPathGroups.isNotEmpty()) {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.app_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Apps,
|
||||
count = appPathGroups.size
|
||||
)
|
||||
|
||||
appPathGroups.forEach { (packageName, paths) ->
|
||||
AppPathGroupCard(
|
||||
packageName = packageName,
|
||||
paths = paths,
|
||||
onDeleteGroup = {
|
||||
paths.forEach { path -> onRemovePath(path) }
|
||||
},
|
||||
onEditGroup = if (onEditPath != null) {
|
||||
{
|
||||
onEditPath(paths.first())
|
||||
}
|
||||
} else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他路径
|
||||
if (otherPaths.isNotEmpty()) {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.other_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Folder,
|
||||
count = otherPaths.size
|
||||
)
|
||||
|
||||
otherPaths.forEach { path ->
|
||||
PathItemCard(
|
||||
path = path,
|
||||
icon = Icons.Default.Folder,
|
||||
onDelete = { onRemovePath(path) },
|
||||
onEdit = if (onEditPath != null) {
|
||||
{ onEditPath(path) }
|
||||
} else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (susPaths.isEmpty()) {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_paths_configured)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BottomActionButtons(
|
||||
primaryButtonText = stringResource(R.string.add_custom_path),
|
||||
onPrimaryClick = onAddPath,
|
||||
secondaryButtonText = stringResource(R.string.susfs_apply),
|
||||
onSecondaryClick = onAddAppPath,
|
||||
isLoading = isLoading
|
||||
)
|
||||
|
||||
if (onReset != null && susPaths.isNotEmpty()) {
|
||||
ResetButton(
|
||||
title = stringResource(R.string.susfs_reset_paths_title),
|
||||
onClick = onReset
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
package com.sukisu.ultra.ui.susfs.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object SuSFSModuleManager {
|
||||
private const val TAG = "SuSFSModuleManager"
|
||||
private const val MODULE_ID = "susfs_manager"
|
||||
private const val MODULE_PATH = "/data/adb/modules/$MODULE_ID"
|
||||
|
||||
data class CommandResult(val isSuccess: Boolean, val output: String, val errorOutput: String = "")
|
||||
|
||||
private fun runCmdWithResult(cmd: String): CommandResult {
|
||||
val result = Shell.getShell().newJob().add(cmd).exec()
|
||||
return CommandResult(
|
||||
isSuccess = result.isSuccess,
|
||||
output = result.out.joinToString("\n"),
|
||||
errorOutput = result.err.joinToString("\n")
|
||||
)
|
||||
}
|
||||
|
||||
fun getModulePath(): String = MODULE_PATH
|
||||
|
||||
private fun getCurrentModuleConfig(context: Context): SuSFSManager.ModuleConfig {
|
||||
return SuSFSManager.getCurrentModuleConfig(context)
|
||||
}
|
||||
|
||||
|
||||
suspend fun createMagiskModule(context: Context): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val config = getCurrentModuleConfig(context)
|
||||
|
||||
// 创建模块目录
|
||||
if (!runCmdWithResult("mkdir -p $MODULE_PATH").isSuccess) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
// 创建 module.prop
|
||||
val moduleProp = ScriptGenerator.generateModuleProp(MODULE_ID)
|
||||
val modulePropCmd = "cat > $MODULE_PATH/module.prop << 'EOF'\n$moduleProp\nEOF"
|
||||
if (!runCmdWithResult(modulePropCmd).isSuccess) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
// 生成并创建所有脚本文件
|
||||
val scripts = ScriptGenerator.generateAllScripts(config)
|
||||
scripts.all { (filename, content) ->
|
||||
val writeCmd = "cat > $MODULE_PATH/$filename << 'EOF'\n$content\nEOF"
|
||||
runCmdWithResult(writeCmd).isSuccess &&
|
||||
runCmdWithResult("chmod 755 $MODULE_PATH/$filename").isSuccess
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create module", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeMagiskModule(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
runCmdWithResult("rm -rf $MODULE_PATH").isSuccess
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove module", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateMagiskModule(context: Context): Boolean {
|
||||
return removeMagiskModule() && createMagiskModule(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,13 @@ object ScriptGenerator {
|
||||
}
|
||||
|
||||
// 日志相关的通用脚本片段
|
||||
private fun generateLogSetup(logFileName: String): String = """
|
||||
private fun generateLogSetup(logFileName: String): String = $$"""
|
||||
# 日志目录
|
||||
LOG_DIR="$LOG_DIR"
|
||||
LOG_FILE="${'$'}LOG_DIR/$logFileName"
|
||||
LOG_DIR="$$LOG_DIR"
|
||||
LOG_FILE="$LOG_DIR/$$logFileName"
|
||||
|
||||
# 创建日志目录
|
||||
mkdir -p "${'$'}LOG_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# 获取当前时间
|
||||
get_current_time() {
|
||||
@@ -41,11 +41,11 @@ object ScriptGenerator {
|
||||
""".trimIndent()
|
||||
|
||||
// 二进制文件检查的通用脚本片段
|
||||
private fun generateBinaryCheck(targetPath: String): String = """
|
||||
private fun generateBinaryCheck(targetPath: String): String = $$"""
|
||||
# 检查SuSFS二进制文件
|
||||
SUSFS_BIN="$targetPath"
|
||||
if [ ! -f "${'$'}SUSFS_BIN" ]; then
|
||||
echo "$(get_current_time): SuSFS二进制文件未找到: ${'$'}SUSFS_BIN" >> "${'$'}LOG_FILE"
|
||||
SUSFS_BIN="$$targetPath"
|
||||
if [ ! -f "$SUSFS_BIN" ]; then
|
||||
echo "$(get_current_time): SuSFS二进制文件未找到: $SUSFS_BIN" >> "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
""".trimIndent()
|
||||
@@ -66,8 +66,8 @@ object ScriptGenerator {
|
||||
appendLine()
|
||||
|
||||
if (shouldConfigureInService(config)) {
|
||||
// 添加SUS路径 (仅在不支持隐藏挂载时)
|
||||
if (!config.support158 && config.susPaths.isNotEmpty()) {
|
||||
// 添加SUS路径
|
||||
if (config.susPaths.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
|
||||
appendLine("sleep 45")
|
||||
@@ -94,7 +94,7 @@ object ScriptGenerator {
|
||||
generateCleanupResidueSection()
|
||||
}
|
||||
|
||||
appendLine("echo \"$(get_current_time): Service脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): Service脚本执行完成\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +112,16 @@ object ScriptGenerator {
|
||||
private fun StringBuilder.generateLogSettingSection(enableLog: Boolean) {
|
||||
appendLine("# 设置日志启用状态")
|
||||
val logValue = if (enableLog) 1 else 0
|
||||
appendLine("\"${'$'}SUSFS_BIN\" enable_log $logValue")
|
||||
appendLine("echo \"$(get_current_time): 日志功能设置为: ${if (enableLog) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" enable_log $$logValue")
|
||||
appendLine($$"echo \"$(get_current_time): 日志功能设置为: $${if (enableLog) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.generateAvcLogSpoofingSection(enableAvcLogSpoofing: Boolean) {
|
||||
appendLine("# 设置AVC日志欺骗状态")
|
||||
val avcLogValue = if (enableAvcLogSpoofing) 1 else 0
|
||||
appendLine("\"${'$'}SUSFS_BIN\" enable_avc_log_spoofing $avcLogValue")
|
||||
appendLine("echo \"$(get_current_time): AVC日志欺骗功能设置为: ${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" enable_avc_log_spoofing $$avcLogValue")
|
||||
appendLine($$"echo \"$(get_current_time): AVC日志欺骗功能设置为: $${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
@@ -129,8 +129,8 @@ object ScriptGenerator {
|
||||
if (susPaths.isNotEmpty()) {
|
||||
appendLine("# 添加SUS路径")
|
||||
susPaths.forEach { path ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path '$path'")
|
||||
appendLine("echo \"$(get_current_time): 添加SUS路径: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" add_sus_path '$$path'")
|
||||
appendLine($$"echo \"$(get_current_time): 添加SUS路径: $$path\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
@@ -140,8 +140,8 @@ object ScriptGenerator {
|
||||
if (susLoopPaths.isNotEmpty()) {
|
||||
appendLine("# 添加SUS循环路径")
|
||||
susLoopPaths.forEach { path ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path_loop '$path'")
|
||||
appendLine("echo \"$(get_current_time): 添加SUS循环路径: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" add_sus_path_loop '$$path'")
|
||||
appendLine($$"echo \"$(get_current_time): 添加SUS循环路径: $$path\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
@@ -156,8 +156,8 @@ object ScriptGenerator {
|
||||
if (addKstatPaths.isNotEmpty()) {
|
||||
appendLine("# 添加Kstat路径")
|
||||
addKstatPaths.forEach { path ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat '$path'")
|
||||
appendLine("echo \"$(get_current_time): 添加Kstat路径: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" add_sus_kstat '$$path'")
|
||||
appendLine($$"echo \"$(get_current_time): 添加Kstat路径: $$path\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
@@ -171,11 +171,11 @@ object ScriptGenerator {
|
||||
val path = parts[0]
|
||||
val params = parts.drop(1).joinToString("' '", "'", "'")
|
||||
appendLine()
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat_statically '$path' $params")
|
||||
appendLine("echo \"$(get_current_time): 添加Kstat静态配置: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" add_sus_kstat_statically '$$path' $$params")
|
||||
appendLine($$"echo \"$(get_current_time): 添加Kstat静态配置: $$path\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
appendLine("\"${'$'}SUSFS_BIN\" update_sus_kstat '$path'")
|
||||
appendLine("echo \"$(get_current_time): 更新Kstat配置: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" update_sus_kstat '$$path'")
|
||||
appendLine($$"echo \"$(get_current_time): 更新Kstat配置: $$path\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
appendLine()
|
||||
@@ -185,8 +185,8 @@ object ScriptGenerator {
|
||||
private fun StringBuilder.generateUnameSection(config: SuSFSManager.ModuleConfig) {
|
||||
if (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
|
||||
appendLine("# 设置uname和构建时间")
|
||||
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'")
|
||||
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" set_uname '$${config.unameValue}' '$${config.buildTimeValue}'")
|
||||
appendLine($$"echo \"$(get_current_time): 设置uname为: $${config.unameValue}, 构建时间为: $${config.buildTimeValue}\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
@@ -194,44 +194,44 @@ object ScriptGenerator {
|
||||
private fun StringBuilder.generateHideBlSection() {
|
||||
appendLine("# 隐藏BL 来自 Shamiko 脚本")
|
||||
appendLine(
|
||||
"""
|
||||
$$"""
|
||||
RESETPROP_BIN="/data/adb/ksu/bin/resetprop"
|
||||
|
||||
check_reset_prop() {
|
||||
local NAME=$1
|
||||
local EXPECTED=$2
|
||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
||||
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
||||
local VALUE=$("$RESETPROP_BIN" $NAME)
|
||||
[ -z $VALUE ] || [ $VALUE = $EXPECTED ] || "$RESETPROP_BIN" $NAME $EXPECTED
|
||||
}
|
||||
|
||||
check_missing_prop() {
|
||||
local NAME=$1
|
||||
local EXPECTED=$2
|
||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
||||
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
||||
local VALUE=$("$RESETPROP_BIN" $NAME)
|
||||
[ -z $VALUE ] && "$RESETPROP_BIN" $NAME $EXPECTED
|
||||
}
|
||||
|
||||
check_missing_match_prop() {
|
||||
local NAME=$1
|
||||
local EXPECTED=$2
|
||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
||||
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
||||
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
||||
local VALUE=$("$RESETPROP_BIN" $NAME)
|
||||
[ -z $VALUE ] || [ $VALUE = $EXPECTED ] || "$RESETPROP_BIN" $NAME $EXPECTED
|
||||
[ -z $VALUE ] && "$RESETPROP_BIN" $NAME $EXPECTED
|
||||
}
|
||||
|
||||
contains_reset_prop() {
|
||||
local NAME=$1
|
||||
local CONTAINS=$2
|
||||
local NEWVAL=$3
|
||||
case "$("${'$'}RESETPROP_BIN" ${'$'}NAME)" in
|
||||
*"${'$'}CONTAINS"*) "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}NEWVAL ;;
|
||||
case "$("$RESETPROP_BIN" $NAME)" in
|
||||
*"$CONTAINS"*) "$RESETPROP_BIN" $NAME $NEWVAL ;;
|
||||
esac
|
||||
}
|
||||
""".trimIndent())
|
||||
appendLine()
|
||||
appendLine("sleep 30")
|
||||
appendLine()
|
||||
appendLine("\"${'$'}RESETPROP_BIN\" -w sys.boot_completed 0")
|
||||
appendLine($$"\"$RESETPROP_BIN\" -w sys.boot_completed 0")
|
||||
|
||||
// 添加所有系统属性重置
|
||||
val systemProps = listOf(
|
||||
@@ -292,27 +292,28 @@ object ScriptGenerator {
|
||||
// 清理残留脚本生成
|
||||
private fun StringBuilder.generateCleanupResidueSection() {
|
||||
appendLine("# 清理工具残留文件")
|
||||
appendLine("echo \"$(get_current_time): 开始清理工具残留\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): 开始清理工具残留\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
|
||||
// 定义清理函数
|
||||
appendLine("""
|
||||
appendLine(
|
||||
$$"""
|
||||
cleanup_path() {
|
||||
local path="$1"
|
||||
local desc="$2"
|
||||
local current="$3"
|
||||
local total="$4"
|
||||
|
||||
if [ -n "${'$'}desc" ]; then
|
||||
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path (${'$'}desc)" >> "${'$'}LOG_FILE"
|
||||
if [ -n "$desc" ]; then
|
||||
echo "$(get_current_time): [$current/$total] 清理: $path ($desc)" >> "$LOG_FILE"
|
||||
else
|
||||
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path" >> "${'$'}LOG_FILE"
|
||||
echo "$(get_current_time): [$current/$total] 清理: $path" >> "$LOG_FILE"
|
||||
fi
|
||||
|
||||
if rm -rf "${'$'}path" 2>/dev/null; then
|
||||
echo "$(get_current_time): ✓ 成功清理: ${'$'}path" >> "${'$'}LOG_FILE"
|
||||
if rm -rf "$path" 2>/dev/null; then
|
||||
echo "$(get_current_time): ✓ 成功清理: $path" >> "$LOG_FILE"
|
||||
else
|
||||
echo "$(get_current_time): ✗ 清理失败或不存在: ${'$'}path" >> "${'$'}LOG_FILE"
|
||||
echo "$(get_current_time): ✗ 清理失败或不存在: $path" >> "$LOG_FILE"
|
||||
fi
|
||||
}
|
||||
""".trimIndent())
|
||||
@@ -360,11 +361,11 @@ object ScriptGenerator {
|
||||
|
||||
cleanupPaths.forEachIndexed { index, (path, desc) ->
|
||||
val current = index + 1
|
||||
appendLine("cleanup_path '$path' '$desc' $current \$TOTAL")
|
||||
appendLine($$"cleanup_path '$$path' '$$desc' $$current $TOTAL")
|
||||
}
|
||||
|
||||
appendLine()
|
||||
appendLine("echo \"$(get_current_time): 工具残留清理完成\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): 工具残留清理完成\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
@@ -381,36 +382,34 @@ object ScriptGenerator {
|
||||
appendLine()
|
||||
appendLine(generateBinaryCheck(config.targetPath))
|
||||
appendLine()
|
||||
appendLine("echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
|
||||
// 设置uname和构建时间 - 只有在选择在post-fs-data中执行时才执行
|
||||
if (config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
|
||||
appendLine("# 设置uname和构建时间")
|
||||
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'")
|
||||
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" set_uname '$${config.unameValue}' '$${config.buildTimeValue}'")
|
||||
appendLine($$"echo \"$(get_current_time): 设置uname为: $${config.unameValue}, 构建时间为: $${config.buildTimeValue}\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService, config.support158)
|
||||
generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService)
|
||||
|
||||
// 添加AVC日志欺骗设置
|
||||
generateAvcLogSpoofingSection(config.enableAvcLogSpoofing)
|
||||
|
||||
appendLine("echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新的生成方法
|
||||
private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean, support158: Boolean) {
|
||||
if (support158) {
|
||||
private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean) {
|
||||
appendLine("# 设置Zygote隔离服务卸载状态")
|
||||
val umountValue = if (umountForZygoteIsoService) 1 else 0
|
||||
appendLine("\"${'$'}SUSFS_BIN\" umount_for_zygote_iso_service $umountValue")
|
||||
appendLine("echo \"$(get_current_time): Zygote隔离服务卸载设置为: ${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" umount_for_zygote_iso_service $$umountValue")
|
||||
appendLine($$"echo \"$(get_current_time): Zygote隔离服务卸载设置为: $${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成post-mount.sh脚本内容
|
||||
@@ -423,37 +422,12 @@ object ScriptGenerator {
|
||||
appendLine()
|
||||
appendLine(generateLogSetup("susfs_post_mount.log"))
|
||||
appendLine()
|
||||
appendLine("echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
appendLine(generateBinaryCheck(config.targetPath))
|
||||
appendLine()
|
||||
|
||||
// 添加SUS挂载
|
||||
if (config.susMounts.isNotEmpty()) {
|
||||
appendLine("# 添加SUS挂载")
|
||||
config.susMounts.forEach { mount ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_mount '$mount'")
|
||||
appendLine("echo \"$(get_current_time): 添加SUS挂载: $mount\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
|
||||
// 添加尝试卸载
|
||||
if (config.tryUmounts.isNotEmpty()) {
|
||||
appendLine("# 添加尝试卸载")
|
||||
config.tryUmounts.forEach { umount ->
|
||||
val parts = umount.split("|")
|
||||
if (parts.size == 2) {
|
||||
val path = parts[0]
|
||||
val mode = parts[1]
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_try_umount '$path' $mode")
|
||||
appendLine("echo \"$(get_current_time): 添加尝试卸载: $path (模式: $mode)\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
|
||||
appendLine("echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,18 +443,16 @@ object ScriptGenerator {
|
||||
appendLine()
|
||||
appendLine(generateLogSetup("susfs_boot_completed.log"))
|
||||
appendLine()
|
||||
appendLine("echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
appendLine(generateBinaryCheck(config.targetPath))
|
||||
appendLine()
|
||||
|
||||
// 仅在支持隐藏挂载功能时执行相关配置
|
||||
if (config.support158) {
|
||||
// SUS挂载隐藏控制
|
||||
val hideValue = if (config.hideSusMountsForAllProcs) 1 else 0
|
||||
appendLine("# 设置SUS挂载隐藏控制")
|
||||
appendLine("\"${'$'}SUSFS_BIN\" hide_sus_mnts_for_all_procs $hideValue")
|
||||
appendLine("echo \"$(get_current_time): SUS挂载隐藏控制设置为: ${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" hide_sus_mnts_for_all_procs $$hideValue")
|
||||
appendLine($$"echo \"$(get_current_time): SUS挂载隐藏控制设置为: $${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
|
||||
// 路径设置和SUS路径设置
|
||||
@@ -502,9 +474,8 @@ object ScriptGenerator {
|
||||
generateSusMapsSection(config.susMaps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendLine("echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,8 +483,8 @@ object ScriptGenerator {
|
||||
if (susMaps.isNotEmpty()) {
|
||||
appendLine("# 添加SUS映射")
|
||||
susMaps.forEach { map ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_map '$map'")
|
||||
appendLine("echo \"$(get_current_time): 添加SUS映射: $map\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" add_sus_map '$$map'")
|
||||
appendLine($$"echo \"$(get_current_time): 添加SUS映射: $$map\" >> \"$LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
@@ -526,12 +497,12 @@ object ScriptGenerator {
|
||||
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
|
||||
appendLine("sleep 60")
|
||||
appendLine()
|
||||
appendLine("\"${'$'}SUSFS_BIN\" set_android_data_root_path '$androidDataPath'")
|
||||
appendLine("echo \"$(get_current_time): Android Data路径设置为: $androidDataPath\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" set_android_data_root_path '$$androidDataPath'")
|
||||
appendLine($$"echo \"$(get_current_time): Android Data路径设置为: $$androidDataPath\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
appendLine("# 设置SD卡路径")
|
||||
appendLine("\"${'$'}SUSFS_BIN\" set_sdcard_root_path '$sdcardPath'")
|
||||
appendLine("echo \"$(get_current_time): SD卡路径设置为: $sdcardPath\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine($$"\"$SUSFS_BIN\" set_sdcard_root_path '$$sdcardPath'")
|
||||
appendLine($$"echo \"$(get_current_time): SD卡路径设置为: $$sdcardPath\" >> \"$LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
@@ -539,8 +510,8 @@ object ScriptGenerator {
|
||||
* 生成module.prop文件内容
|
||||
*/
|
||||
fun generateModuleProp(moduleId: String): String {
|
||||
val moduleVersion = "v1.0.2"
|
||||
val moduleVersionCode = "1002"
|
||||
val moduleVersion = "v4.0.0"
|
||||
val moduleVersionCode = "40000"
|
||||
|
||||
return """
|
||||
id=$moduleId
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
package com.sukisu.ultra.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Stable
|
||||
object CardConfig {
|
||||
// 卡片透明度
|
||||
var cardAlpha by mutableFloatStateOf(1f)
|
||||
internal set
|
||||
// 卡片亮度
|
||||
var cardDim by mutableFloatStateOf(0f)
|
||||
internal set
|
||||
// 卡片阴影
|
||||
var cardElevation by mutableStateOf(0.dp)
|
||||
internal set
|
||||
|
||||
// 功能开关
|
||||
var isShadowEnabled by mutableStateOf(true)
|
||||
internal set
|
||||
var isCustomBackgroundEnabled by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
var isCustomAlphaSet by mutableStateOf(false)
|
||||
internal set
|
||||
var isCustomDimSet by mutableStateOf(false)
|
||||
internal set
|
||||
var isUserDarkModeEnabled by mutableStateOf(false)
|
||||
internal set
|
||||
var isUserLightModeEnabled by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
// 配置键名
|
||||
private object Keys {
|
||||
const val CARD_ALPHA = "card_alpha"
|
||||
const val CARD_DIM = "card_dim"
|
||||
const val CUSTOM_BACKGROUND_ENABLED = "custom_background_enabled"
|
||||
const val IS_SHADOW_ENABLED = "is_shadow_enabled"
|
||||
const val IS_CUSTOM_ALPHA_SET = "is_custom_alpha_set"
|
||||
const val IS_CUSTOM_DIM_SET = "is_custom_dim_set"
|
||||
const val IS_USER_DARK_MODE_ENABLED = "is_user_dark_mode_enabled"
|
||||
const val IS_USER_LIGHT_MODE_ENABLED = "is_user_light_mode_enabled"
|
||||
}
|
||||
|
||||
fun updateAlpha(alpha: Float, isCustom: Boolean = true) {
|
||||
cardAlpha = alpha.coerceIn(0f, 1f)
|
||||
if (isCustom) isCustomAlphaSet = true
|
||||
}
|
||||
|
||||
fun updateDim(dim: Float, isCustom: Boolean = true) {
|
||||
cardDim = dim.coerceIn(0f, 1f)
|
||||
if (isCustom) isCustomDimSet = true
|
||||
}
|
||||
|
||||
fun updateShadow(enabled: Boolean, elevation: Dp = cardElevation) {
|
||||
isShadowEnabled = enabled
|
||||
cardElevation = if (enabled) elevation else cardElevation
|
||||
}
|
||||
|
||||
fun updateBackground(enabled: Boolean) {
|
||||
isCustomBackgroundEnabled = enabled
|
||||
// 自定义背景时自动禁用阴影以获得更好的视觉效果
|
||||
if (enabled) {
|
||||
updateShadow(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateThemePreference(darkMode: Boolean?, lightMode: Boolean?) {
|
||||
isUserDarkModeEnabled = darkMode ?: false
|
||||
isUserLightModeEnabled = lightMode ?: false
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
cardAlpha = 1f
|
||||
cardDim = 0f
|
||||
cardElevation = 0.dp
|
||||
isShadowEnabled = true
|
||||
isCustomBackgroundEnabled = false
|
||||
isCustomAlphaSet = false
|
||||
isCustomDimSet = false
|
||||
isUserDarkModeEnabled = false
|
||||
isUserLightModeEnabled = false
|
||||
}
|
||||
|
||||
fun setThemeDefaults(isDarkMode: Boolean) {
|
||||
if (!isCustomAlphaSet) {
|
||||
updateAlpha(if (isDarkMode) 0.88f else 1f, false)
|
||||
}
|
||||
if (!isCustomDimSet) {
|
||||
updateDim(if (isDarkMode) 0.25f else 0f, false)
|
||||
}
|
||||
// 暗色模式下默认启用轻微阴影
|
||||
if (isDarkMode && !isCustomBackgroundEnabled) {
|
||||
updateShadow(true, 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
fun save(context: Context) {
|
||||
val prefs = context.getSharedPreferences("card_settings", Context.MODE_PRIVATE)
|
||||
prefs.edit().apply {
|
||||
putFloat(Keys.CARD_ALPHA, cardAlpha)
|
||||
putFloat(Keys.CARD_DIM, cardDim)
|
||||
putBoolean(Keys.CUSTOM_BACKGROUND_ENABLED, isCustomBackgroundEnabled)
|
||||
putBoolean(Keys.IS_SHADOW_ENABLED, isShadowEnabled)
|
||||
putBoolean(Keys.IS_CUSTOM_ALPHA_SET, isCustomAlphaSet)
|
||||
putBoolean(Keys.IS_CUSTOM_DIM_SET, isCustomDimSet)
|
||||
putBoolean(Keys.IS_USER_DARK_MODE_ENABLED, isUserDarkModeEnabled)
|
||||
putBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, isUserLightModeEnabled)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun load(context: Context) {
|
||||
val prefs = context.getSharedPreferences("card_settings", Context.MODE_PRIVATE)
|
||||
cardAlpha = prefs.getFloat(Keys.CARD_ALPHA, 1f).coerceIn(0f, 1f)
|
||||
cardDim = prefs.getFloat(Keys.CARD_DIM, 0f).coerceIn(0f, 1f)
|
||||
isCustomBackgroundEnabled = prefs.getBoolean(Keys.CUSTOM_BACKGROUND_ENABLED, false)
|
||||
isShadowEnabled = prefs.getBoolean(Keys.IS_SHADOW_ENABLED, true)
|
||||
isCustomAlphaSet = prefs.getBoolean(Keys.IS_CUSTOM_ALPHA_SET, false)
|
||||
isCustomDimSet = prefs.getBoolean(Keys.IS_CUSTOM_DIM_SET, false)
|
||||
isUserDarkModeEnabled = prefs.getBoolean(Keys.IS_USER_DARK_MODE_ENABLED, false)
|
||||
isUserLightModeEnabled = prefs.getBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, false)
|
||||
|
||||
// 应用阴影设置
|
||||
updateShadow(isShadowEnabled, if (isShadowEnabled) cardElevation else 0.dp)
|
||||
}
|
||||
|
||||
@Deprecated("使用 updateShadow 替代", ReplaceWith("updateShadow(enabled)"))
|
||||
fun updateShadowEnabled(enabled: Boolean) {
|
||||
updateShadow(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
object CardStyleProvider {
|
||||
|
||||
@Composable
|
||||
fun getCardColors(originalColor: Color) = CardDefaults.cardColors(
|
||||
containerColor = originalColor.copy(alpha = CardConfig.cardAlpha),
|
||||
contentColor = determineContentColor(originalColor),
|
||||
disabledContainerColor = originalColor.copy(alpha = CardConfig.cardAlpha * 0.38f),
|
||||
disabledContentColor = determineContentColor(originalColor).copy(alpha = 0.38f)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun getCardElevation() = CardDefaults.cardElevation(
|
||||
defaultElevation = CardConfig.cardElevation,
|
||||
pressedElevation = if (CardConfig.isShadowEnabled) {
|
||||
(CardConfig.cardElevation.value + 0).dp
|
||||
} else 0.dp,
|
||||
focusedElevation = if (CardConfig.isShadowEnabled) {
|
||||
(CardConfig.cardElevation.value + 0).dp
|
||||
} else 0.dp,
|
||||
hoveredElevation = if (CardConfig.isShadowEnabled) {
|
||||
(CardConfig.cardElevation.value + 0).dp
|
||||
} else 0.dp,
|
||||
draggedElevation = if (CardConfig.isShadowEnabled) {
|
||||
(CardConfig.cardElevation.value + 0).dp
|
||||
} else 0.dp,
|
||||
disabledElevation = 0.dp
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun determineContentColor(originalColor: Color): Color {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
return when {
|
||||
ThemeConfig.isThemeChanging -> {
|
||||
if (isDarkTheme) Color.White else Color.Black
|
||||
}
|
||||
CardConfig.isUserLightModeEnabled -> Color.Black
|
||||
CardConfig.isUserDarkModeEnabled -> Color.White
|
||||
else -> {
|
||||
val luminance = originalColor.luminance()
|
||||
val threshold = if (isDarkTheme) 0.4f else 0.6f
|
||||
if (luminance > threshold) Color.Black else Color.White
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容
|
||||
@Composable
|
||||
fun getCardColors(originalColor: Color) = CardStyleProvider.getCardColors(originalColor)
|
||||
|
||||
@Composable
|
||||
fun getCardElevation() = CardStyleProvider.getCardElevation()
|
||||
@@ -1,615 +0,0 @@
|
||||
package com.sukisu.ultra.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
sealed class ThemeColors {
|
||||
// 浅色
|
||||
abstract val primaryLight: Color
|
||||
abstract val onPrimaryLight: Color
|
||||
abstract val primaryContainerLight: Color
|
||||
abstract val onPrimaryContainerLight: Color
|
||||
abstract val secondaryLight: Color
|
||||
abstract val onSecondaryLight: Color
|
||||
abstract val secondaryContainerLight: Color
|
||||
abstract val onSecondaryContainerLight: Color
|
||||
abstract val tertiaryLight: Color
|
||||
abstract val onTertiaryLight: Color
|
||||
abstract val tertiaryContainerLight: Color
|
||||
abstract val onTertiaryContainerLight: Color
|
||||
abstract val errorLight: Color
|
||||
abstract val onErrorLight: Color
|
||||
abstract val errorContainerLight: Color
|
||||
abstract val onErrorContainerLight: Color
|
||||
abstract val backgroundLight: Color
|
||||
abstract val onBackgroundLight: Color
|
||||
abstract val surfaceLight: Color
|
||||
abstract val onSurfaceLight: Color
|
||||
abstract val surfaceVariantLight: Color
|
||||
abstract val onSurfaceVariantLight: Color
|
||||
abstract val outlineLight: Color
|
||||
abstract val outlineVariantLight: Color
|
||||
abstract val scrimLight: Color
|
||||
abstract val inverseSurfaceLight: Color
|
||||
abstract val inverseOnSurfaceLight: Color
|
||||
abstract val inversePrimaryLight: Color
|
||||
abstract val surfaceDimLight: Color
|
||||
abstract val surfaceBrightLight: Color
|
||||
abstract val surfaceContainerLowestLight: Color
|
||||
abstract val surfaceContainerLowLight: Color
|
||||
abstract val surfaceContainerLight: Color
|
||||
abstract val surfaceContainerHighLight: Color
|
||||
abstract val surfaceContainerHighestLight: Color
|
||||
// 深色
|
||||
abstract val primaryDark: Color
|
||||
abstract val onPrimaryDark: Color
|
||||
abstract val primaryContainerDark: Color
|
||||
abstract val onPrimaryContainerDark: Color
|
||||
abstract val secondaryDark: Color
|
||||
abstract val onSecondaryDark: Color
|
||||
abstract val secondaryContainerDark: Color
|
||||
abstract val onSecondaryContainerDark: Color
|
||||
abstract val tertiaryDark: Color
|
||||
abstract val onTertiaryDark: Color
|
||||
abstract val tertiaryContainerDark: Color
|
||||
abstract val onTertiaryContainerDark: Color
|
||||
abstract val errorDark: Color
|
||||
abstract val onErrorDark: Color
|
||||
abstract val errorContainerDark: Color
|
||||
abstract val onErrorContainerDark: Color
|
||||
abstract val backgroundDark: Color
|
||||
abstract val onBackgroundDark: Color
|
||||
abstract val surfaceDark: Color
|
||||
abstract val onSurfaceDark: Color
|
||||
abstract val surfaceVariantDark: Color
|
||||
abstract val onSurfaceVariantDark: Color
|
||||
abstract val outlineDark: Color
|
||||
abstract val outlineVariantDark: Color
|
||||
abstract val scrimDark: Color
|
||||
abstract val inverseSurfaceDark: Color
|
||||
abstract val inverseOnSurfaceDark: Color
|
||||
abstract val inversePrimaryDark: Color
|
||||
abstract val surfaceDimDark: Color
|
||||
abstract val surfaceBrightDark: Color
|
||||
abstract val surfaceContainerLowestDark: Color
|
||||
abstract val surfaceContainerLowDark: Color
|
||||
abstract val surfaceContainerDark: Color
|
||||
abstract val surfaceContainerHighDark: Color
|
||||
abstract val surfaceContainerHighestDark: Color
|
||||
|
||||
// 默认主题 (蓝色)
|
||||
object Default : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF415F91)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFD6E3FF)
|
||||
override val onPrimaryContainerLight = Color(0xFF284777)
|
||||
override val secondaryLight = Color(0xFF565F71)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFDAE2F9)
|
||||
override val onSecondaryContainerLight = Color(0xFF3E4759)
|
||||
override val tertiaryLight = Color(0xFF705575)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFFAD8FD)
|
||||
override val onTertiaryContainerLight = Color(0xFF573E5C)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFF9F9FF)
|
||||
override val onBackgroundLight = Color(0xFF191C20)
|
||||
override val surfaceLight = Color(0xFFF9F9FF)
|
||||
override val onSurfaceLight = Color(0xFF191C20)
|
||||
override val surfaceVariantLight = Color(0xFFE0E2EC)
|
||||
override val onSurfaceVariantLight = Color(0xFF44474E)
|
||||
override val outlineLight = Color(0xFF74777F)
|
||||
override val outlineVariantLight = Color(0xFFC4C6D0)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF2E3036)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF0F0F7)
|
||||
override val inversePrimaryLight = Color(0xFFAAC7FF)
|
||||
override val surfaceDimLight = Color(0xFFD9D9E0)
|
||||
override val surfaceBrightLight = Color(0xFFF9F9FF)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFF3F3FA)
|
||||
override val surfaceContainerLight = Color(0xFFEDEDF4)
|
||||
override val surfaceContainerHighLight = Color(0xFFE7E8EE)
|
||||
override val surfaceContainerHighestLight = Color(0xFFE2E2E9)
|
||||
|
||||
override val primaryDark = Color(0xFFAAC7FF)
|
||||
override val onPrimaryDark = Color(0xFF0A305F)
|
||||
override val primaryContainerDark = Color(0xFF284777)
|
||||
override val onPrimaryContainerDark = Color(0xFFD6E3FF)
|
||||
override val secondaryDark = Color(0xFFBEC6DC)
|
||||
override val onSecondaryDark = Color(0xFF283141)
|
||||
override val secondaryContainerDark = Color(0xFF3E4759)
|
||||
override val onSecondaryContainerDark = Color(0xFFDAE2F9)
|
||||
override val tertiaryDark = Color(0xFFDDBCE0)
|
||||
override val onTertiaryDark = Color(0xFF3F2844)
|
||||
override val tertiaryContainerDark = Color(0xFF573E5C)
|
||||
override val onTertiaryContainerDark = Color(0xFFFAD8FD)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF111318)
|
||||
override val onBackgroundDark = Color(0xFFE2E2E9)
|
||||
override val surfaceDark = Color(0xFF111318)
|
||||
override val onSurfaceDark = Color(0xFFE2E2E9)
|
||||
override val surfaceVariantDark = Color(0xFF44474E)
|
||||
override val onSurfaceVariantDark = Color(0xFFC4C6D0)
|
||||
override val outlineDark = Color(0xFF8E9099)
|
||||
override val outlineVariantDark = Color(0xFF44474E)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFE2E2E9)
|
||||
override val inverseOnSurfaceDark = Color(0xFF2E3036)
|
||||
override val inversePrimaryDark = Color(0xFF415F91)
|
||||
override val surfaceDimDark = Color(0xFF111318)
|
||||
override val surfaceBrightDark = Color(0xFF37393E)
|
||||
override val surfaceContainerLowestDark = Color(0xFF0C0E13)
|
||||
override val surfaceContainerLowDark = Color(0xFF191C20)
|
||||
override val surfaceContainerDark = Color(0xFF1D2024)
|
||||
override val surfaceContainerHighDark = Color(0xFF282A2F)
|
||||
override val surfaceContainerHighestDark = Color(0xFF33353A)
|
||||
}
|
||||
|
||||
// 绿色主题
|
||||
object Green : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF4C662B)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFCDEDA3)
|
||||
override val onPrimaryContainerLight = Color(0xFF354E16)
|
||||
override val secondaryLight = Color(0xFF586249)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFDCE7C8)
|
||||
override val onSecondaryContainerLight = Color(0xFF404A33)
|
||||
override val tertiaryLight = Color(0xFF386663)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFBCECE7)
|
||||
override val onTertiaryContainerLight = Color(0xFF1F4E4B)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFF9FAEF)
|
||||
override val onBackgroundLight = Color(0xFF1A1C16)
|
||||
override val surfaceLight = Color(0xFFF9FAEF)
|
||||
override val onSurfaceLight = Color(0xFF1A1C16)
|
||||
override val surfaceVariantLight = Color(0xFFE1E4D5)
|
||||
override val onSurfaceVariantLight = Color(0xFF44483D)
|
||||
override val outlineLight = Color(0xFF75796C)
|
||||
override val outlineVariantLight = Color(0xFFC5C8BA)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF2F312A)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF1F2E6)
|
||||
override val inversePrimaryLight = Color(0xFFB1D18A)
|
||||
override val surfaceDimLight = Color(0xFFDADBD0)
|
||||
override val surfaceBrightLight = Color(0xFFF9FAEF)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFF3F4E9)
|
||||
override val surfaceContainerLight = Color(0xFFEEEFE3)
|
||||
override val surfaceContainerHighLight = Color(0xFFE8E9DE)
|
||||
override val surfaceContainerHighestLight = Color(0xFFE2E3D8)
|
||||
|
||||
override val primaryDark = Color(0xFFB1D18A)
|
||||
override val onPrimaryDark = Color(0xFF1F3701)
|
||||
override val primaryContainerDark = Color(0xFF354E16)
|
||||
override val onPrimaryContainerDark = Color(0xFFCDEDA3)
|
||||
override val secondaryDark = Color(0xFFBFCBAD)
|
||||
override val onSecondaryDark = Color(0xFF2A331E)
|
||||
override val secondaryContainerDark = Color(0xFF404A33)
|
||||
override val onSecondaryContainerDark = Color(0xFFDCE7C8)
|
||||
override val tertiaryDark = Color(0xFFA0D0CB)
|
||||
override val onTertiaryDark = Color(0xFF003735)
|
||||
override val tertiaryContainerDark = Color(0xFF1F4E4B)
|
||||
override val onTertiaryContainerDark = Color(0xFFBCECE7)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF12140E)
|
||||
override val onBackgroundDark = Color(0xFFE2E3D8)
|
||||
override val surfaceDark = Color(0xFF12140E)
|
||||
override val onSurfaceDark = Color(0xFFE2E3D8)
|
||||
override val surfaceVariantDark = Color(0xFF44483D)
|
||||
override val onSurfaceVariantDark = Color(0xFFC5C8BA)
|
||||
override val outlineDark = Color(0xFF8F9285)
|
||||
override val outlineVariantDark = Color(0xFF44483D)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFE2E3D8)
|
||||
override val inverseOnSurfaceDark = Color(0xFF2F312A)
|
||||
override val inversePrimaryDark = Color(0xFF4C662B)
|
||||
override val surfaceDimDark = Color(0xFF12140E)
|
||||
override val surfaceBrightDark = Color(0xFF383A32)
|
||||
override val surfaceContainerLowestDark = Color(0xFF0C0F09)
|
||||
override val surfaceContainerLowDark = Color(0xFF1A1C16)
|
||||
override val surfaceContainerDark = Color(0xFF1E201A)
|
||||
override val surfaceContainerHighDark = Color(0xFF282B24)
|
||||
override val surfaceContainerHighestDark = Color(0xFF33362E)
|
||||
}
|
||||
|
||||
// 紫色主题
|
||||
object Purple : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF7C4E7E)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFFFD6FC)
|
||||
override val onPrimaryContainerLight = Color(0xFF623765)
|
||||
override val secondaryLight = Color(0xFF6C586B)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFF5DBF1)
|
||||
override val onSecondaryContainerLight = Color(0xFF534152)
|
||||
override val tertiaryLight = Color(0xFF825249)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFFFDAD4)
|
||||
override val onTertiaryContainerLight = Color(0xFF673B33)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFFF7FA)
|
||||
override val onBackgroundLight = Color(0xFF1F1A1F)
|
||||
override val surfaceLight = Color(0xFFFFF7FA)
|
||||
override val onSurfaceLight = Color(0xFF1F1A1F)
|
||||
override val surfaceVariantLight = Color(0xFFEDDFE8)
|
||||
override val onSurfaceVariantLight = Color(0xFF4D444C)
|
||||
override val outlineLight = Color(0xFF7F747C)
|
||||
override val outlineVariantLight = Color(0xFFD0C3CC)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF352F34)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF9EEF4)
|
||||
override val inversePrimaryLight = Color(0xFFECB4EC)
|
||||
override val surfaceDimLight = Color(0xFFE2D7DE)
|
||||
override val surfaceBrightLight = Color(0xFFFFF7FA)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFFCF0F7)
|
||||
override val surfaceContainerLight = Color(0xFFF6EBF2)
|
||||
override val surfaceContainerHighLight = Color(0xFFF0E5EC)
|
||||
override val surfaceContainerHighestLight = Color(0xFFEBDFE6)
|
||||
|
||||
override val primaryDark = Color(0xFFECB4EC)
|
||||
override val onPrimaryDark = Color(0xFF49204D)
|
||||
override val primaryContainerDark = Color(0xFF623765)
|
||||
override val onPrimaryContainerDark = Color(0xFFFFD6FC)
|
||||
override val secondaryDark = Color(0xFFD8BFD5)
|
||||
override val onSecondaryDark = Color(0xFF3B2B3B)
|
||||
override val secondaryContainerDark = Color(0xFF534152)
|
||||
override val onSecondaryContainerDark = Color(0xFFF5DBF1)
|
||||
override val tertiaryDark = Color(0xFFF6B8AD)
|
||||
override val onTertiaryDark = Color(0xFF4C251F)
|
||||
override val tertiaryContainerDark = Color(0xFF673B33)
|
||||
override val onTertiaryContainerDark = Color(0xFFFFDAD4)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF171216)
|
||||
override val onBackgroundDark = Color(0xFFEBDFE6)
|
||||
override val surfaceDark = Color(0xFF171216)
|
||||
override val onSurfaceDark = Color(0xFFEBDFE6)
|
||||
override val surfaceVariantDark = Color(0xFF4D444C)
|
||||
override val onSurfaceVariantDark = Color(0xFFD0C3CC)
|
||||
override val outlineDark = Color(0xFF998D96)
|
||||
override val outlineVariantDark = Color(0xFF4D444C)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFEBDFE6)
|
||||
override val inverseOnSurfaceDark = Color(0xFF352F34)
|
||||
override val inversePrimaryDark = Color(0xFF7C4E7E)
|
||||
override val surfaceDimDark = Color(0xFF171216)
|
||||
override val surfaceBrightDark = Color(0xFF3E373D)
|
||||
override val surfaceContainerLowestDark = Color(0xFF110D11)
|
||||
override val surfaceContainerLowDark = Color(0xFF1F1A1F)
|
||||
override val surfaceContainerDark = Color(0xFF231E23)
|
||||
override val surfaceContainerHighDark = Color(0xFF2E282D)
|
||||
override val surfaceContainerHighestDark = Color(0xFF393338)
|
||||
}
|
||||
|
||||
// 橙色主题
|
||||
object Orange : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF8B4F24)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFFFDCC7)
|
||||
override val onPrimaryContainerLight = Color(0xFF6E390E)
|
||||
override val secondaryLight = Color(0xFF755846)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFFFDCC7)
|
||||
override val onSecondaryContainerLight = Color(0xFF5B4130)
|
||||
override val tertiaryLight = Color(0xFF865219)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFFFDCBF)
|
||||
override val onTertiaryContainerLight = Color(0xFF6A3B01)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFFF8F5)
|
||||
override val onBackgroundLight = Color(0xFF221A15)
|
||||
override val surfaceLight = Color(0xFFFFF8F5)
|
||||
override val onSurfaceLight = Color(0xFF221A15)
|
||||
override val surfaceVariantLight = Color(0xFFF4DED3)
|
||||
override val onSurfaceVariantLight = Color(0xFF52443C)
|
||||
override val outlineLight = Color(0xFF84746A)
|
||||
override val outlineVariantLight = Color(0xFFD7C3B8)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF382E29)
|
||||
override val inverseOnSurfaceLight = Color(0xFFFFEDE5)
|
||||
override val inversePrimaryLight = Color(0xFFFFB787)
|
||||
override val surfaceDimLight = Color(0xFFE7D7CE)
|
||||
override val surfaceBrightLight = Color(0xFFFFF8F5)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFFFF1EA)
|
||||
override val surfaceContainerLight = Color(0xFFFCEBE2)
|
||||
override val surfaceContainerHighLight = Color(0xFFF6E5DC)
|
||||
override val surfaceContainerHighestLight = Color(0xFFF0DFD7)
|
||||
|
||||
override val primaryDark = Color(0xFFFFB787)
|
||||
override val onPrimaryDark = Color(0xFF502400)
|
||||
override val primaryContainerDark = Color(0xFF6E390E)
|
||||
override val onPrimaryContainerDark = Color(0xFFFFDCC7)
|
||||
override val secondaryDark = Color(0xFFE5BFA8)
|
||||
override val onSecondaryDark = Color(0xFF422B1B)
|
||||
override val secondaryContainerDark = Color(0xFF5B4130)
|
||||
override val onSecondaryContainerDark = Color(0xFFFFDCC7)
|
||||
override val tertiaryDark = Color(0xFFFDB876)
|
||||
override val onTertiaryDark = Color(0xFF4B2800)
|
||||
override val tertiaryContainerDark = Color(0xFF6A3B01)
|
||||
override val onTertiaryContainerDark = Color(0xFFFFDCBF)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF19120D)
|
||||
override val onBackgroundDark = Color(0xFFF0DFD7)
|
||||
override val surfaceDark = Color(0xFF19120D)
|
||||
override val onSurfaceDark = Color(0xFFF0DFD7)
|
||||
override val surfaceVariantDark = Color(0xFF52443C)
|
||||
override val onSurfaceVariantDark = Color(0xFFD7C3B8)
|
||||
override val outlineDark = Color(0xFF9F8D83)
|
||||
override val outlineVariantDark = Color(0xFF52443C)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFF0DFD7)
|
||||
override val inverseOnSurfaceDark = Color(0xFF382E29)
|
||||
override val inversePrimaryDark = Color(0xFF8B4F24)
|
||||
override val surfaceDimDark = Color(0xFF19120D)
|
||||
override val surfaceBrightDark = Color(0xFF413731)
|
||||
override val surfaceContainerLowestDark = Color(0xFF140D08)
|
||||
override val surfaceContainerLowDark = Color(0xFF221A15)
|
||||
override val surfaceContainerDark = Color(0xFF261E19)
|
||||
override val surfaceContainerHighDark = Color(0xFF312823)
|
||||
override val surfaceContainerHighestDark = Color(0xFF3D332D)
|
||||
}
|
||||
|
||||
// 粉色主题
|
||||
object Pink : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF8C4A60)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFFFD9E2)
|
||||
override val onPrimaryContainerLight = Color(0xFF703348)
|
||||
override val secondaryLight = Color(0xFF8B4A62)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFFFD9E3)
|
||||
override val onSecondaryContainerLight = Color(0xFF6F334B)
|
||||
override val tertiaryLight = Color(0xFF8B4A62)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFFFD9E3)
|
||||
override val onTertiaryContainerLight = Color(0xFF6F334B)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFFF8F8)
|
||||
override val onBackgroundLight = Color(0xFF22191B)
|
||||
override val surfaceLight = Color(0xFFFFF8F8)
|
||||
override val onSurfaceLight = Color(0xFF22191B)
|
||||
override val surfaceVariantLight = Color(0xFFF2DDE1)
|
||||
override val onSurfaceVariantLight = Color(0xFF514346)
|
||||
override val outlineLight = Color(0xFF837377)
|
||||
override val outlineVariantLight = Color(0xFFD5C2C5)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF372E30)
|
||||
override val inverseOnSurfaceLight = Color(0xFFFDEDEF)
|
||||
override val inversePrimaryLight = Color(0xFFFFB1C7)
|
||||
override val surfaceDimLight = Color(0xFFE6D6D9)
|
||||
override val surfaceBrightLight = Color(0xFFFFF8F8)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFFFF0F2)
|
||||
override val surfaceContainerLight = Color(0xFFFBEAED)
|
||||
override val surfaceContainerHighLight = Color(0xFFF5E4E7)
|
||||
override val surfaceContainerHighestLight = Color(0xFFEFDFE1)
|
||||
|
||||
override val primaryDark = Color(0xFFFFB1C7)
|
||||
override val onPrimaryDark = Color(0xFF541D32)
|
||||
override val primaryContainerDark = Color(0xFF703348)
|
||||
override val onPrimaryContainerDark = Color(0xFFFFD9E2)
|
||||
override val secondaryDark = Color(0xFFFFB0CB)
|
||||
override val onSecondaryDark = Color(0xFF541D34)
|
||||
override val secondaryContainerDark = Color(0xFF6F334B)
|
||||
override val onSecondaryContainerDark = Color(0xFFFFD9E3)
|
||||
override val tertiaryDark = Color(0xFFFFB0CB)
|
||||
override val onTertiaryDark = Color(0xFF541D34)
|
||||
override val tertiaryContainerDark = Color(0xFF6F334B)
|
||||
override val onTertiaryContainerDark = Color(0xFFFFD9E3)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF191113)
|
||||
override val onBackgroundDark = Color(0xFFEFDFE1)
|
||||
override val surfaceDark = Color(0xFF191113)
|
||||
override val onSurfaceDark = Color(0xFFEFDFE1)
|
||||
override val surfaceVariantDark = Color(0xFF514346)
|
||||
override val onSurfaceVariantDark = Color(0xFFD5C2C5)
|
||||
override val outlineDark = Color(0xFF9E8C90)
|
||||
override val outlineVariantDark = Color(0xFF514346)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFEFDFE1)
|
||||
override val inverseOnSurfaceDark = Color(0xFF372E30)
|
||||
override val inversePrimaryDark = Color(0xFF8C4A60)
|
||||
override val surfaceDimDark = Color(0xFF191113)
|
||||
override val surfaceBrightDark = Color(0xFF413739)
|
||||
override val surfaceContainerLowestDark = Color(0xFF140C0E)
|
||||
override val surfaceContainerLowDark = Color(0xFF22191B)
|
||||
override val surfaceContainerDark = Color(0xFF261D1F)
|
||||
override val surfaceContainerHighDark = Color(0xFF31282A)
|
||||
override val surfaceContainerHighestDark = Color(0xFF3C3234)
|
||||
}
|
||||
|
||||
// 灰色主题
|
||||
object Gray : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF5B5C5C)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFF747474)
|
||||
override val onPrimaryContainerLight = Color(0xFFFEFCFC)
|
||||
override val secondaryLight = Color(0xFF5F5E5E)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFE4E2E1)
|
||||
override val onSecondaryContainerLight = Color(0xFF656464)
|
||||
override val tertiaryLight = Color(0xFF5E5B5D)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFF777375)
|
||||
override val onTertiaryContainerLight = Color(0xFFFFFBFF)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFCF8F8)
|
||||
override val onBackgroundLight = Color(0xFF1C1B1B)
|
||||
override val surfaceLight = Color(0xFFFCF8F8)
|
||||
override val onSurfaceLight = Color(0xFF1C1B1B)
|
||||
override val surfaceVariantLight = Color(0xFFE0E3E3)
|
||||
override val onSurfaceVariantLight = Color(0xFF444748)
|
||||
override val outlineLight = Color(0xFF747878)
|
||||
override val outlineVariantLight = Color(0xFFC4C7C7)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF313030)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF4F0EF)
|
||||
override val inversePrimaryLight = Color(0xFFC7C6C6)
|
||||
override val surfaceDimLight = Color(0xFFDDD9D8)
|
||||
override val surfaceBrightLight = Color(0xFFFCF8F8)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFF7F3F2)
|
||||
override val surfaceContainerLight = Color(0xFFF1EDEC)
|
||||
override val surfaceContainerHighLight = Color(0xFFEBE7E7)
|
||||
override val surfaceContainerHighestLight = Color(0xFFE5E2E1)
|
||||
|
||||
override val primaryDark = Color(0xFFC7C6C6)
|
||||
override val onPrimaryDark = Color(0xFF303031)
|
||||
override val primaryContainerDark = Color(0xFF919190)
|
||||
override val onPrimaryContainerDark = Color(0xFF161718)
|
||||
override val secondaryDark = Color(0xFFC8C6C5)
|
||||
override val onSecondaryDark = Color(0xFF303030)
|
||||
override val secondaryContainerDark = Color(0xFF474746)
|
||||
override val onSecondaryContainerDark = Color(0xFFB7B5B4)
|
||||
override val tertiaryDark = Color(0xFFCAC5C7)
|
||||
override val onTertiaryDark = Color(0xFF323031)
|
||||
override val tertiaryContainerDark = Color(0xFF948F91)
|
||||
override val onTertiaryContainerDark = Color(0xFF181718)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF141313)
|
||||
override val onBackgroundDark = Color(0xFFE5E2E1)
|
||||
override val surfaceDark = Color(0xFF141313)
|
||||
override val onSurfaceDark = Color(0xFFE5E2E1)
|
||||
override val surfaceVariantDark = Color(0xFF444748)
|
||||
override val onSurfaceVariantDark = Color(0xFFC4C7C7)
|
||||
override val outlineDark = Color(0xFF8E9192)
|
||||
override val outlineVariantDark = Color(0xFF444748)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFE5E2E1)
|
||||
override val inverseOnSurfaceDark = Color(0xFF313030)
|
||||
override val inversePrimaryDark = Color(0xFF5E5E5E)
|
||||
override val surfaceDimDark = Color(0xFF141313)
|
||||
override val surfaceBrightDark = Color(0xFF3A3939)
|
||||
override val surfaceContainerLowestDark = Color(0xFF0E0E0E)
|
||||
override val surfaceContainerLowDark = Color(0xFF1C1B1B)
|
||||
override val surfaceContainerDark = Color(0xFF201F1F)
|
||||
override val surfaceContainerHighDark = Color(0xFF2A2A2A)
|
||||
override val surfaceContainerHighestDark = Color(0xFF353434)
|
||||
}
|
||||
|
||||
// 黄色主题
|
||||
object Yellow : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF6D5E0F)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFF8E288)
|
||||
override val onPrimaryContainerLight = Color(0xFF534600)
|
||||
override val secondaryLight = Color(0xFF6D5E0F)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFF7E388)
|
||||
override val onSecondaryContainerLight = Color(0xFF534600)
|
||||
override val tertiaryLight = Color(0xFF685F13)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFF1E58A)
|
||||
override val onTertiaryContainerLight = Color(0xFF4F4800)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFFF9ED)
|
||||
override val onBackgroundLight = Color(0xFF1E1C13)
|
||||
override val surfaceLight = Color(0xFFFFF9ED)
|
||||
override val onSurfaceLight = Color(0xFF1E1C13)
|
||||
override val surfaceVariantLight = Color(0xFFE9E2D0)
|
||||
override val onSurfaceVariantLight = Color(0xFF4B4739)
|
||||
override val outlineLight = Color(0xFF7C7768)
|
||||
override val outlineVariantLight = Color(0xFFCDC6B4)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF333027)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF7F0E2)
|
||||
override val inversePrimaryLight = Color(0xFFDAC66F)
|
||||
override val surfaceDimLight = Color(0xFFE0D9CC)
|
||||
override val surfaceBrightLight = Color(0xFFFFF9ED)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFFAF3E5)
|
||||
override val surfaceContainerLight = Color(0xFFF4EDDF)
|
||||
override val surfaceContainerHighLight = Color(0xFFEEE8DA)
|
||||
override val surfaceContainerHighestLight = Color(0xFFE8E2D4)
|
||||
|
||||
override val primaryDark = Color(0xFFDAC66F)
|
||||
override val onPrimaryDark = Color(0xFF393000)
|
||||
override val primaryContainerDark = Color(0xFF534600)
|
||||
override val onPrimaryContainerDark = Color(0xFFF8E288)
|
||||
override val secondaryDark = Color(0xFFDAC76F)
|
||||
override val onSecondaryDark = Color(0xFF393000)
|
||||
override val secondaryContainerDark = Color(0xFF534600)
|
||||
override val onSecondaryContainerDark = Color(0xFFF7E388)
|
||||
override val tertiaryDark = Color(0xFFD4C871)
|
||||
override val onTertiaryDark = Color(0xFF363100)
|
||||
override val tertiaryContainerDark = Color(0xFF4F4800)
|
||||
override val onTertiaryContainerDark = Color(0xFFF1E58A)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF15130B)
|
||||
override val onBackgroundDark = Color(0xFFE8E2D4)
|
||||
override val surfaceDark = Color(0xFF15130B)
|
||||
override val onSurfaceDark = Color(0xFFE8E2D4)
|
||||
override val surfaceVariantDark = Color(0xFF4B4739)
|
||||
override val onSurfaceVariantDark = Color(0xFFCDC6B4)
|
||||
override val outlineDark = Color(0xFF969080)
|
||||
override val outlineVariantDark = Color(0xFF4B4739)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFE8E2D4)
|
||||
override val inverseOnSurfaceDark = Color(0xFF333027)
|
||||
override val inversePrimaryDark = Color(0xFF6D5E0F)
|
||||
override val surfaceDimDark = Color(0xFF15130B)
|
||||
override val surfaceBrightDark = Color(0xFF3C3930)
|
||||
override val surfaceContainerLowestDark = Color(0xFF100E07)
|
||||
override val surfaceContainerLowDark = Color(0xFF1E1C13)
|
||||
override val surfaceContainerDark = Color(0xFF222017)
|
||||
override val surfaceContainerHighDark = Color(0xFF2C2A21)
|
||||
override val surfaceContainerHighestDark = Color(0xFF37352B)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromName(name: String): ThemeColors = when (name.lowercase()) {
|
||||
"green" -> Green
|
||||
"purple" -> Purple
|
||||
"orange" -> Orange
|
||||
"pink" -> Pink
|
||||
"gray" -> Gray
|
||||
"yellow" -> Yellow
|
||||
else -> Default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,593 +1,57 @@
|
||||
package com.sukisu.ultra.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.paint
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.sukisu.ultra.ui.theme.util.BackgroundTransformation
|
||||
import com.sukisu.ultra.ui.theme.util.saveTransformedBackground
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@Stable
|
||||
object ThemeConfig {
|
||||
// 主题状态
|
||||
var customBackgroundUri by mutableStateOf<Uri?>(null)
|
||||
var forceDarkMode by mutableStateOf<Boolean?>(null)
|
||||
var currentTheme by mutableStateOf<ThemeColors>(ThemeColors.Default)
|
||||
var useDynamicColor by mutableStateOf(false)
|
||||
|
||||
// 背景状态
|
||||
var backgroundImageLoaded by mutableStateOf(false)
|
||||
var isThemeChanging by mutableStateOf(false)
|
||||
var preventBackgroundRefresh by mutableStateOf(false)
|
||||
|
||||
// 主题变化检测
|
||||
private var lastDarkModeState: Boolean? = null
|
||||
|
||||
fun detectThemeChange(currentDarkMode: Boolean): Boolean {
|
||||
val hasChanged = lastDarkModeState != null && lastDarkModeState != currentDarkMode
|
||||
lastDarkModeState = currentDarkMode
|
||||
return hasChanged
|
||||
}
|
||||
|
||||
fun resetBackgroundState() {
|
||||
if (!preventBackgroundRefresh) {
|
||||
backgroundImageLoaded = false
|
||||
}
|
||||
isThemeChanging = true
|
||||
}
|
||||
|
||||
fun updateTheme(
|
||||
theme: ThemeColors? = null,
|
||||
dynamicColor: Boolean? = null,
|
||||
darkMode: Boolean? = null
|
||||
) {
|
||||
theme?.let { currentTheme = it }
|
||||
dynamicColor?.let { useDynamicColor = it }
|
||||
darkMode?.let { forceDarkMode = it }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
customBackgroundUri = null
|
||||
forceDarkMode = null
|
||||
currentTheme = ThemeColors.Default
|
||||
useDynamicColor = false
|
||||
backgroundImageLoaded = false
|
||||
isThemeChanging = false
|
||||
preventBackgroundRefresh = false
|
||||
lastDarkModeState = null
|
||||
}
|
||||
}
|
||||
|
||||
object ThemeManager {
|
||||
private const val PREFS_NAME = "theme_prefs"
|
||||
|
||||
fun saveThemeMode(context: Context, forceDark: Boolean?) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
||||
putString("theme_mode", when (forceDark) {
|
||||
true -> "dark"
|
||||
false -> "light"
|
||||
null -> "system"
|
||||
})
|
||||
}
|
||||
ThemeConfig.forceDarkMode = forceDark
|
||||
}
|
||||
|
||||
fun loadThemeMode(context: Context) {
|
||||
val mode = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString("theme_mode", "system")
|
||||
|
||||
ThemeConfig.forceDarkMode = when (mode) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveThemeColors(context: Context, themeName: String) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
||||
putString("theme_colors", themeName)
|
||||
}
|
||||
ThemeConfig.currentTheme = ThemeColors.fromName(themeName)
|
||||
}
|
||||
|
||||
fun loadThemeColors(context: Context) {
|
||||
val themeName = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString("theme_colors", "default") ?: "default"
|
||||
ThemeConfig.currentTheme = ThemeColors.fromName(themeName)
|
||||
}
|
||||
|
||||
fun saveDynamicColorState(context: Context, enabled: Boolean) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
||||
putBoolean("use_dynamic_color", enabled)
|
||||
}
|
||||
ThemeConfig.useDynamicColor = enabled
|
||||
}
|
||||
|
||||
|
||||
fun loadDynamicColorState(context: Context) {
|
||||
val enabled = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean("use_dynamic_color", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
ThemeConfig.useDynamicColor = enabled
|
||||
}
|
||||
}
|
||||
|
||||
object BackgroundManager {
|
||||
private const val TAG = "BackgroundManager"
|
||||
|
||||
fun saveAndApplyCustomBackground(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
transformation: BackgroundTransformation? = null
|
||||
) {
|
||||
try {
|
||||
val finalUri = if (transformation != null) {
|
||||
context.saveTransformedBackground(uri, transformation)
|
||||
} else {
|
||||
copyImageToInternalStorage(context, uri)
|
||||
}
|
||||
|
||||
saveBackgroundUri(context, finalUri)
|
||||
ThemeConfig.customBackgroundUri = finalUri
|
||||
CardConfig.updateBackground(true)
|
||||
resetBackgroundState(context)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "保存背景失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCustomBackground(context: Context) {
|
||||
saveBackgroundUri(context, null)
|
||||
ThemeConfig.customBackgroundUri = null
|
||||
CardConfig.updateBackground(false)
|
||||
resetBackgroundState(context)
|
||||
}
|
||||
|
||||
fun loadCustomBackground(context: Context) {
|
||||
val uriString = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getString("custom_background", null)
|
||||
|
||||
val newUri = uriString?.toUri()
|
||||
val preventRefresh = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("prevent_background_refresh", false)
|
||||
|
||||
ThemeConfig.preventBackgroundRefresh = preventRefresh
|
||||
|
||||
if (!preventRefresh || ThemeConfig.customBackgroundUri?.toString() != newUri?.toString()) {
|
||||
Log.d(TAG, "加载自定义背景: $uriString")
|
||||
ThemeConfig.customBackgroundUri = newUri
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
CardConfig.updateBackground(newUri != null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBackgroundUri(context: Context, uri: Uri?) {
|
||||
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
|
||||
putString("custom_background", uri?.toString())
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetBackgroundState(context: Context) {
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
ThemeConfig.preventBackgroundRefresh = false
|
||||
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyImageToInternalStorage(context: Context, uri: Uri): Uri? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
|
||||
val fileName = "custom_background_${System.currentTimeMillis()}.jpg"
|
||||
val file = File(context.filesDir, fileName)
|
||||
|
||||
FileOutputStream(file).use { outputStream ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
outputStream.write(buffer, 0, read)
|
||||
}
|
||||
outputStream.flush()
|
||||
}
|
||||
inputStream.close()
|
||||
|
||||
Uri.fromFile(file)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "复制图片失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
import com.sukisu.ultra.ui.webui.MonetColorsProvider.UpdateCss
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||
|
||||
@Composable
|
||||
fun KernelSUTheme(
|
||||
darkTheme: Boolean = when(ThemeConfig.forceDarkMode) {
|
||||
true -> true
|
||||
false -> false
|
||||
null -> isSystemInDarkTheme()
|
||||
},
|
||||
dynamicColor: Boolean = ThemeConfig.useDynamicColor,
|
||||
colorMode: Int = 0,
|
||||
keyColor: Color? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val systemIsDark = isSystemInDarkTheme()
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val controller = when (colorMode) {
|
||||
1 -> ThemeController(ColorSchemeMode.Light)
|
||||
2 -> ThemeController(ColorSchemeMode.Dark)
|
||||
3 -> ThemeController(
|
||||
ColorSchemeMode.MonetSystem,
|
||||
keyColor = keyColor,
|
||||
isDark = isDark
|
||||
)
|
||||
|
||||
// 初始化主题
|
||||
ThemeInitializer(context = context, systemIsDark = systemIsDark)
|
||||
4 -> ThemeController(
|
||||
ColorSchemeMode.MonetLight,
|
||||
keyColor = keyColor,
|
||||
)
|
||||
|
||||
// 创建颜色方案
|
||||
val colorScheme = createColorScheme(context, darkTheme, dynamicColor)
|
||||
5 -> ThemeController(
|
||||
ColorSchemeMode.MonetDark,
|
||||
keyColor = keyColor,
|
||||
)
|
||||
|
||||
// 系统栏样式
|
||||
SystemBarController(darkTheme)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 背景层
|
||||
BackgroundLayer(darkTheme)
|
||||
// 内容层
|
||||
Box(modifier = Modifier.fillMaxSize().zIndex(1f)) {
|
||||
else -> ThemeController(ColorSchemeMode.System)
|
||||
}
|
||||
return MiuixTheme(
|
||||
controller = controller,
|
||||
content = {
|
||||
UpdateCss()
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeInitializer(context: Context, systemIsDark: Boolean) {
|
||||
val themeChanged = ThemeConfig.detectThemeChange(systemIsDark)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 处理系统主题变化
|
||||
LaunchedEffect(systemIsDark, themeChanged) {
|
||||
if (ThemeConfig.forceDarkMode == null && themeChanged) {
|
||||
Log.d("ThemeSystem", "系统主题变化: $systemIsDark")
|
||||
ThemeConfig.resetBackgroundState()
|
||||
|
||||
if (!ThemeConfig.preventBackgroundRefresh) {
|
||||
BackgroundManager.loadCustomBackground(context)
|
||||
}
|
||||
|
||||
CardConfig.apply {
|
||||
load(context)
|
||||
setThemeDefaults(systemIsDark)
|
||||
save(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载配置
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
ThemeManager.loadThemeMode(context)
|
||||
ThemeManager.loadThemeColors(context)
|
||||
ThemeManager.loadDynamicColorState(context)
|
||||
CardConfig.load(context)
|
||||
|
||||
if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) {
|
||||
BackgroundManager.loadCustomBackground(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundLayer(darkTheme: Boolean) {
|
||||
val backgroundUri = rememberSaveable { mutableStateOf(ThemeConfig.customBackgroundUri) }
|
||||
|
||||
LaunchedEffect(ThemeConfig.customBackgroundUri) {
|
||||
backgroundUri.value = ThemeConfig.customBackgroundUri
|
||||
}
|
||||
|
||||
// 默认背景
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(-2f)
|
||||
.background(
|
||||
if (CardConfig.isCustomBackgroundEnabled) {
|
||||
MaterialTheme.colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
MaterialTheme.colorScheme.background
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 自定义背景
|
||||
backgroundUri.value?.let { uri ->
|
||||
CustomBackgroundLayer(uri = uri, darkTheme = darkTheme)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomBackgroundLayer(uri: Uri, darkTheme: Boolean) {
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = uri,
|
||||
onError = { error ->
|
||||
Log.e("ThemeSystem", "背景加载失败: ${error.result.throwable.message}")
|
||||
ThemeConfig.customBackgroundUri = null
|
||||
},
|
||||
onSuccess = {
|
||||
Log.d("ThemeSystem", "背景加载成功")
|
||||
ThemeConfig.backgroundImageLoaded = true
|
||||
ThemeConfig.isThemeChanging = false
|
||||
}
|
||||
)
|
||||
|
||||
val transition = updateTransition(
|
||||
targetState = ThemeConfig.backgroundImageLoaded,
|
||||
label = "backgroundTransition"
|
||||
)
|
||||
|
||||
val alpha by transition.animateFloat(
|
||||
label = "backgroundAlpha",
|
||||
transitionSpec = {
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
}
|
||||
) { loaded -> if (loaded) 1f else 0f }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(-1f)
|
||||
.alpha(alpha)
|
||||
) {
|
||||
// 背景图片
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.paint(painter = painter, contentScale = ContentScale.Crop)
|
||||
.graphicsLayer {
|
||||
this.alpha = (painter.state as? AsyncImagePainter.State.Success)?.let { 1f } ?: 0f
|
||||
}
|
||||
)
|
||||
|
||||
// 遮罩层
|
||||
BackgroundOverlay(darkTheme = darkTheme)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundOverlay(darkTheme: Boolean) {
|
||||
val dimFactor = CardConfig.cardDim
|
||||
|
||||
// 主要遮罩层
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (darkTheme) {
|
||||
Color.Black.copy(alpha = 0.3f + dimFactor * 0.4f)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.05f + dimFactor * 0.3f)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 边缘渐变遮罩
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
if (darkTheme) {
|
||||
Color.Black.copy(alpha = 0.2f + dimFactor * 0.2f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.05f + dimFactor * 0.1f)
|
||||
}
|
||||
),
|
||||
radius = 1000f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun createColorScheme(
|
||||
context: Context,
|
||||
darkTheme: Boolean,
|
||||
dynamicColor: Boolean
|
||||
): ColorScheme {
|
||||
return when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) createDynamicDarkColorScheme(context)
|
||||
else createDynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> createDarkColorScheme()
|
||||
else -> createLightColorScheme()
|
||||
@ReadOnlyComposable
|
||||
fun isInDarkTheme(themeMode: Int): Boolean {
|
||||
return when (themeMode) {
|
||||
1, 4 -> false // Force light mode
|
||||
2, 5 -> true // Force dark mode
|
||||
else -> isSystemInDarkTheme() // Follow system (0 or default)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemBarController(darkMode: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as ComponentActivity
|
||||
|
||||
SideEffect {
|
||||
activity.enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
Color.Transparent.toArgb(),
|
||||
Color.Transparent.toArgb(),
|
||||
) { darkMode },
|
||||
navigationBarStyle = if (darkMode) {
|
||||
SystemBarStyle.dark(Color.Transparent.toArgb())
|
||||
} else {
|
||||
SystemBarStyle.light(
|
||||
Color.Transparent.toArgb(),
|
||||
Color.Transparent.toArgb()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
private fun createDynamicDarkColorScheme(context: Context): ColorScheme {
|
||||
val scheme = dynamicDarkColorScheme(context)
|
||||
return scheme.copy(
|
||||
background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.background,
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.surface,
|
||||
onBackground = scheme.onBackground,
|
||||
onSurface = scheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
private fun createDynamicLightColorScheme(context: Context): ColorScheme {
|
||||
val scheme = dynamicLightColorScheme(context)
|
||||
return scheme.copy(
|
||||
background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.background,
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.surface,
|
||||
onBackground = scheme.onBackground,
|
||||
onSurface = scheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun createDarkColorScheme() = darkColorScheme(
|
||||
primary = ThemeConfig.currentTheme.primaryDark,
|
||||
onPrimary = ThemeConfig.currentTheme.onPrimaryDark,
|
||||
primaryContainer = ThemeConfig.currentTheme.primaryContainerDark,
|
||||
onPrimaryContainer = ThemeConfig.currentTheme.onPrimaryContainerDark,
|
||||
secondary = ThemeConfig.currentTheme.secondaryDark,
|
||||
onSecondary = ThemeConfig.currentTheme.onSecondaryDark,
|
||||
secondaryContainer = ThemeConfig.currentTheme.secondaryContainerDark,
|
||||
onSecondaryContainer = ThemeConfig.currentTheme.onSecondaryContainerDark,
|
||||
tertiary = ThemeConfig.currentTheme.tertiaryDark,
|
||||
onTertiary = ThemeConfig.currentTheme.onTertiaryDark,
|
||||
tertiaryContainer = ThemeConfig.currentTheme.tertiaryContainerDark,
|
||||
onTertiaryContainer = ThemeConfig.currentTheme.onTertiaryContainerDark,
|
||||
error = ThemeConfig.currentTheme.errorDark,
|
||||
onError = ThemeConfig.currentTheme.onErrorDark,
|
||||
errorContainer = ThemeConfig.currentTheme.errorContainerDark,
|
||||
onErrorContainer = ThemeConfig.currentTheme.onErrorContainerDark,
|
||||
background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.backgroundDark,
|
||||
onBackground = ThemeConfig.currentTheme.onBackgroundDark,
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.surfaceDark,
|
||||
onSurface = ThemeConfig.currentTheme.onSurfaceDark,
|
||||
surfaceVariant = ThemeConfig.currentTheme.surfaceVariantDark,
|
||||
onSurfaceVariant = ThemeConfig.currentTheme.onSurfaceVariantDark,
|
||||
outline = ThemeConfig.currentTheme.outlineDark,
|
||||
outlineVariant = ThemeConfig.currentTheme.outlineVariantDark,
|
||||
scrim = ThemeConfig.currentTheme.scrimDark,
|
||||
inverseSurface = ThemeConfig.currentTheme.inverseSurfaceDark,
|
||||
inverseOnSurface = ThemeConfig.currentTheme.inverseOnSurfaceDark,
|
||||
inversePrimary = ThemeConfig.currentTheme.inversePrimaryDark,
|
||||
surfaceDim = ThemeConfig.currentTheme.surfaceDimDark,
|
||||
surfaceBright = ThemeConfig.currentTheme.surfaceBrightDark,
|
||||
surfaceContainerLowest = ThemeConfig.currentTheme.surfaceContainerLowestDark,
|
||||
surfaceContainerLow = ThemeConfig.currentTheme.surfaceContainerLowDark,
|
||||
surfaceContainer = ThemeConfig.currentTheme.surfaceContainerDark,
|
||||
surfaceContainerHigh = ThemeConfig.currentTheme.surfaceContainerHighDark,
|
||||
surfaceContainerHighest = ThemeConfig.currentTheme.surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun createLightColorScheme() = lightColorScheme(
|
||||
primary = ThemeConfig.currentTheme.primaryLight,
|
||||
onPrimary = ThemeConfig.currentTheme.onPrimaryLight,
|
||||
primaryContainer = ThemeConfig.currentTheme.primaryContainerLight,
|
||||
onPrimaryContainer = ThemeConfig.currentTheme.onPrimaryContainerLight,
|
||||
secondary = ThemeConfig.currentTheme.secondaryLight,
|
||||
onSecondary = ThemeConfig.currentTheme.onSecondaryLight,
|
||||
secondaryContainer = ThemeConfig.currentTheme.secondaryContainerLight,
|
||||
onSecondaryContainer = ThemeConfig.currentTheme.onSecondaryContainerLight,
|
||||
tertiary = ThemeConfig.currentTheme.tertiaryLight,
|
||||
onTertiary = ThemeConfig.currentTheme.onTertiaryLight,
|
||||
tertiaryContainer = ThemeConfig.currentTheme.tertiaryContainerLight,
|
||||
onTertiaryContainer = ThemeConfig.currentTheme.onTertiaryContainerLight,
|
||||
error = ThemeConfig.currentTheme.errorLight,
|
||||
onError = ThemeConfig.currentTheme.onErrorLight,
|
||||
errorContainer = ThemeConfig.currentTheme.errorContainerLight,
|
||||
onErrorContainer = ThemeConfig.currentTheme.onErrorContainerLight,
|
||||
background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.backgroundLight,
|
||||
onBackground = ThemeConfig.currentTheme.onBackgroundLight,
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.surfaceLight,
|
||||
onSurface = ThemeConfig.currentTheme.onSurfaceLight,
|
||||
surfaceVariant = ThemeConfig.currentTheme.surfaceVariantLight,
|
||||
onSurfaceVariant = ThemeConfig.currentTheme.onSurfaceVariantLight,
|
||||
outline = ThemeConfig.currentTheme.outlineLight,
|
||||
outlineVariant = ThemeConfig.currentTheme.outlineVariantLight,
|
||||
scrim = ThemeConfig.currentTheme.scrimLight,
|
||||
inverseSurface = ThemeConfig.currentTheme.inverseSurfaceLight,
|
||||
inverseOnSurface = ThemeConfig.currentTheme.inverseOnSurfaceLight,
|
||||
inversePrimary = ThemeConfig.currentTheme.inversePrimaryLight,
|
||||
surfaceDim = ThemeConfig.currentTheme.surfaceDimLight,
|
||||
surfaceBright = ThemeConfig.currentTheme.surfaceBrightLight,
|
||||
surfaceContainerLowest = ThemeConfig.currentTheme.surfaceContainerLowestLight,
|
||||
surfaceContainerLow = ThemeConfig.currentTheme.surfaceContainerLowLight,
|
||||
surfaceContainer = ThemeConfig.currentTheme.surfaceContainerLight,
|
||||
surfaceContainerHigh = ThemeConfig.currentTheme.surfaceContainerHighLight,
|
||||
surfaceContainerHighest = ThemeConfig.currentTheme.surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
// 向后兼容
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun Context.saveAndApplyCustomBackground(uri: Uri, transformation: BackgroundTransformation? = null) {
|
||||
kotlinx.coroutines.GlobalScope.launch {
|
||||
BackgroundManager.saveAndApplyCustomBackground(this@saveAndApplyCustomBackground, uri, transformation)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.saveCustomBackground(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
saveAndApplyCustomBackground(uri)
|
||||
} else {
|
||||
BackgroundManager.clearCustomBackground(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.saveThemeMode(forceDark: Boolean?) {
|
||||
ThemeManager.saveThemeMode(this, forceDark)
|
||||
}
|
||||
|
||||
|
||||
fun Context.saveThemeColors(themeName: String) {
|
||||
ThemeManager.saveThemeColors(this, themeName)
|
||||
}
|
||||
|
||||
|
||||
fun Context.saveDynamicColorState(enabled: Boolean) {
|
||||
ThemeManager.saveDynamicColorState(this, enabled)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user