Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5be402710 | ||
|
|
809b74a5f3 | ||
|
|
a98a718d61 | ||
|
|
45ea8455fc | ||
|
|
f81023246f | ||
|
|
8b06df3468 | ||
|
|
46cfd936a0 | ||
|
|
de04ea9db0 | ||
|
|
3853928305 | ||
|
|
5e64eee624 | ||
|
|
3a97e6580f | ||
|
|
228b6b1273 | ||
|
|
314fbe8cf7 | ||
|
|
ccb38061ee | ||
|
|
b631344e7c | ||
|
|
85d739a153 | ||
|
|
9817724a10 | ||
|
|
7c7e72f111 | ||
|
|
c5d473c815 | ||
|
|
f2de18bc26 | ||
|
|
61f5785729 | ||
|
|
a7713f0445 | ||
|
|
0109723187 | ||
|
|
15fe454b6d | ||
|
|
5c80febdbd | ||
|
|
39f4a5991a | ||
|
|
b2565fda08 | ||
|
|
923ba8c213 | ||
|
|
c94608a2eb | ||
|
|
ccb59cb7ca | ||
|
|
36d93501c8 | ||
|
|
27f6db889a | ||
|
|
6898d82daf | ||
|
|
8d8d0180ae | ||
|
|
f7b875fc16 | ||
|
|
0d73908d1b | ||
|
|
3dd210cfec | ||
|
|
18c65c8495 | ||
|
|
4f9b745cd0 | ||
|
|
a585989a03 | ||
|
|
ba6f29557e | ||
|
|
79b78e35ba | ||
|
|
932fabd35c | ||
|
|
4ea5c8f450 | ||
|
|
c6b184793e | ||
|
|
e3ef521de5 | ||
|
|
3d4e0e48b4 | ||
|
|
ff3071ca08 | ||
|
|
dd969eac22 | ||
|
|
9f2e5f513d | ||
|
|
385f4ab2c5 | ||
|
|
6826406494 | ||
|
|
6465e7a874 | ||
|
|
c753dd1345 | ||
|
|
06c8580788 | ||
|
|
5f228f1896 | ||
|
|
2368c5afd5 | ||
|
|
16ec695b63 | ||
|
|
404352b536 | ||
|
|
8e7f1f1cc7 | ||
|
|
d2a6fa4513 | ||
|
|
9574409955 | ||
|
|
9c2924de78 | ||
|
|
d7878ddd45 | ||
|
|
bc3399fd1b | ||
|
|
ba1aaaa160 | ||
|
|
a4e5a571bd | ||
|
|
3c501295b7 | ||
|
|
a8acea9180 | ||
|
|
4f79c94ab9 | ||
|
|
a14551b3ec |
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main", "dev", "ci" ]
|
branches: [ "main", "dev", "ci", "miuix" ]
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-manager.yml'
|
- '.github/workflows/build-manager.yml'
|
||||||
- '.github/workflows/build-lkm.yml'
|
- '.github/workflows/build-lkm.yml'
|
||||||
@@ -11,13 +11,14 @@ on:
|
|||||||
- 'userspace/ksud/**'
|
- 'userspace/ksud/**'
|
||||||
- 'userspace/user_scanner/**'
|
- 'userspace/user_scanner/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main", "dev" ]
|
branches: [ "main", "dev", "miuix" ]
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/build-manager.yml'
|
- '.github/workflows/build-manager.yml'
|
||||||
- '.github/workflows/build-lkm.yml'
|
- '.github/workflows/build-lkm.yml'
|
||||||
- 'manager/**'
|
- 'manager/**'
|
||||||
- 'kernel/**'
|
- 'kernel/**'
|
||||||
- 'userspace/ksud/**'
|
- 'userspace/ksud/**'
|
||||||
|
- 'userspace/user_scanner/**'
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -81,7 +82,14 @@ jobs:
|
|||||||
- name: Determine manager variant for telegram bot
|
- name: Determine manager variant for telegram bot
|
||||||
id: determine
|
id: determine
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ matrix.spoofed }}" == "true" ]; then
|
if [ "${{ github.ref_name }}" == "miuix" ] && [ "${{ matrix.spoofed }}" == "true" ]; then
|
||||||
|
echo "SKIP=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "${{ github.ref_name }}" == "miuix" ]; then
|
||||||
|
echo "title=Manager" >> $GITHUB_OUTPUT
|
||||||
|
echo "topicid=${{ vars.MESSAGE_MIUIX_THREAD_ID }}" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ matrix.spoofed }}" == "true" ]; then
|
||||||
echo "title=Spoofed-Manager" >> $GITHUB_OUTPUT
|
echo "title=Spoofed-Manager" >> $GITHUB_OUTPUT
|
||||||
# maybe need a new var
|
# maybe need a new var
|
||||||
echo "topicid=${{ vars.MESSAGE_SPOOFED_THREAD_ID }}" >> $GITHUB_OUTPUT
|
echo "topicid=${{ vars.MESSAGE_SPOOFED_THREAD_ID }}" >> $GITHUB_OUTPUT
|
||||||
@@ -91,13 +99,13 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run randomizer
|
- name: Run randomizer
|
||||||
if: ${{ matrix.spoofed == 'true' }}
|
if: ${{ matrix.spoofed == 'true' && steps.determine.outputs.SKIP != 'true' }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x randomizer
|
chmod +x randomizer
|
||||||
./randomizer
|
./randomizer
|
||||||
|
|
||||||
- name: Write key
|
- name: Write key
|
||||||
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' )) || github.ref_type == 'tag' }}
|
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/miuix' )) || github.ref_type == 'tag' }}
|
||||||
run: |
|
run: |
|
||||||
if [ ! -z "${{ secrets.KEYSTORE }}" ]; then
|
if [ ! -z "${{ secrets.KEYSTORE }}" ]; then
|
||||||
{
|
{
|
||||||
@@ -164,24 +172,25 @@ jobs:
|
|||||||
cp -f ../armeabi-v7a/uid_scanner ../manager/app/src/main/jniLibs/armeabi-v7a/libuid_scanner.so
|
cp -f ../armeabi-v7a/uid_scanner ../manager/app/src/main/jniLibs/armeabi-v7a/libuid_scanner.so
|
||||||
|
|
||||||
- name: Build with Gradle
|
- name: Build with Gradle
|
||||||
|
if: ${{ steps.determine.outputs.SKIP != 'true' }}
|
||||||
run: ./gradlew clean assembleRelease
|
run: ./gradlew clean assembleRelease
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Upload build artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' )) || github.ref_type == 'tag' }}
|
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/miuix' )) || github.ref_type == 'tag' }}
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.determine.outputs.title }}
|
name: ${{ steps.determine.outputs.title }}
|
||||||
path: manager/app/build/outputs/apk/release/*.apk
|
path: manager/app/build/outputs/apk/release/*.apk
|
||||||
|
|
||||||
- name: Upload mappings
|
- name: Upload mappings
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' )) || github.ref_type == 'tag' }}
|
if: ${{ ( github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/miuix' )) || github.ref_type == 'tag' }}
|
||||||
with:
|
with:
|
||||||
name: "${{ steps.determine.outputs.title }}-mappings"
|
name: "${{ steps.determine.outputs.title }}-mappings"
|
||||||
path: "manager/app/build/outputs/mapping/release/"
|
path: "manager/app/build/outputs/mapping/release/"
|
||||||
|
|
||||||
- name: Upload to telegram
|
- name: Upload to telegram
|
||||||
if: github.event_name != 'pull_request' && steps.need_upload.outputs.UPLOAD == 'true'
|
if: github.event_name != 'pull_request' && steps.need_upload.outputs.UPLOAD == 'true' && steps.determine.outputs.SKIP != 'true'
|
||||||
env:
|
env:
|
||||||
CHAT_ID: ${{ vars.CHAT_ID }}
|
CHAT_ID: ${{ vars.CHAT_ID }}
|
||||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ kernelsu-objs += ksud.o
|
|||||||
kernelsu-objs += embed_ksud.o
|
kernelsu-objs += embed_ksud.o
|
||||||
kernelsu-objs += seccomp_cache.o
|
kernelsu-objs += seccomp_cache.o
|
||||||
kernelsu-objs += file_wrapper.o
|
kernelsu-objs += file_wrapper.o
|
||||||
|
kernelsu-objs += util.o
|
||||||
kernelsu-objs += throne_comm.o
|
kernelsu-objs += throne_comm.o
|
||||||
kernelsu-objs += sulog.o
|
kernelsu-objs += sulog.o
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ obj-$(CONFIG_KPM) += kpm/
|
|||||||
|
|
||||||
REPO_OWNER := SukiSU-Ultra
|
REPO_OWNER := SukiSU-Ultra
|
||||||
REPO_NAME := SukiSU-Ultra
|
REPO_NAME := SukiSU-Ultra
|
||||||
REPO_BRANCH := main
|
REPO_BRANCH := miuix
|
||||||
KSU_VERSION_API := 4.0.0
|
KSU_VERSION_API := 4.0.0
|
||||||
|
|
||||||
GIT_BIN := /usr/bin/env PATH="$$PATH":/usr/bin:/usr/local/bin git
|
GIT_BIN := /usr/bin/env PATH="$$PATH":/usr/bin:/usr/local/bin git
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
#define PER_USER_RANGE 100000
|
#define PER_USER_RANGE 100000
|
||||||
#define FIRST_APPLICATION_UID 10000
|
#define FIRST_APPLICATION_UID 10000
|
||||||
#define LAST_APPLICATION_UID 19999
|
#define LAST_APPLICATION_UID 19999
|
||||||
|
#define FIRST_ISOLATED_UID 99000
|
||||||
|
#define LAST_ISOLATED_UID 99999
|
||||||
|
|
||||||
void ksu_allowlist_init(void);
|
void ksu_allowlist_init(void);
|
||||||
|
|
||||||
@@ -41,6 +43,12 @@ static inline bool is_appuid(uid_t uid)
|
|||||||
return appid >= FIRST_APPLICATION_UID && appid <= LAST_APPLICATION_UID;
|
return appid >= FIRST_APPLICATION_UID && appid <= LAST_APPLICATION_UID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static inline bool is_isolated_process(uid_t uid)
|
||||||
|
{
|
||||||
|
uid_t appid = uid % PER_USER_RANGE;
|
||||||
|
return appid >= FIRST_ISOLATED_UID && appid <= LAST_ISOLATED_UID;
|
||||||
|
}
|
||||||
|
|
||||||
#ifdef CONFIG_KSU_MANUAL_SU
|
#ifdef CONFIG_KSU_MANUAL_SU
|
||||||
bool ksu_temp_grant_root_once(uid_t uid);
|
bool ksu_temp_grant_root_once(uid_t uid);
|
||||||
void ksu_temp_revoke_root_once(uid_t uid);
|
void ksu_temp_revoke_root_once(uid_t uid);
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ int ksu_handle_umount(uid_t old_uid, uid_t new_uid)
|
|||||||
{
|
{
|
||||||
struct umount_tw *tw;
|
struct umount_tw *tw;
|
||||||
|
|
||||||
// this hook is used for umounting overlayfs for some uid, if there isn't any module mounted, just ignore it!
|
// if there isn't any module mounted, just ignore it!
|
||||||
if (!ksu_module_mounted) {
|
if (!ksu_module_mounted) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -115,18 +115,24 @@ int ksu_handle_umount(uid_t old_uid, uid_t new_uid)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: isolated process which directly forks from zygote is not handled
|
// There are 5 scenarios:
|
||||||
if (!is_appuid(new_uid)) {
|
// 1. Normal app: zygote -> appuid
|
||||||
|
// 2. Isolated process forked from zygote: zygote -> isolated_process
|
||||||
|
// 3. App zygote forked from zygote: zygote -> appuid
|
||||||
|
// 4. Isolated process froked from app zygote: appuid -> isolated_process (already handled by 3)
|
||||||
|
// 5. Isolated process froked from webview zygote (no need to handle, app cannot run custom code)
|
||||||
|
if (!is_appuid(new_uid) && !is_isolated_process(new_uid)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ksu_uid_should_umount(new_uid)) {
|
if (!ksu_uid_should_umount(new_uid) && !is_isolated_process(new_uid)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check old process's selinux context, if it is not zygote, ignore it!
|
// check old process's selinux context, if it is not zygote, ignore it!
|
||||||
// because some su apps may setuid to untrusted_app but they are in global mount namespace
|
// because some su apps may setuid to untrusted_app but they are in global mount namespace
|
||||||
// when we umount for such process, that is a disaster!
|
// when we umount for such process, that is a disaster!
|
||||||
|
// also handle case 4 and 5
|
||||||
bool is_zygote_child = is_zygote(get_current_cred());
|
bool is_zygote_child = is_zygote(get_current_cred());
|
||||||
if (!is_zygote_child) {
|
if (!is_zygote_child) {
|
||||||
pr_info("handle umount ignore non zygote child: %d\n", current->pid);
|
pr_info("handle umount ignore non zygote child: %d\n", current->pid);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include "arch.h"
|
#include "arch.h"
|
||||||
#include "klog.h" // IWYU pragma: keep
|
#include "klog.h" // IWYU pragma: keep
|
||||||
#include "ksud.h"
|
#include "ksud.h"
|
||||||
|
#include "util.h"
|
||||||
#include "selinux/selinux.h"
|
#include "selinux/selinux.h"
|
||||||
#include "throne_tracker.h"
|
#include "throne_tracker.h"
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ void on_post_fs_data(void)
|
|||||||
}
|
}
|
||||||
|
|
||||||
extern void ext4_unregister_sysfs(struct super_block *sb);
|
extern void ext4_unregister_sysfs(struct super_block *sb);
|
||||||
int nuke_ext4_sysfs(const char* mnt)
|
int nuke_ext4_sysfs(const char *mnt)
|
||||||
{
|
{
|
||||||
#ifdef CONFIG_EXT4_FS
|
#ifdef CONFIG_EXT4_FS
|
||||||
struct path path;
|
struct path path;
|
||||||
@@ -117,12 +118,14 @@ int nuke_ext4_sysfs(const char* mnt)
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_module_mounted(void){
|
void on_module_mounted(void)
|
||||||
|
{
|
||||||
pr_info("on_module_mounted!\n");
|
pr_info("on_module_mounted!\n");
|
||||||
ksu_module_mounted = true;
|
ksu_module_mounted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_boot_completed(void){
|
void on_boot_completed(void)
|
||||||
|
{
|
||||||
ksu_boot_completed = true;
|
ksu_boot_completed = true;
|
||||||
pr_info("on_boot_completed!\n");
|
pr_info("on_boot_completed!\n");
|
||||||
track_throne(true);
|
track_throne(true);
|
||||||
@@ -527,12 +530,25 @@ static int sys_execve_handler_pre(struct kprobe *p, struct pt_regs *regs)
|
|||||||
struct user_arg_ptr argv = { .ptr.native = __argv };
|
struct user_arg_ptr argv = { .ptr.native = __argv };
|
||||||
struct filename filename_in, *filename_p;
|
struct filename filename_in, *filename_p;
|
||||||
char path[32];
|
char path[32];
|
||||||
|
long ret;
|
||||||
|
unsigned long addr;
|
||||||
|
const char __user *fn;
|
||||||
|
|
||||||
if (!filename_user)
|
if (!filename_user)
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
|
addr = untagged_addr((unsigned long)*filename_user);
|
||||||
|
fn = (const char __user *)addr;
|
||||||
|
|
||||||
memset(path, 0, sizeof(path));
|
memset(path, 0, sizeof(path));
|
||||||
strncpy_from_user_nofault(path, *filename_user, 32);
|
ret = strncpy_from_user_nofault(path, fn, 32);
|
||||||
|
if (ret < 0 && try_set_access_flag(addr)) {
|
||||||
|
ret = strncpy_from_user_nofault(path, fn, 32);
|
||||||
|
}
|
||||||
|
if (ret < 0) {
|
||||||
|
pr_err("Access filename failed for execve_handler_pre\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
filename_in.name = path;
|
filename_in.name = path;
|
||||||
|
|
||||||
filename_p = &filename_in;
|
filename_p = &filename_in;
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
#include "linux/compiler.h"
|
#include <linux/compiler_types.h>
|
||||||
#include "linux/printk.h"
|
#include <linux/preempt.h>
|
||||||
|
#include <linux/printk.h>
|
||||||
|
#include <linux/mm.h>
|
||||||
|
#include <linux/pgtable.h>
|
||||||
|
#include <linux/uaccess.h>
|
||||||
#include <asm/current.h>
|
#include <asm/current.h>
|
||||||
#include <linux/cred.h>
|
#include <linux/cred.h>
|
||||||
#include <linux/fs.h>
|
#include <linux/fs.h>
|
||||||
#include <linux/types.h>
|
#include <linux/types.h>
|
||||||
#include <linux/uaccess.h>
|
|
||||||
#include <linux/version.h>
|
#include <linux/version.h>
|
||||||
#include <linux/sched/task_stack.h>
|
#include <linux/sched/task_stack.h>
|
||||||
#include <linux/ptrace.h>
|
#include <linux/ptrace.h>
|
||||||
@@ -15,7 +18,7 @@
|
|||||||
#include "ksud.h"
|
#include "ksud.h"
|
||||||
#include "sucompat.h"
|
#include "sucompat.h"
|
||||||
#include "app_profile.h"
|
#include "app_profile.h"
|
||||||
#include "syscall_hook_manager.h"
|
#include "util.h"
|
||||||
|
|
||||||
#include "sulog.h"
|
#include "sulog.h"
|
||||||
|
|
||||||
@@ -107,20 +110,6 @@ int ksu_handle_stat(int *dfd, const char __user **filename_user, int *flags)
|
|||||||
|
|
||||||
char path[sizeof(su) + 1];
|
char path[sizeof(su) + 1];
|
||||||
memset(path, 0, sizeof(path));
|
memset(path, 0, sizeof(path));
|
||||||
// Remove this later!! we use syscall hook, so this will never happen!!!!!
|
|
||||||
#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 18, 0) && 0
|
|
||||||
// it becomes a `struct filename *` after 5.18
|
|
||||||
// https://elixir.bootlin.com/linux/v5.18/source/fs/stat.c#L216
|
|
||||||
const char sh[] = SH_PATH;
|
|
||||||
struct filename *filename = *((struct filename **)filename_user);
|
|
||||||
if (IS_ERR(filename)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (likely(memcmp(filename->name, su, sizeof(su))))
|
|
||||||
return 0;
|
|
||||||
pr_info("vfs_statx su->sh!\n");
|
|
||||||
memcpy((void *)filename->name, sh, sizeof(sh));
|
|
||||||
#else
|
|
||||||
strncpy_from_user_nofault(path, *filename_user, sizeof(path));
|
strncpy_from_user_nofault(path, *filename_user, sizeof(path));
|
||||||
|
|
||||||
if (unlikely(!memcmp(path, su, sizeof(su)))) {
|
if (unlikely(!memcmp(path, su, sizeof(su)))) {
|
||||||
@@ -130,7 +119,6 @@ int ksu_handle_stat(int *dfd, const char __user **filename_user, int *flags)
|
|||||||
pr_info("newfstatat su->sh!\n");
|
pr_info("newfstatat su->sh!\n");
|
||||||
*filename_user = sh_user_path();
|
*filename_user = sh_user_path();
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -140,17 +128,14 @@ int ksu_handle_execve_sucompat(const char __user **filename_user,
|
|||||||
int *__never_use_flags)
|
int *__never_use_flags)
|
||||||
{
|
{
|
||||||
const char su[] = SU_PATH;
|
const char su[] = SU_PATH;
|
||||||
|
const char __user *fn;
|
||||||
char path[sizeof(su) + 1];
|
char path[sizeof(su) + 1];
|
||||||
|
long ret;
|
||||||
|
unsigned long addr;
|
||||||
|
|
||||||
if (unlikely(!filename_user))
|
if (unlikely(!filename_user))
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
memset(path, 0, sizeof(path));
|
|
||||||
strncpy_from_user_nofault(path, *filename_user, sizeof(path));
|
|
||||||
|
|
||||||
if (likely(memcmp(path, su, sizeof(su))))
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
#if __SULOG_GATE
|
#if __SULOG_GATE
|
||||||
bool is_allowed = ksu_is_allow_uid_for_current(current_uid().val);
|
bool is_allowed = ksu_is_allow_uid_for_current(current_uid().val);
|
||||||
ksu_sulog_report_syscall(current_uid().val, NULL, "execve", path);
|
ksu_sulog_report_syscall(current_uid().val, NULL, "execve", path);
|
||||||
@@ -165,6 +150,32 @@ int ksu_handle_execve_sucompat(const char __user **filename_user,
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
addr = untagged_addr((unsigned long)*filename_user);
|
||||||
|
fn = (const char __user *)addr;
|
||||||
|
memset(path, 0, sizeof(path));
|
||||||
|
ret = strncpy_from_user_nofault(path, fn, sizeof(path));
|
||||||
|
|
||||||
|
if (ret < 0 && try_set_access_flag(addr)) {
|
||||||
|
ret = strncpy_from_user_nofault(path, fn, sizeof(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ret < 0 && preempt_count()) {
|
||||||
|
/* This is crazy, but we know what we are doing:
|
||||||
|
* Temporarily exit atomic context to handle page faults, then restore it */
|
||||||
|
pr_info("Access filename failed, try rescue..\n");
|
||||||
|
preempt_enable_no_resched_notrace();
|
||||||
|
ret = strncpy_from_user(path, fn, sizeof(path));
|
||||||
|
preempt_disable_notrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ret < 0) {
|
||||||
|
pr_warn("Access filename when execve failed: %ld", ret);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (likely(memcmp(path, su, sizeof(su))))
|
||||||
|
return 0;
|
||||||
|
|
||||||
pr_info("sys_execve su found\n");
|
pr_info("sys_execve su found\n");
|
||||||
*filename_user = ksud_user_path();
|
*filename_user = ksud_user_path();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "sucompat.h"
|
#include "sucompat.h"
|
||||||
#include "setuid_hook.h"
|
#include "setuid_hook.h"
|
||||||
#include "selinux/selinux.h"
|
#include "selinux/selinux.h"
|
||||||
|
#include "util.h"
|
||||||
|
|
||||||
// Tracepoint registration count management
|
// Tracepoint registration count management
|
||||||
// == 1: just us
|
// == 1: just us
|
||||||
@@ -246,12 +247,22 @@ static inline bool check_syscall_fastpath(int nr)
|
|||||||
int ksu_handle_init_mark_tracker(const char __user **filename_user)
|
int ksu_handle_init_mark_tracker(const char __user **filename_user)
|
||||||
{
|
{
|
||||||
char path[64];
|
char path[64];
|
||||||
|
unsigned long addr;
|
||||||
|
const char __user *fn;
|
||||||
|
long ret;
|
||||||
|
|
||||||
if (unlikely(!filename_user))
|
if (unlikely(!filename_user))
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
|
addr = untagged_addr((unsigned long)*filename_user);
|
||||||
|
fn = (const char __user *)addr;
|
||||||
|
|
||||||
memset(path, 0, sizeof(path));
|
memset(path, 0, sizeof(path));
|
||||||
strncpy_from_user_nofault(path, *filename_user, sizeof(path));
|
ret = strncpy_from_user_nofault(path, fn, sizeof(path));
|
||||||
|
if (ret < 0 && try_set_access_flag(addr)) {
|
||||||
|
ret = strncpy_from_user_nofault(path, fn, sizeof(path));
|
||||||
|
pr_info("ksu_handle_init_mark_tracker: %ld\n", ret);
|
||||||
|
}
|
||||||
|
|
||||||
if (likely(strstr(path, "/app_process") == NULL && strstr(path, "/adbd") == NULL && strstr(path, "/ksud") == NULL)) {
|
if (likely(strstr(path, "/app_process") == NULL && strstr(path, "/adbd") == NULL && strstr(path, "/ksud") == NULL)) {
|
||||||
pr_info("hook_manager: unmark %d exec %s", current->pid, path);
|
pr_info("hook_manager: unmark %d exec %s", current->pid, path);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
static struct umount_manager g_umount_mgr = {
|
static struct umount_manager g_umount_mgr = {
|
||||||
.entry_count = 0,
|
.entry_count = 0,
|
||||||
.max_entries = 64,
|
.max_entries = 512,
|
||||||
};
|
};
|
||||||
|
|
||||||
static void try_umount_path(struct umount_entry *entry)
|
static void try_umount_path(struct umount_entry *entry)
|
||||||
@@ -33,35 +33,123 @@ static struct umount_entry *find_entry_locked(const char *path)
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int init_default_entries(void)
|
static bool is_path_in_mount_list(const char *path)
|
||||||
{
|
{
|
||||||
int ret;
|
struct mount_entry *entry;
|
||||||
|
bool found = false;
|
||||||
|
|
||||||
const struct {
|
down_read(&mount_list_lock);
|
||||||
const char *path;
|
list_for_each_entry(entry, &mount_list, list) {
|
||||||
int flags;
|
if (entry->umountable && strcmp(entry->umountable, path) == 0) {
|
||||||
} defaults[] = {
|
found = true;
|
||||||
{ "/odm", 0 },
|
break;
|
||||||
{ "/system", 0 },
|
|
||||||
{ "/vendor", 0 },
|
|
||||||
{ "/product", 0 },
|
|
||||||
{ "/system_ext", 0 },
|
|
||||||
{ "/data/adb/modules", MNT_DETACH },
|
|
||||||
{ "/debug_ramdisk", MNT_DETACH },
|
|
||||||
};
|
|
||||||
|
|
||||||
for (int i = 0; i < ARRAY_SIZE(defaults); i++) {
|
|
||||||
ret = ksu_umount_manager_add(defaults[i].path,
|
|
||||||
defaults[i].flags,
|
|
||||||
true); // is_default = true
|
|
||||||
if (ret) {
|
|
||||||
pr_err("Failed to add default entry: %s, ret=%d\n",
|
|
||||||
defaults[i].path, ret);
|
|
||||||
return ret;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
up_read(&mount_list_lock);
|
||||||
|
|
||||||
pr_info("Initialized %zu default umount entries\n", ARRAY_SIZE(defaults));
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int copy_mount_entry_to_user(struct ksu_umount_entry_info __user *entries,
|
||||||
|
u32 idx, const char *path, int flags)
|
||||||
|
{
|
||||||
|
struct ksu_umount_entry_info info;
|
||||||
|
|
||||||
|
memset(&info, 0, sizeof(info));
|
||||||
|
strncpy(info.path, path, sizeof(info.path) - 1);
|
||||||
|
info.path[sizeof(info.path) - 1] = '\0';
|
||||||
|
info.flags = flags;
|
||||||
|
info.is_default = 1;
|
||||||
|
info.state = UMOUNT_STATE_IDLE;
|
||||||
|
info.ref_count = 0;
|
||||||
|
|
||||||
|
if (copy_to_user(&entries[idx], &info, sizeof(info))) {
|
||||||
|
return -EFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int copy_umount_entry_to_user(struct ksu_umount_entry_info __user *entries,
|
||||||
|
u32 idx, struct umount_entry *entry)
|
||||||
|
{
|
||||||
|
struct ksu_umount_entry_info info;
|
||||||
|
|
||||||
|
memset(&info, 0, sizeof(info));
|
||||||
|
strncpy(info.path, entry->path, sizeof(info.path) - 1);
|
||||||
|
info.path[sizeof(info.path) - 1] = '\0';
|
||||||
|
info.flags = entry->flags;
|
||||||
|
info.is_default = entry->is_default;
|
||||||
|
info.state = entry->state;
|
||||||
|
info.ref_count = entry->ref_count;
|
||||||
|
|
||||||
|
if (copy_to_user(&entries[idx], &info, sizeof(info))) {
|
||||||
|
return -EFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int collect_mount_list_entries(struct ksu_umount_entry_info __user *entries,
|
||||||
|
u32 max_count, u32 *out_idx)
|
||||||
|
{
|
||||||
|
struct mount_entry *mount_entry;
|
||||||
|
u32 idx = 0;
|
||||||
|
|
||||||
|
down_read(&mount_list_lock);
|
||||||
|
list_for_each_entry(mount_entry, &mount_list, list) {
|
||||||
|
if (idx >= max_count) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mount_entry->umountable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copy_mount_entry_to_user(entries, idx, mount_entry->umountable,
|
||||||
|
mount_entry->flags)) {
|
||||||
|
up_read(&mount_list_lock);
|
||||||
|
return -EFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
up_read(&mount_list_lock);
|
||||||
|
|
||||||
|
*out_idx = idx;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int collect_umount_manager_entries(struct ksu_umount_entry_info __user *entries,
|
||||||
|
u32 start_idx, u32 max_count, u32 *out_idx)
|
||||||
|
{
|
||||||
|
struct umount_entry *entry;
|
||||||
|
unsigned long flags;
|
||||||
|
u32 idx = start_idx;
|
||||||
|
|
||||||
|
spin_lock_irqsave(&g_umount_mgr.lock, flags);
|
||||||
|
|
||||||
|
list_for_each_entry(entry, &g_umount_mgr.entry_list, list) {
|
||||||
|
if (idx >= max_count) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_path_in_mount_list(entry->path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
|
||||||
|
|
||||||
|
if (copy_umount_entry_to_user(entries, idx, entry)) {
|
||||||
|
return -EFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
idx++;
|
||||||
|
spin_lock_irqsave(&g_umount_mgr.lock, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
|
||||||
|
*out_idx = idx;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +158,7 @@ int ksu_umount_manager_init(void)
|
|||||||
INIT_LIST_HEAD(&g_umount_mgr.entry_list);
|
INIT_LIST_HEAD(&g_umount_mgr.entry_list);
|
||||||
spin_lock_init(&g_umount_mgr.lock);
|
spin_lock_init(&g_umount_mgr.lock);
|
||||||
|
|
||||||
return init_default_entries();
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ksu_umount_manager_exit(void)
|
void ksu_umount_manager_exit(void)
|
||||||
@@ -104,6 +192,11 @@ int ksu_umount_manager_add(const char *path, int flags, bool is_default)
|
|||||||
return -EINVAL;
|
return -EINVAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_path_in_mount_list(path)) {
|
||||||
|
pr_warn("Umount manager: path already exists in mount_list: %s\n", path);
|
||||||
|
return -EEXIST;
|
||||||
|
}
|
||||||
|
|
||||||
spin_lock_irqsave(&g_umount_mgr.lock, irqflags);
|
spin_lock_irqsave(&g_umount_mgr.lock, irqflags);
|
||||||
|
|
||||||
if (g_umount_mgr.entry_count >= g_umount_mgr.max_entries) {
|
if (g_umount_mgr.entry_count >= g_umount_mgr.max_entries) {
|
||||||
@@ -216,37 +309,23 @@ void ksu_umount_manager_execute_all(const struct cred *cred)
|
|||||||
|
|
||||||
int ksu_umount_manager_get_entries(struct ksu_umount_entry_info __user *entries, u32 *count)
|
int ksu_umount_manager_get_entries(struct ksu_umount_entry_info __user *entries, u32 *count)
|
||||||
{
|
{
|
||||||
struct umount_entry *entry;
|
|
||||||
struct ksu_umount_entry_info info;
|
|
||||||
unsigned long flags;
|
|
||||||
u32 idx = 0;
|
|
||||||
u32 max_count = *count;
|
u32 max_count = *count;
|
||||||
|
u32 idx;
|
||||||
|
int ret;
|
||||||
|
|
||||||
spin_lock_irqsave(&g_umount_mgr.lock, flags);
|
ret = collect_mount_list_entries(entries, max_count, &idx);
|
||||||
|
if (ret) {
|
||||||
list_for_each_entry(entry, &g_umount_mgr.entry_list, list) {
|
return ret;
|
||||||
if (idx >= max_count) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
memset(&info, 0, sizeof(info));
|
if (idx < max_count) {
|
||||||
strncpy(info.path, entry->path, sizeof(info.path) - 1);
|
ret = collect_umount_manager_entries(entries, idx, max_count, &idx);
|
||||||
info.flags = entry->flags;
|
if (ret) {
|
||||||
info.is_default = entry->is_default;
|
return ret;
|
||||||
info.state = entry->state;
|
|
||||||
info.ref_count = entry->ref_count;
|
|
||||||
|
|
||||||
if (copy_to_user(&entries[idx], &info, sizeof(info))) {
|
|
||||||
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
|
|
||||||
return -EFAULT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
idx++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*count = idx;
|
*count = idx;
|
||||||
|
|
||||||
spin_unlock_irqrestore(&g_umount_mgr.lock, flags);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
kernel/util.c
Normal file
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.ksp)
|
||||||
alias(libs.plugins.lsplugin.apksign)
|
alias(libs.plugins.lsplugin.apksign)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val managerVersionCode: Int by rootProject.extra
|
val managerVersionCode: Int by rootProject.extra
|
||||||
@@ -25,7 +23,6 @@ apksign {
|
|||||||
keyPasswordProperty = "KEY_PASSWORD"
|
keyPasswordProperty = "KEY_PASSWORD"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
||||||
/**signingConfigs {
|
/**signingConfigs {
|
||||||
@@ -117,13 +114,9 @@ dependencies {
|
|||||||
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.material.icons.extended)
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
implementation(libs.androidx.compose.material)
|
|
||||||
implementation(libs.androidx.compose.material3)
|
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.foundation)
|
|
||||||
implementation(libs.androidx.documentfile)
|
implementation(libs.androidx.documentfile)
|
||||||
implementation(libs.androidx.compose.foundation)
|
|
||||||
|
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
@@ -145,24 +138,14 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
implementation(libs.me.zhanghai.android.appiconloader.coil)
|
|
||||||
|
|
||||||
implementation(libs.sheet.compose.dialogs.core)
|
|
||||||
implementation(libs.sheet.compose.dialogs.list)
|
|
||||||
implementation(libs.sheet.compose.dialogs.input)
|
|
||||||
|
|
||||||
implementation(libs.markdown)
|
implementation(libs.markdown)
|
||||||
|
implementation(libs.markdown.ext.tables)
|
||||||
|
|
||||||
implementation(libs.androidx.webkit)
|
implementation(libs.androidx.webkit)
|
||||||
|
|
||||||
implementation(libs.lsposed.cxx)
|
implementation(libs.lsposed.cxx)
|
||||||
|
|
||||||
implementation(libs.com.github.topjohnwu.libsu.core)
|
implementation(libs.miuix)
|
||||||
|
implementation(libs.haze)
|
||||||
implementation(libs.mmrl.platform)
|
implementation(libs.capsule)
|
||||||
compileOnly(libs.mmrl.hidden.api)
|
|
||||||
implementation(libs.mmrl.webui)
|
|
||||||
implementation(libs.mmrl.ui)
|
|
||||||
|
|
||||||
implementation(libs.accompanist.drawablepainter)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
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">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
|
||||||
tools:ignore="ScopedStorage" />
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
||||||
tools:ignore="ScopedStorage" />
|
|
||||||
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".KernelSUApplication"
|
android:name=".KernelSUApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:requestLegacyExternalStorage="true"
|
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.KernelSU"
|
android:theme="@style/Theme.KernelSU"
|
||||||
tools:targetApi="34">
|
tools:targetApi="34">
|
||||||
<!-- 专门为小米手机桌面卸载添加了提示,提升用户体验 -->
|
|
||||||
<meta-data
|
|
||||||
android:name="app_description_title"
|
|
||||||
android:resource="@string/miui_uninstall_title" />
|
|
||||||
<meta-data
|
|
||||||
android:name="app_description_content"
|
|
||||||
android:resource="@string/miui_uninstall_content" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:enabled="true"
|
android:theme="@style/Theme.KernelSU"
|
||||||
android:launchMode="standard"
|
android:windowSoftInputMode="adjustResize">
|
||||||
android:documentLaunchMode="intoExisting"
|
|
||||||
android:autoRemoveFromRecents="true"
|
|
||||||
android:theme="@style/Theme.KernelSU">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -62,39 +46,6 @@
|
|||||||
<data android:mimeType="application/vnd.android.package-archive" />
|
<data android:mimeType="application/vnd.android.package-archive" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- 切换图标 -->
|
|
||||||
<activity-alias
|
|
||||||
android:name=".ui.MainActivityAlias"
|
|
||||||
android:targetActivity=".ui.MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:enabled="false"
|
|
||||||
android:icon="@mipmap/ic_launcher_alt"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_alt_round">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="content" />
|
|
||||||
<data android:mimeType="application/zip" />
|
|
||||||
<data android:mimeType="application/vnd.android.package-archive" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="application/zip" />
|
|
||||||
<data android:mimeType="application/vnd.android.package-archive" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="application/zip" />
|
|
||||||
<data android:mimeType="application/vnd.android.package-archive" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity-alias>
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.webui.WebUIActivity"
|
android:name=".ui.webui.WebUIActivity"
|
||||||
@@ -103,13 +54,6 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@style/Theme.KernelSU.WebUI" />
|
android:theme="@style/Theme.KernelSU.WebUI" />
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.webui.WebUIXActivity"
|
|
||||||
android:autoRemoveFromRecents="true"
|
|
||||||
android:documentLaunchMode="intoExisting"
|
|
||||||
android:exported="false"
|
|
||||||
android:theme="@style/Theme.KernelSU.WebUI" />
|
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
package com.sukisu.zako;
|
package com.sukisu.zako;
|
||||||
|
|
||||||
import android.content.pm.PackageInfo;
|
import android.content.pm.PackageInfo;
|
||||||
import java.util.List;
|
import rikka.parcelablelist.ParcelableListSlice;
|
||||||
|
|
||||||
interface IKsuInterface {
|
interface IKsuInterface {
|
||||||
int getPackageCount();
|
ParcelableListSlice<PackageInfo> getPackages(int flags);
|
||||||
List<PackageInfo> getPackages(int start, int maxCount);
|
|
||||||
}
|
}
|
||||||
Binary file not shown.
@@ -15,14 +15,4 @@ add_library(kernelsu
|
|||||||
|
|
||||||
find_library(log-lib log)
|
find_library(log-lib log)
|
||||||
|
|
||||||
if(ANDROID_ABI STREQUAL "arm64-v8a")
|
target_link_libraries(kernelsu ${log-lib})
|
||||||
set(zakosign-lib ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libzakosign.so)
|
|
||||||
elseif(ANDROID_ABI STREQUAL "armeabi-v7a")
|
|
||||||
set(zakosign-lib ${CMAKE_SOURCE_DIR}/../jniLibs/armeabi-v7a/libzakosign.so)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
if(ANDROID_ABI STREQUAL "arm64-v8a" OR ANDROID_ABI STREQUAL "armeabi-v7a")
|
|
||||||
target_link_libraries(kernelsu ${log-lib} ${zakosign-lib})
|
|
||||||
else()
|
|
||||||
target_link_libraries(kernelsu ${log-lib})
|
|
||||||
endif()
|
|
||||||
|
|||||||
@@ -335,11 +335,6 @@ NativeBridge(getUserName, jstring, jint uid) {
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if KPM is enabled
|
|
||||||
NativeBridgeNP(isKPMEnabled, jboolean) {
|
|
||||||
return is_KPM_enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get HOOK type
|
// Get HOOK type
|
||||||
NativeBridgeNP(getHookType, jstring) {
|
NativeBridgeNP(getHookType, jstring) {
|
||||||
char hook_type[32] = { 0 };
|
char hook_type[32] = { 0 };
|
||||||
@@ -419,7 +414,7 @@ NativeBridgeNP(getManagersList, jobject) {
|
|||||||
LogDebug("getManagersList: count=%d", managerListInfo.count);
|
LogDebug("getManagersList: count=%d", managerListInfo.count);
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
#if 0
|
||||||
NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) {
|
NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) {
|
||||||
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
||||||
if (!modulePath) {
|
if (!modulePath) {
|
||||||
@@ -438,6 +433,7 @@ NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) {
|
|||||||
return false;
|
return false;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
NativeBridgeNP(isUidScannerEnabled, jboolean) {
|
NativeBridgeNP(isUidScannerEnabled, jboolean) {
|
||||||
return is_uid_scanner_enabled();
|
return is_uid_scanner_enabled();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "prelude.h"
|
#include "prelude.h"
|
||||||
#include "ksu.h"
|
#include "ksu.h"
|
||||||
|
|
||||||
|
#if 0
|
||||||
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
||||||
|
|
||||||
// Zako extern declarations
|
// Zako extern declarations
|
||||||
@@ -23,6 +24,7 @@ extern uint32_t zako_file_verify_esig(int fd, uint32_t flags);
|
|||||||
extern const char* zako_file_verrcidx2str(uint8_t index);
|
extern const char* zako_file_verrcidx2str(uint8_t index);
|
||||||
|
|
||||||
#endif // __aarch64__ || _M_ARM64 || __arm__ || _M_ARM
|
#endif // __aarch64__ || _M_ARM64 || __arm__ || _M_ARM
|
||||||
|
#endif
|
||||||
|
|
||||||
static int fd = -1;
|
static int fd = -1;
|
||||||
|
|
||||||
@@ -257,14 +259,6 @@ void get_full_version(char* buff) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool is_KPM_enable(void) {
|
|
||||||
struct ksu_enable_kpm_cmd cmd = {};
|
|
||||||
if (ksuctl(KSU_IOCTL_ENABLE_KPM, &cmd) == 0 && cmd.enabled) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return legacy_is_KPM_enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
void get_hook_type(char *buff) {
|
void get_hook_type(char *buff) {
|
||||||
struct ksu_hook_type_cmd cmd = {0};
|
struct ksu_hook_type_cmd cmd = {0};
|
||||||
if (ksuctl(KSU_IOCTL_HOOK_TYPE, &cmd) == 0) {
|
if (ksuctl(KSU_IOCTL_HOOK_TYPE, &cmd) == 0) {
|
||||||
@@ -348,6 +342,7 @@ bool clear_uid_scanner_environment(void)
|
|||||||
return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd);
|
return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if 0
|
||||||
bool verify_module_signature(const char* input) {
|
bool verify_module_signature(const char* input) {
|
||||||
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
|
||||||
if (input == NULL) {
|
if (input == NULL) {
|
||||||
@@ -404,3 +399,4 @@ bool verify_module_signature(const char* input) {
|
|||||||
return false;
|
return false;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ bool clear_dynamic_manager();
|
|||||||
|
|
||||||
bool get_managers_list(struct manager_list_info* info);
|
bool get_managers_list(struct manager_list_info* info);
|
||||||
|
|
||||||
|
#if 0
|
||||||
bool verify_module_signature(const char* input);
|
bool verify_module_signature(const char* input);
|
||||||
|
#endif
|
||||||
|
|
||||||
bool is_uid_scanner_enabled();
|
bool is_uid_scanner_enabled();
|
||||||
|
|
||||||
@@ -227,10 +229,6 @@ struct ksu_hook_type_cmd {
|
|||||||
char hook_type[32]; // Output: hook type string
|
char hook_type[32]; // Output: hook type string
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ksu_enable_kpm_cmd {
|
|
||||||
uint8_t enabled; // Output: true if KPM is enabled
|
|
||||||
};
|
|
||||||
|
|
||||||
struct ksu_dynamic_manager_cmd {
|
struct ksu_dynamic_manager_cmd {
|
||||||
struct dynamic_manager_user_config config; // Input/Output: dynamic manager config
|
struct dynamic_manager_user_config config; // Input/Output: dynamic manager config
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,12 +88,6 @@ bool legacy_is_su_enabled() {
|
|||||||
return enabled;
|
return enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool legacy_is_KPM_enable() {
|
|
||||||
int enabled = false;
|
|
||||||
ksuctl(CMD_ENABLE_KPM, &enabled, NULL);
|
|
||||||
return enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool legacy_get_hook_type(char* hook_type, size_t size) {
|
bool legacy_get_hook_type(char* hook_type, size_t size) {
|
||||||
if (hook_type == NULL || size == 0) {
|
if (hook_type == NULL || size == 0) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,15 +5,7 @@ import android.system.Os
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.ViewModelStore
|
import androidx.lifecycle.ViewModelStore
|
||||||
import androidx.lifecycle.ViewModelStoreOwner
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||||
import coil.Coil
|
|
||||||
import coil.ImageLoader
|
|
||||||
import com.dergoogler.mmrl.platform.Platform
|
|
||||||
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
|
||||||
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -30,24 +22,8 @@ class KernelSUApplication : Application(), ViewModelStoreOwner {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
ksuApp = this
|
ksuApp = this
|
||||||
|
|
||||||
// For faster response when first entering superuser or webui activity
|
|
||||||
val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java]
|
val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java]
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
superUserViewModel.loadAppList()
|
||||||
superUserViewModel.fetchAppList()
|
|
||||||
}
|
|
||||||
|
|
||||||
Platform.setHiddenApiExemptions()
|
|
||||||
|
|
||||||
val context = this
|
|
||||||
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
|
||||||
Coil.setImageLoader(
|
|
||||||
ImageLoader.Builder(context)
|
|
||||||
.components {
|
|
||||||
add(AppIconKeyer())
|
|
||||||
add(AppIconFetcher.Factory(iconSize, false, context))
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
|
|
||||||
val webroot = File(dataDir, "webroot")
|
val webroot = File(dataDir, "webroot")
|
||||||
if (!webroot.exists()) {
|
if (!webroot.exists()) {
|
||||||
@@ -62,11 +38,12 @@ class KernelSUApplication : Application(), ViewModelStoreOwner {
|
|||||||
.addInterceptor { block ->
|
.addInterceptor { block ->
|
||||||
block.proceed(
|
block.proceed(
|
||||||
block.request().newBuilder()
|
block.request().newBuilder()
|
||||||
.header("User-Agent", "SukiSU/${BuildConfig.VERSION_CODE}")
|
.header("User-Agent", "KernelSU/${BuildConfig.VERSION_CODE}")
|
||||||
.header("Accept-Language", Locale.getDefault().toLanguageTag()).build()
|
.header("Accept-Language", Locale.getDefault().toLanguageTag()).build()
|
||||||
)
|
)
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val viewModelStore: ViewModelStore
|
override val viewModelStore: ViewModelStore
|
||||||
get() = appViewModelStore
|
get() = appViewModelStore
|
||||||
}
|
}
|
||||||
@@ -8,11 +8,23 @@ import android.system.Os
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) {
|
data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) {
|
||||||
override fun toString(): String = "$major.$patchLevel.$subLevel"
|
override fun toString(): String {
|
||||||
fun isGKI(): Boolean = when {
|
return "$major.$patchLevel.$subLevel"
|
||||||
major > 5 -> true
|
}
|
||||||
major == 5 && patchLevel >= 10 -> true
|
|
||||||
else -> false
|
fun isGKI(): Boolean {
|
||||||
|
|
||||||
|
// kernel 6.x
|
||||||
|
if (major > 5) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// kernel 5.10.x
|
||||||
|
if (major == 5) {
|
||||||
|
return patchLevel >= 10
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,27 +17,18 @@ object Natives {
|
|||||||
// 10977: change groups_count and groups to avoid overflow write
|
// 10977: change groups_count and groups to avoid overflow write
|
||||||
// 11071: Fix the issue of failing to set a custom SELinux type.
|
// 11071: Fix the issue of failing to set a custom SELinux type.
|
||||||
// 12143: breaking: new supercall impl
|
// 12143: breaking: new supercall impl
|
||||||
const val MINIMAL_SUPPORTED_KERNEL = 12143
|
const val MINIMAL_SUPPORTED_KERNEL = 22000
|
||||||
|
|
||||||
|
// Get full version
|
||||||
|
external fun getFullVersion(): String
|
||||||
|
const val MINIMAL_SUPPORTED_KERNEL_FULL = "v4.0.0"
|
||||||
|
|
||||||
// 12040: Support disable sucompat mode
|
// 12040: Support disable sucompat mode
|
||||||
const val KERNEL_SU_DOMAIN = "u:r:su:s0"
|
const val KERNEL_SU_DOMAIN = "u:r:su:s0"
|
||||||
|
|
||||||
const val MINIMAL_SUPPORTED_KERNEL_FULL = "v3.1.8"
|
|
||||||
|
|
||||||
const val MINIMAL_SUPPORTED_KPM = 12800
|
|
||||||
|
|
||||||
const val MINIMAL_SUPPORTED_DYNAMIC_MANAGER = 13215
|
|
||||||
|
|
||||||
const val MINIMAL_SUPPORTED_UID_SCANNER = 13347
|
|
||||||
|
|
||||||
const val MINIMAL_NEW_IOCTL_KERNEL = 13490
|
|
||||||
|
|
||||||
const val ROOT_UID = 0
|
const val ROOT_UID = 0
|
||||||
const val ROOT_GID = 0
|
const val ROOT_GID = 0
|
||||||
|
|
||||||
// 获取完整版本号
|
|
||||||
external fun getFullVersion(): String
|
|
||||||
|
|
||||||
fun isVersionLessThan(v1Full: String, v2Full: String): Boolean {
|
fun isVersionLessThan(v1Full: String, v2Full: String): Boolean {
|
||||||
fun extractVersionParts(version: String): List<Int> {
|
fun extractVersionParts(version: String): List<Int> {
|
||||||
val match = Regex("""v\d+(\.\d+)*""").find(version)
|
val match = Regex("""v\d+(\.\d+)*""").find(version)
|
||||||
@@ -61,7 +52,6 @@ object Natives {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("zakosign")
|
|
||||||
System.loadLibrary("kernelsu")
|
System.loadLibrary("kernelsu")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +108,11 @@ object Natives {
|
|||||||
external fun isEnhancedSecurityEnabled(): Boolean
|
external fun isEnhancedSecurityEnabled(): Boolean
|
||||||
external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean
|
external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user name for the uid.
|
||||||
|
*/
|
||||||
|
external fun getUserName(uid: Int): String?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Su Log can be enabled/disabled.
|
* Su Log can be enabled/disabled.
|
||||||
* 0: disabled
|
* 0: disabled
|
||||||
@@ -126,15 +121,8 @@ object Natives {
|
|||||||
*/
|
*/
|
||||||
external fun isSuLogEnabled(): Boolean
|
external fun isSuLogEnabled(): Boolean
|
||||||
external fun setSuLogEnabled(enabled: Boolean): Boolean
|
external fun setSuLogEnabled(enabled: Boolean): Boolean
|
||||||
|
|
||||||
external fun isKPMEnabled(): Boolean
|
|
||||||
external fun getHookType(): String
|
external fun getHookType(): String
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SUSFS feature status from kernel
|
|
||||||
* @return SusfsFeatureStatus object containing all feature states, or null if failed
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set dynamic managerature configuration
|
* Set dynamic managerature configuration
|
||||||
* @param size APK signature size
|
* @param size APK signature size
|
||||||
@@ -162,9 +150,6 @@ object Natives {
|
|||||||
*/
|
*/
|
||||||
external fun getManagersList(): ManagersList?
|
external fun getManagersList(): ManagersList?
|
||||||
|
|
||||||
// 模块签名验证
|
|
||||||
external fun verifyModuleSignature(modulePath: String): Boolean
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if UID scanner is currently enabled
|
* Check if UID scanner is currently enabled
|
||||||
* @return true if UID scanner is enabled, false otherwise
|
* @return true if UID scanner is enabled, false otherwise
|
||||||
@@ -185,7 +170,7 @@ object Natives {
|
|||||||
*/
|
*/
|
||||||
external fun clearUidScannerEnvironment(): Boolean
|
external fun clearUidScannerEnvironment(): Boolean
|
||||||
|
|
||||||
external fun getUserName(uid: Int): String?
|
|
||||||
|
|
||||||
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
|
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
|
||||||
private const val NOBODY_UID = 9999
|
private const val NOBODY_UID = 9999
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ enum class Groups(val gid: Int, val display: String, val desc: String) {
|
|||||||
SDK_SANDBOX(1090, "sdk_sandbox", "SDK sandbox virtual UID"),
|
SDK_SANDBOX(1090, "sdk_sandbox", "SDK sandbox virtual UID"),
|
||||||
SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"),
|
SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"),
|
||||||
PRNG_SEEDER(1092, "prng_seeder", "PRNG seeder daemon"),
|
PRNG_SEEDER(1092, "prng_seeder", "PRNG seeder daemon"),
|
||||||
|
UPROBESTATS(1093, "uprobestats", "uid for uprobestats"),
|
||||||
|
CROS_EC(1094, "cros_ec", "uid for accessing ChromeOS EC (cros_ec)"),
|
||||||
|
MMD(1095, "mmd", "uid for memory management daemon"),
|
||||||
|
|
||||||
SHELL(2000, "shell", "adb and debug shell user"),
|
SHELL(2000, "shell", "adb and debug shell user"),
|
||||||
CACHE(2001, "cache", "cache access"),
|
CACHE(2001, "cache", "cache access"),
|
||||||
@@ -122,6 +125,7 @@ enum class Groups(val gid: Int, val display: String, val desc: String) {
|
|||||||
WAKELOCK(3010, "wakelock", "Allow system wakelock read/write access"),
|
WAKELOCK(3010, "wakelock", "Allow system wakelock read/write access"),
|
||||||
UHID(3011, "uhid", "Allow read/write to /dev/uhid node"),
|
UHID(3011, "uhid", "Allow read/write to /dev/uhid node"),
|
||||||
READTRACEFS(3012, "readtracefs", "Allow tracefs read"),
|
READTRACEFS(3012, "readtracefs", "Allow tracefs read"),
|
||||||
|
VIRTUALMACHINE(3013, "virtualmachine", "Allows VMs to tune for performance"),
|
||||||
|
|
||||||
EVERYBODY(9997, "everybody", "Shared external storage read/write"),
|
EVERYBODY(9997, "everybody", "Shared external storage read/write"),
|
||||||
MISC(9998, "misc", "Access to misc storage"),
|
MISC(9998, "misc", "Access to misc storage"),
|
||||||
|
|||||||
@@ -1,75 +1,71 @@
|
|||||||
package com.sukisu.ultra.ui
|
package com.sukisu.ultra.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.*
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.UserHandle
|
||||||
|
import android.os.UserManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.topjohnwu.superuser.ipc.RootService
|
import com.topjohnwu.superuser.ipc.RootService
|
||||||
import com.sukisu.zako.IKsuInterface
|
import com.sukisu.zako.IKsuInterface
|
||||||
|
import rikka.parcelablelist.ParcelableListSlice
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author ShirkNeko
|
* @author weishu
|
||||||
* @date 2025/10/17.
|
* @date 2023/4/18.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class KsuService : RootService() {
|
class KsuService : RootService() {
|
||||||
|
|
||||||
private val TAG = "KsuService"
|
companion object {
|
||||||
|
private const val TAG = "KsuService"
|
||||||
private val cacheLock = Object()
|
|
||||||
private var _all: List<PackageInfo>? = null
|
|
||||||
private val allPackages: List<PackageInfo>
|
|
||||||
get() = synchronized(cacheLock) {
|
|
||||||
_all ?: loadAllPackages().also { _all = it }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadAllPackages(): List<PackageInfo> {
|
override fun onBind(intent: Intent): IBinder {
|
||||||
val tmp = arrayListOf<PackageInfo>()
|
return Stub()
|
||||||
for (user in (getSystemService(USER_SERVICE) as UserManager).userProfiles) {
|
|
||||||
val userId = user.getUserIdCompat()
|
|
||||||
tmp += getInstalledPackagesAsUser(userId)
|
|
||||||
}
|
|
||||||
return tmp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal inner class Stub : IKsuInterface.Stub() {
|
private fun getUserIds(): List<Int> {
|
||||||
override fun getPackageCount(): Int = allPackages.size
|
val result = ArrayList<Int>()
|
||||||
|
val um = getSystemService(USER_SERVICE) as UserManager
|
||||||
override fun getPackages(start: Int, maxCount: Int): List<PackageInfo> {
|
val userProfiles = um.userProfiles
|
||||||
val list = allPackages
|
for (userProfile: UserHandle in userProfiles) {
|
||||||
val end = (start + maxCount).coerceAtMost(list.size)
|
result.add(userProfile.hashCode())
|
||||||
return if (start >= list.size) emptyList()
|
|
||||||
else list.subList(start, end)
|
|
||||||
}
|
}
|
||||||
|
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")
|
@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) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "getInstalledPackagesAsUser", e)
|
Log.e(TAG, "err", e)
|
||||||
emptyList()
|
ArrayList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun UserHandle.getUserIdCompat(): Int {
|
private inner class Stub : IKsuInterface.Stub() {
|
||||||
return try {
|
override fun getPackages(flags: Int): ParcelableListSlice<PackageInfo> {
|
||||||
javaClass.getDeclaredField("identifier").apply { isAccessible = true }.getInt(this)
|
val list = getInstalledPackagesAll(flags)
|
||||||
} catch (_: NoSuchFieldException) {
|
Log.i(TAG, "getPackages: ${list.size}")
|
||||||
javaClass.getDeclaredMethod("getIdentifier").invoke(this) as Int
|
return ParcelableListSlice(list)
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e("KsuService", "getUserIdCompat", e)
|
|
||||||
0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,307 +1,217 @@
|
|||||||
package com.sukisu.ultra.ui
|
package com.sukisu.ultra.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.SharedPreferences
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||||
|
import androidx.compose.animation.EnterTransition
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||||
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
|
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
|
||||||
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
import com.ramcosta.composedestinations.generated.NavGraphs
|
import com.ramcosta.composedestinations.generated.NavGraphs
|
||||||
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination
|
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||||
import com.ramcosta.composedestinations.spec.NavHostGraphSpec
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||||
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
|
import dev.chrisbanes.haze.HazeState
|
||||||
import com.sukisu.ultra.Natives
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
import com.sukisu.ultra.ui.screen.BottomBarDestination
|
import dev.chrisbanes.haze.HazeTint
|
||||||
import com.sukisu.ultra.ui.theme.KernelSUTheme
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
|
||||||
import com.sukisu.ultra.ui.util.install
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.HomeViewModel
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
|
||||||
import com.sukisu.ultra.ui.webui.initPlatform
|
|
||||||
import com.sukisu.ultra.ui.component.*
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.sukisu.ultra.ui.activity.component.BottomBar
|
import com.sukisu.ultra.Natives
|
||||||
import com.sukisu.ultra.ui.activity.util.*
|
import com.sukisu.ultra.ui.component.BottomBar
|
||||||
|
import com.sukisu.ultra.ui.screen.HomePager
|
||||||
|
import com.sukisu.ultra.ui.screen.ModulePager
|
||||||
|
import com.sukisu.ultra.ui.screen.SettingPager
|
||||||
|
import com.sukisu.ultra.ui.screen.SuperUserPager
|
||||||
|
import com.sukisu.ultra.ui.theme.KernelSUTheme
|
||||||
|
import com.sukisu.ultra.ui.util.install
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private lateinit var superUserViewModel: SuperUserViewModel
|
|
||||||
private lateinit var homeViewModel: HomeViewModel
|
|
||||||
internal val settingsStateFlow = MutableStateFlow(SettingsState())
|
|
||||||
|
|
||||||
data class SettingsState(
|
|
||||||
val isHideOtherInfo: Boolean = false,
|
|
||||||
val showKpmInfo: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
private var showConfirmationDialog = mutableStateOf(false)
|
|
||||||
private var pendingZipFiles = mutableStateOf<List<ZipFileInfo>>(emptyList())
|
|
||||||
|
|
||||||
private lateinit var themeChangeObserver: ThemeChangeContentObserver
|
|
||||||
private var isInitialized = false
|
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
|
||||||
super.attachBaseContext(newBase?.let { LocaleHelper.applyLanguage(it) })
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
try {
|
|
||||||
// 应用自定义 DPI
|
|
||||||
DisplayUtils.applyCustomDpi(this)
|
|
||||||
|
|
||||||
// Enable edge to edge
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
window.isNavigationBarContrastEnforced = false
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val isManager = Natives.isManager
|
val isManager = Natives.isManager
|
||||||
if (isManager && !Natives.requireNewKernel()) {
|
if (isManager && !Natives.requireNewKernel()) install()
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent {
|
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 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 navigator = navController.rememberDestinationsNavigator()
|
||||||
|
val initialIntent = remember { intent }
|
||||||
|
|
||||||
InstallConfirmationDialog(
|
Scaffold {
|
||||||
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 ->
|
|
||||||
DestinationsNavHost(
|
DestinationsNavHost(
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier,
|
||||||
navGraph = NavGraphs.root as NavHostGraphSpec,
|
navGraph = NavGraphs.root,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
|
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
|
||||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
|
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(
|
||||||
slideInHorizontally(initialOffsetX = { it })
|
initialOffsetX = { it },
|
||||||
} else {
|
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||||
// Otherwise (switching between bottom navigation pages), use fade in
|
)
|
||||||
fadeIn(animationSpec = tween(340))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
|
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(
|
||||||
slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut()
|
targetOffsetX = { -it / 5 },
|
||||||
} else {
|
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||||
// Otherwise (switching between bottom navigation pages), use fade out
|
)
|
||||||
fadeOut(animationSpec = tween(340))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
|
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(
|
||||||
slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn()
|
initialOffsetX = { -it / 5 },
|
||||||
} else {
|
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||||
// Otherwise (e.g., returning between multiple detail pages), use default fade in
|
)
|
||||||
fadeIn(animationSpec = tween(340))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
|
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) {
|
slideOutHorizontally(
|
||||||
scaleOut(targetScale = 0.9f) + fadeOut()
|
targetOffsetX = { it },
|
||||||
} else {
|
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||||
// Otherwise, use default fade out
|
)
|
||||||
fadeOut(animationSpec = tween(340))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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
|
package com.sukisu.ultra.ui.component
|
||||||
|
|
||||||
import android.graphics.text.LineBreaker
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.text.Layout
|
|
||||||
import android.text.method.LinkMovementMethod
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.Saver
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import kotlinx.coroutines.CancellableContinuation
|
||||||
import androidx.compose.ui.window.Dialog
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import kotlinx.coroutines.async
|
||||||
import io.noties.markwon.Markwon
|
|
||||||
import io.noties.markwon.utils.NoCopySpannableFactory
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.ReceiveChannel
|
import kotlinx.coroutines.channels.ReceiveChannel
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
import kotlinx.coroutines.flow.consumeAsFlow
|
import kotlinx.coroutines.flow.consumeAsFlow
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import com.sukisu.ultra.R
|
||||||
|
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
private const val TAG = "DialogComponent"
|
private const val TAG = "DialogComponent"
|
||||||
|
|
||||||
interface ConfirmDialogVisuals : Parcelable {
|
interface ConfirmDialogVisuals : Parcelable {
|
||||||
val title: String
|
val title: String
|
||||||
val content: String
|
val content: String?
|
||||||
val isMarkdown: Boolean
|
val isMarkdown: Boolean
|
||||||
val confirm: String?
|
val confirm: String?
|
||||||
val dismiss: String?
|
val dismiss: String?
|
||||||
@@ -54,7 +62,7 @@ interface ConfirmDialogVisuals : Parcelable {
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
private data class ConfirmDialogVisualsImpl(
|
private data class ConfirmDialogVisualsImpl(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
override val content: String,
|
override val content: String?,
|
||||||
override val isMarkdown: Boolean,
|
override val isMarkdown: Boolean,
|
||||||
override val confirm: String?,
|
override val confirm: String?,
|
||||||
override val dismiss: String?,
|
override val dismiss: String?,
|
||||||
@@ -86,16 +94,15 @@ interface ConfirmDialogHandle : DialogHandle {
|
|||||||
|
|
||||||
fun showConfirm(
|
fun showConfirm(
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String? = null,
|
||||||
markdown: Boolean = false,
|
markdown: Boolean = false,
|
||||||
confirm: String? = null,
|
confirm: String? = null,
|
||||||
dismiss: String? = null
|
dismiss: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun awaitConfirm(
|
suspend fun awaitConfirm(
|
||||||
|
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String? = null,
|
||||||
markdown: Boolean = false,
|
markdown: Boolean = false,
|
||||||
confirm: String? = null,
|
confirm: String? = null,
|
||||||
dismiss: String? = null
|
dismiss: String? = null
|
||||||
@@ -159,7 +166,10 @@ interface ConfirmCallback {
|
|||||||
val isEmpty: Boolean get() = onConfirm == null && onDismiss == null
|
val isEmpty: Boolean get() = onConfirm == null && onDismiss == null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
operator fun invoke(onConfirmProvider: () -> NullableCallback, onDismissProvider: () -> NullableCallback): ConfirmCallback {
|
operator fun invoke(
|
||||||
|
onConfirmProvider: () -> NullableCallback,
|
||||||
|
onDismissProvider: () -> NullableCallback
|
||||||
|
): ConfirmCallback {
|
||||||
return object : ConfirmCallback {
|
return object : ConfirmCallback {
|
||||||
override val onConfirm: NullableCallback
|
override val onConfirm: NullableCallback
|
||||||
get() = onConfirmProvider()
|
get() = onConfirmProvider()
|
||||||
@@ -250,7 +260,7 @@ private class ConfirmDialogHandleImpl(
|
|||||||
|
|
||||||
override fun showConfirm(
|
override fun showConfirm(
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String?,
|
||||||
markdown: Boolean,
|
markdown: Boolean,
|
||||||
confirm: String?,
|
confirm: String?,
|
||||||
dismiss: String?
|
dismiss: String?
|
||||||
@@ -263,7 +273,7 @@ private class ConfirmDialogHandleImpl(
|
|||||||
|
|
||||||
override suspend fun awaitConfirm(
|
override suspend fun awaitConfirm(
|
||||||
title: String,
|
title: String,
|
||||||
content: String,
|
content: String?,
|
||||||
markdown: Boolean,
|
markdown: Boolean,
|
||||||
confirm: String?,
|
confirm: String?,
|
||||||
dismiss: String?
|
dismiss: String?
|
||||||
@@ -299,23 +309,12 @@ private class ConfirmDialogHandleImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CustomDialogHandleImpl(
|
|
||||||
visible: MutableState<Boolean>,
|
|
||||||
coroutineScope: CoroutineScope
|
|
||||||
) : DialogHandleBase(visible, coroutineScope) {
|
|
||||||
override val dialogType: String get() = "CustomDialog"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberLoadingDialog(): LoadingDialogHandle {
|
fun rememberLoadingDialog(): LoadingDialogHandle {
|
||||||
val visible = remember {
|
val visible = remember { mutableStateOf(false) }
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
if (visible.value) {
|
LoadingDialog(visible)
|
||||||
LoadingDialog()
|
|
||||||
}
|
|
||||||
|
|
||||||
return remember {
|
return remember {
|
||||||
LoadingDialogHandleImpl(visible, coroutineScope)
|
LoadingDialogHandleImpl(visible, coroutineScope)
|
||||||
@@ -343,7 +342,8 @@ private fun rememberConfirmDialog(visuals: ConfirmDialogVisuals, callback: Confi
|
|||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
handle.visuals,
|
handle.visuals,
|
||||||
confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
|
confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
|
||||||
dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } }
|
dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } },
|
||||||
|
showDialog = visible
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,99 +370,97 @@ fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCustomDialog(composable: @Composable (dismiss: () -> Unit) -> Unit): DialogHandle {
|
private fun LoadingDialog(showDialog: MutableState<Boolean>) {
|
||||||
val visible = rememberSaveable {
|
SuperDialog(
|
||||||
mutableStateOf(false)
|
show = showDialog,
|
||||||
}
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
if (visible.value) {
|
|
||||||
composable { visible.value = false }
|
|
||||||
}
|
|
||||||
return remember {
|
|
||||||
CustomDialogHandleImpl(visible, coroutineScope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LoadingDialog() {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = {},
|
onDismissRequest = {},
|
||||||
properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false)
|
content = {
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator()
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
InfiniteProgressIndicator(
|
||||||
|
color = MiuixTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
text = stringResource(R.string.processing),
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConfirmDialog(visuals: ConfirmDialogVisuals, confirm: () -> Unit, dismiss: () -> Unit) {
|
private fun ConfirmDialog(
|
||||||
AlertDialog(
|
visuals: ConfirmDialogVisuals,
|
||||||
|
confirm: () -> Unit,
|
||||||
|
dismiss: () -> Unit,
|
||||||
|
showDialog: MutableState<Boolean>
|
||||||
|
) {
|
||||||
|
SuperDialog(
|
||||||
|
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
|
||||||
|
show = showDialog,
|
||||||
|
title = visuals.title,
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
dismiss()
|
dismiss()
|
||||||
|
showDialog.value = false
|
||||||
},
|
},
|
||||||
title = {
|
content = {
|
||||||
Text(text = visuals.title)
|
Layout(
|
||||||
},
|
content = {
|
||||||
text = {
|
visuals.content?.let {
|
||||||
if (visuals.isMarkdown) {
|
if (visuals.isMarkdown) {
|
||||||
MarkdownContent(content = visuals.content)
|
MarkdownContent(content = visuals.content!!)
|
||||||
} else {
|
} else {
|
||||||
Text(text = visuals.content)
|
Text(text = visuals.content!!)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = confirm) {
|
|
||||||
Text(text = visuals.confirm ?: stringResource(id = android.R.string.ok))
|
|
||||||
}
|
}
|
||||||
},
|
Row(
|
||||||
dismissButton = {
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
TextButton(onClick = dismiss) {
|
modifier = Modifier.padding(top = 12.dp)
|
||||||
Text(text = visuals.dismiss ?: stringResource(id = android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MarkdownContent(content: String) {
|
|
||||||
val contentColor = LocalContentColor.current
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(
|
|
||||||
state = scrollState,
|
|
||||||
flingBehavior = ScrollableDefaults.flingBehavior()
|
|
||||||
)
|
|
||||||
.padding(12.dp)
|
|
||||||
) {
|
) {
|
||||||
AndroidView(
|
TextButton(
|
||||||
factory = { context ->
|
text = visuals.dismiss ?: stringResource(id = android.R.string.cancel),
|
||||||
TextView(context).apply {
|
onClick = {
|
||||||
movementMethod = LinkMovementMethod.getInstance()
|
dismiss()
|
||||||
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
showDialog.value = false
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
update = {
|
modifier = Modifier.weight(1f)
|
||||||
Markwon.create(it.context).setMarkdown(it, content)
|
)
|
||||||
it.setTextColor(contentColor.toArgb())
|
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 androidx.compose.runtime.Composable
|
||||||
import com.sukisu.ultra.Natives
|
import com.sukisu.ultra.Natives
|
||||||
import com.sukisu.ultra.ksuApp
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun KsuIsValid(
|
fun KsuIsValid(
|
||||||
@@ -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
|
package com.sukisu.ultra.ui.component.profile
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import com.sukisu.ultra.Natives
|
import com.sukisu.ultra.Natives
|
||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
import com.sukisu.ultra.ui.component.SwitchItem
|
import com.sukisu.ultra.ui.component.EditText
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperSwitch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppProfileConfig(
|
fun AppProfileConfig(
|
||||||
@@ -21,13 +24,15 @@ fun AppProfileConfig(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
if (!fixedName) {
|
if (!fixedName) {
|
||||||
OutlinedTextField(
|
EditText(
|
||||||
label = { Text(stringResource(R.string.profile_name)) },
|
title = stringResource(R.string.profile_name),
|
||||||
value = profile.name,
|
textValue = remember { mutableStateOf(profile.name) },
|
||||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
onTextValueChange = { onProfileChange(profile.copy(name = it)) },
|
||||||
|
enabled = enabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SwitchItem(
|
|
||||||
|
SuperSwitch(
|
||||||
title = stringResource(R.string.profile_umount_modules),
|
title = stringResource(R.string.profile_umount_modules),
|
||||||
summary = stringResource(R.string.profile_umount_modules_summary),
|
summary = stringResource(R.string.profile_umount_modules_summary),
|
||||||
checked = if (enabled) {
|
checked = if (enabled) {
|
||||||
|
|||||||
@@ -1,34 +1,46 @@
|
|||||||
package com.sukisu.ultra.ui.component.profile
|
package com.sukisu.ultra.ui.component.profile
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.text.isDigitsOnly
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.Header
|
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import com.maxkeppeler.sheets.input.InputDialog
|
|
||||||
import com.maxkeppeler.sheets.input.models.*
|
|
||||||
import com.maxkeppeler.sheets.list.ListDialog
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListOption
|
|
||||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
|
||||||
import com.sukisu.ultra.Natives
|
import com.sukisu.ultra.Natives
|
||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
import com.sukisu.ultra.profile.Capabilities
|
import com.sukisu.ultra.profile.Capabilities
|
||||||
import com.sukisu.ultra.profile.Groups
|
import com.sukisu.ultra.profile.Groups
|
||||||
import com.sukisu.ultra.ui.component.rememberCustomDialog
|
import com.sukisu.ultra.ui.component.SuperEditArrow
|
||||||
import com.sukisu.ultra.ui.util.isSepolicyValid
|
import com.sukisu.ultra.ui.util.isSepolicyValid
|
||||||
|
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
|
import top.yukonga.miuix.kmp.extra.CheckboxLocation
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperCheckbox
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RootProfileConfig(
|
fun RootProfileConfig(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -36,94 +48,49 @@ fun RootProfileConfig(
|
|||||||
profile: Natives.Profile,
|
profile: Natives.Profile,
|
||||||
onProfileChange: (Natives.Profile) -> Unit,
|
onProfileChange: (Natives.Profile) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier) {
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
if (!fixedName) {
|
if (!fixedName) {
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
label = { Text(stringResource(R.string.profile_name)) },
|
label = stringResource(R.string.profile_name),
|
||||||
value = profile.name,
|
value = profile.name,
|
||||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
SuperEditArrow(
|
||||||
var expanded by remember { mutableStateOf(false) }
|
title = "UID",
|
||||||
val currentNamespace = when (profile.namespace) {
|
defaultValue = profile.uid,
|
||||||
Natives.Profile.Namespace.INHERITED.ordinal -> stringResource(R.string.profile_namespace_inherited)
|
|
||||||
Natives.Profile.Namespace.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global)
|
|
||||||
Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual)
|
|
||||||
else -> stringResource(R.string.profile_namespace_inherited)
|
|
||||||
}
|
|
||||||
ListItem(headlineContent = {
|
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = expanded,
|
|
||||||
onExpandedChange = { expanded = !expanded }
|
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
|
||||||
modifier = Modifier
|
|
||||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text(stringResource(R.string.profile_namespace)) },
|
|
||||||
value = currentNamespace,
|
|
||||||
onValueChange = {},
|
|
||||||
trailingIcon = {
|
|
||||||
if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
|
||||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = { expanded = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.profile_namespace_inherited)) },
|
|
||||||
onClick = {
|
|
||||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal))
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.profile_namespace_global)) },
|
|
||||||
onClick = {
|
|
||||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal))
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.profile_namespace_individual)) },
|
|
||||||
onClick = {
|
|
||||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal))
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
UidPanel(uid = profile.uid, label = "uid", onUidChange = {
|
|
||||||
onProfileChange(
|
onProfileChange(
|
||||||
profile.copy(
|
profile.copy(
|
||||||
uid = it,
|
uid = it,
|
||||||
rootUseDefault = false
|
rootUseDefault = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
UidPanel(uid = profile.gid, label = "gid", onUidChange = {
|
}
|
||||||
|
|
||||||
|
SuperEditArrow(
|
||||||
|
title = "GID",
|
||||||
|
defaultValue = profile.gid,
|
||||||
|
) {
|
||||||
onProfileChange(
|
onProfileChange(
|
||||||
profile.copy(
|
profile.copy(
|
||||||
gid = it,
|
gid = it,
|
||||||
rootUseDefault = false
|
rootUseDefault = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
}
|
||||||
|
|
||||||
val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e ->
|
val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e ->
|
||||||
e.mapNotNull { g ->
|
e.mapNotNull { g ->
|
||||||
Groups.entries.find { it.gid == g }
|
Groups.entries.find { it.gid == g }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GroupsPanel(selectedGroups) {
|
GroupsPanel(selectedGroups) {
|
||||||
onProfileChange(
|
onProfileChange(
|
||||||
profile.copy(
|
profile.copy(
|
||||||
@@ -155,15 +122,15 @@ fun RootProfileConfig(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) {
|
fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) {
|
||||||
val selectGroupsDialog = rememberCustomDialog { dismiss: () -> Unit ->
|
val showDialog = remember { mutableStateOf(false) }
|
||||||
val groups = Groups.entries.toTypedArray().sortedWith(
|
|
||||||
|
val groups = remember {
|
||||||
|
Groups.entries.toTypedArray().sortedWith(
|
||||||
compareBy<Groups> { if (selected.contains(it)) 0 else 1 }
|
compareBy<Groups> { if (selected.contains(it)) 0 else 1 }
|
||||||
.then(compareBy {
|
.then(compareBy {
|
||||||
when (it) {
|
when (it) {
|
||||||
@@ -174,308 +141,255 @@ fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(compareBy { it.name })
|
.then(compareBy { it.name })
|
||||||
|
|
||||||
)
|
|
||||||
val options = groups.map { value ->
|
|
||||||
ListOption(
|
|
||||||
titleText = value.display,
|
|
||||||
subtitleText = value.desc,
|
|
||||||
selected = selected.contains(value),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val selection = HashSet(selected)
|
val currentSelection = remember { mutableStateOf(selected.toSet()) }
|
||||||
|
|
||||||
MaterialTheme(
|
SuperDialog(
|
||||||
colorScheme = MaterialTheme.colorScheme.copy(
|
show = showDialog,
|
||||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListDialog(
|
|
||||||
state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
|
||||||
closeSelection(selection)
|
|
||||||
}, onCloseRequest = {
|
|
||||||
dismiss()
|
|
||||||
}),
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.profile_groups),
|
title = stringResource(R.string.profile_groups),
|
||||||
),
|
summary = "${currentSelection.value.size} / 32",
|
||||||
selection = ListSelection.Multiple(
|
insideMargin = DpSize(0.dp, 24.dp),
|
||||||
showCheckBoxes = true,
|
onDismissRequest = { showDialog.value = false }
|
||||||
options = options,
|
) {
|
||||||
maxChoices = 32, // Kernel only supports 32 groups at most
|
Column(modifier = Modifier.heightIn(max = 500.dp)) {
|
||||||
) { indecies, _ ->
|
LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
// Handle selection
|
items(groups) { group ->
|
||||||
selection.clear()
|
SuperCheckbox(
|
||||||
indecies.forEach { index ->
|
title = group.display,
|
||||||
val group = groups[index]
|
summary = group.desc,
|
||||||
selection.add(group)
|
insideMargin = PaddingValues(horizontal = 30.dp, vertical = 16.dp),
|
||||||
|
checkboxLocation = CheckboxLocation.Right,
|
||||||
|
checked = currentSelection.value.contains(group),
|
||||||
|
holdDownState = currentSelection.value.contains(group),
|
||||||
|
onCheckedChange = { isChecked ->
|
||||||
|
val newSelection = currentSelection.value.toMutableSet()
|
||||||
|
if (isChecked) {
|
||||||
|
if (newSelection.size < 32) newSelection.add(group)
|
||||||
|
} else {
|
||||||
|
newSelection.remove(group)
|
||||||
}
|
}
|
||||||
|
currentSelection.value = newSelection
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
OutlinedCard(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
.fillMaxWidth()
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
) {
|
||||||
|
TextButton(
|
||||||
Column(
|
onClick = {
|
||||||
modifier = Modifier
|
currentSelection.value = selected.toSet()
|
||||||
.fillMaxSize()
|
showDialog.value = false
|
||||||
.clickable {
|
},
|
||||||
selectGroupsDialog.show()
|
text = stringResource(android.R.string.cancel),
|
||||||
}
|
modifier = Modifier.weight(1f),
|
||||||
.padding(16.dp)
|
)
|
||||||
) {
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
Text(stringResource(R.string.profile_groups))
|
TextButton(
|
||||||
FlowRow {
|
onClick = {
|
||||||
selected.forEach { group ->
|
closeSelection(currentSelection.value)
|
||||||
AssistChip(
|
showDialog.value = false
|
||||||
modifier = Modifier.padding(3.dp),
|
},
|
||||||
onClick = { /*TODO*/ },
|
text = stringResource(R.string.confirm),
|
||||||
label = { Text(group.display) })
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val tag = if (selected.isEmpty()) {
|
||||||
|
"None"
|
||||||
|
} else {
|
||||||
|
selected.joinToString(separator = ",", transform = { it.display })
|
||||||
}
|
}
|
||||||
|
SuperArrow(
|
||||||
|
title = stringResource(R.string.profile_groups),
|
||||||
|
summary = tag,
|
||||||
|
onClick = {
|
||||||
|
showDialog.value = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CapsPanel(
|
fun CapsPanel(
|
||||||
selected: Collection<Capabilities>,
|
selected: Collection<Capabilities>,
|
||||||
closeSelection: (selection: Set<Capabilities>) -> Unit
|
closeSelection: (selection: Set<Capabilities>) -> Unit
|
||||||
) {
|
) {
|
||||||
val selectCapabilitiesDialog = rememberCustomDialog { dismiss ->
|
val showDialog = remember { mutableStateOf(false) }
|
||||||
val caps = Capabilities.entries.toTypedArray().sortedWith(
|
|
||||||
|
val caps = remember {
|
||||||
|
Capabilities.entries.toTypedArray().sortedWith(
|
||||||
compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 }
|
compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 }
|
||||||
.then(compareBy { it.name })
|
.then(compareBy { it.name })
|
||||||
)
|
)
|
||||||
val options = caps.map { value ->
|
|
||||||
ListOption(
|
|
||||||
titleText = value.display,
|
|
||||||
subtitleText = value.desc,
|
|
||||||
selected = selected.contains(value),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val selection = HashSet(selected)
|
val currentSelection = remember { mutableStateOf(selected.toSet()) }
|
||||||
|
|
||||||
MaterialTheme(
|
SuperDialog(
|
||||||
colorScheme = MaterialTheme.colorScheme.copy(
|
show = showDialog,
|
||||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ListDialog(
|
|
||||||
state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
|
||||||
closeSelection(selection)
|
|
||||||
}, onCloseRequest = {
|
|
||||||
dismiss()
|
|
||||||
}),
|
|
||||||
header = Header.Default(
|
|
||||||
title = stringResource(R.string.profile_capabilities),
|
title = stringResource(R.string.profile_capabilities),
|
||||||
),
|
insideMargin = DpSize(0.dp, 24.dp),
|
||||||
selection = ListSelection.Multiple(
|
onDismissRequest = { showDialog.value = false },
|
||||||
showCheckBoxes = true,
|
content = {
|
||||||
options = options
|
Column(modifier = Modifier.heightIn(max = 500.dp)) {
|
||||||
) { indecies, _ ->
|
LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
// Handle selection
|
items(caps) { cap ->
|
||||||
selection.clear()
|
SuperCheckbox(
|
||||||
indecies.forEach { index ->
|
title = cap.display,
|
||||||
val group = caps[index]
|
summary = cap.desc,
|
||||||
selection.add(group)
|
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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
OutlinedCard(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
.fillMaxWidth()
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
) {
|
||||||
|
TextButton(
|
||||||
Column(
|
onClick = {
|
||||||
modifier = Modifier
|
showDialog.value = false
|
||||||
.fillMaxSize()
|
currentSelection.value = selected.toSet()
|
||||||
.clickable {
|
},
|
||||||
selectCapabilitiesDialog.show()
|
text = stringResource(android.R.string.cancel),
|
||||||
}
|
modifier = Modifier.weight(1f)
|
||||||
.padding(16.dp)
|
)
|
||||||
) {
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
Text(stringResource(R.string.profile_capabilities))
|
TextButton(
|
||||||
FlowRow {
|
onClick = {
|
||||||
selected.forEach { group ->
|
closeSelection(currentSelection.value)
|
||||||
AssistChip(
|
showDialog.value = false
|
||||||
modifier = Modifier.padding(3.dp),
|
},
|
||||||
onClick = { /*TODO*/ },
|
text = stringResource(R.string.confirm),
|
||||||
label = { Text(group.display) })
|
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
|
@Composable
|
||||||
private fun SELinuxPanel(
|
private fun SELinuxPanel(
|
||||||
profile: Natives.Profile,
|
profile: Natives.Profile,
|
||||||
onSELinuxChange: (domain: String, rules: String) -> Unit
|
onSELinuxChange: (domain: String, rules: String) -> Unit
|
||||||
) {
|
) {
|
||||||
val editSELinuxDialog = rememberCustomDialog { dismiss ->
|
val showDialog = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var domain by remember { mutableStateOf(profile.context) }
|
var domain by remember { mutableStateOf(profile.context) }
|
||||||
var rules by remember { mutableStateOf(profile.rules) }
|
var rules by remember { mutableStateOf(profile.rules) }
|
||||||
|
|
||||||
val inputOptions = listOf(
|
val isDomainValid = remember(domain) {
|
||||||
InputTextField(
|
val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$")
|
||||||
text = domain,
|
domain.matches(regex)
|
||||||
header = InputHeader(
|
}
|
||||||
title = stringResource(id = R.string.profile_selinux_domain),
|
val isRulesValid = remember(rules) { isSepolicyValid(rules) }
|
||||||
),
|
|
||||||
type = InputTextFieldType.OUTLINED,
|
SuperDialog(
|
||||||
required = true,
|
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(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Ascii,
|
keyboardType = KeyboardType.Ascii,
|
||||||
imeAction = ImeAction.Next
|
imeAction = ImeAction.Next
|
||||||
),
|
),
|
||||||
resultListener = {
|
singleLine = true
|
||||||
domain = it ?: ""
|
)
|
||||||
|
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(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Ascii,
|
keyboardType = KeyboardType.Ascii,
|
||||||
),
|
),
|
||||||
singleLine = false,
|
singleLine = false
|
||||||
resultListener = {
|
)
|
||||||
rules = it ?: ""
|
|
||||||
},
|
|
||||||
validationListener = { value ->
|
|
||||||
if (isSepolicyValid(value)) ValidationResult.Valid
|
|
||||||
else ValidationResult.Invalid("SELinux rules is invalid!")
|
|
||||||
}
|
}
|
||||||
)
|
Spacer(Modifier.height(12.dp))
|
||||||
)
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = MaterialTheme.colorScheme.copy(
|
|
||||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
InputDialog(
|
TextButton(
|
||||||
state = rememberUseCaseState(
|
onClick = { showDialog.value = false },
|
||||||
visible = true,
|
text = stringResource(android.R.string.cancel),
|
||||||
onFinishedRequest = {
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
onSELinuxChange(domain, rules)
|
onSELinuxChange(domain, rules)
|
||||||
|
showDialog.value = false
|
||||||
},
|
},
|
||||||
onCloseRequest = {
|
text = stringResource(R.string.confirm),
|
||||||
dismiss()
|
enabled = isDomainValid && isRulesValid,
|
||||||
}),
|
modifier = Modifier.weight(1f),
|
||||||
header = Header.Default(
|
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SuperArrow(
|
||||||
title = stringResource(R.string.profile_selinux_context),
|
title = stringResource(R.string.profile_selinux_context),
|
||||||
),
|
summary = profile.context,
|
||||||
selection = InputSelection(
|
onClick = { showDialog.value = true }
|
||||||
input = inputOptions,
|
|
||||||
onPositiveClick = { result ->
|
|
||||||
// Handle selection
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
package com.sukisu.ultra.ui.component.profile
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ReadMore
|
import androidx.compose.material.icons.rounded.Create
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.material.icons.filled.Create
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import com.sukisu.ultra.Natives
|
import com.sukisu.ultra.Natives
|
||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
import com.sukisu.ultra.ui.util.listAppProfileTemplates
|
import com.sukisu.ultra.ui.util.listAppProfileTemplates
|
||||||
import com.sukisu.ultra.ui.util.setSepolicy
|
import com.sukisu.ultra.ui.util.setSepolicy
|
||||||
import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById
|
import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperDropdown
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author weishu
|
* @author weishu
|
||||||
* @date 2023/10/21.
|
* @date 2023/10/21.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TemplateConfig(
|
fun TemplateConfig(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
profile: Natives.Profile,
|
profile: Natives.Profile,
|
||||||
onViewTemplate: (id: String) -> Unit = {},
|
onViewTemplate: (id: String) -> Unit = {},
|
||||||
onManageTemplate: () -> Unit = {},
|
onManageTemplate: () -> Unit = {},
|
||||||
onProfileChange: (Natives.Profile) -> Unit
|
onProfileChange: (Natives.Profile) -> Unit
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
var template by rememberSaveable {
|
|
||||||
mutableStateOf(profile.rootTemplate ?: "")
|
|
||||||
}
|
|
||||||
val profileTemplates = listAppProfileTemplates()
|
val profileTemplates = listAppProfileTemplates()
|
||||||
val noTemplates = profileTemplates.isEmpty()
|
val noTemplates = profileTemplates.isEmpty()
|
||||||
|
|
||||||
ListItem(headlineContent = {
|
|
||||||
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) {
|
if (noTemplates) {
|
||||||
IconButton(
|
SuperArrow(
|
||||||
onClick = onManageTemplate
|
modifier = modifier,
|
||||||
) {
|
title = stringResource(R.string.app_profile_template_create),
|
||||||
Icon(Icons.Filled.Create, null)
|
leftAction = {
|
||||||
}
|
Icon(
|
||||||
} else if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
Icons.Rounded.Create,
|
||||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
null,
|
||||||
},
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
tint = MiuixTheme.colorScheme.onBackground
|
||||||
)
|
)
|
||||||
if (profileTemplates.isEmpty()) {
|
},
|
||||||
return@ExposedDropdownMenuBox
|
onClick = onManageTemplate,
|
||||||
}
|
)
|
||||||
ExposedDropdownMenu(
|
} else {
|
||||||
expanded = expanded,
|
var template by rememberSaveable { mutableStateOf(profile.rootTemplate ?: profileTemplates[0]) }
|
||||||
onDismissRequest = { expanded = false }
|
|
||||||
) {
|
Column(modifier = modifier) {
|
||||||
profileTemplates.forEach { tid ->
|
SuperDropdown(
|
||||||
val templateInfo =
|
title = stringResource(R.string.profile_template),
|
||||||
getTemplateInfoById(tid) ?: return@forEach
|
items = profileTemplates,
|
||||||
DropdownMenuItem(
|
selectedIndex = profileTemplates.indexOf(template).takeIf { it >= 0 } ?: 0,
|
||||||
text = { Text(tid) },
|
onSelectedIndexChange = { index ->
|
||||||
onClick = {
|
if (index < 0 || index >= profileTemplates.size) return@SuperDropdown
|
||||||
template = tid
|
template = profileTemplates[index]
|
||||||
if (setSepolicy(tid, templateInfo.rules.joinToString("\n"))) {
|
val templateInfo = getTemplateInfoById(template)
|
||||||
|
if (templateInfo != null && setSepolicy(template, templateInfo.rules.joinToString("\n"))) {
|
||||||
onProfileChange(
|
onProfileChange(
|
||||||
profile.copy(
|
profile.copy(
|
||||||
rootTemplate = tid,
|
rootTemplate = template,
|
||||||
rootUseDefault = false,
|
rootUseDefault = false,
|
||||||
uid = templateInfo.uid,
|
uid = templateInfo.uid,
|
||||||
gid = templateInfo.gid,
|
gid = templateInfo.gid,
|
||||||
@@ -88,18 +79,16 @@ fun TemplateConfig(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
expanded = false
|
|
||||||
},
|
},
|
||||||
trailingIcon = {
|
onClick = {
|
||||||
IconButton(onClick = {
|
expanded = !expanded
|
||||||
onViewTemplate(tid)
|
},
|
||||||
}) {
|
maxHeight = 280.dp
|
||||||
Icon(Icons.AutoMirrored.Filled.ReadMore, null)
|
)
|
||||||
}
|
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.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.edit
|
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
|
||||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
|
||||||
import com.sukisu.ultra.ui.util.reboot
|
import com.sukisu.ultra.ui.util.reboot
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.FlashState
|
import com.sukisu.ultra.ui.kernelFlash.state.*
|
||||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelState
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelWorker
|
import top.yukonga.miuix.kmp.basic.FloatingActionButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.LinearProgressIndicator
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Save
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -60,12 +65,17 @@ private object KernelFlashStateHolder {
|
|||||||
var currentKpmPatchEnabled: Boolean = false
|
var currentKpmPatchEnabled: Boolean = false
|
||||||
var currentKpmUndoPatch: Boolean = false
|
var currentKpmUndoPatch: Boolean = false
|
||||||
var isFlashing = false
|
var isFlashing = false
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
currentState = null
|
||||||
|
currentUri = null
|
||||||
|
currentSlot = null
|
||||||
|
currentKpmPatchEnabled = false
|
||||||
|
currentKpmUndoPatch = false
|
||||||
|
isFlashing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Kernel刷写界面
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
@Destination<RootGraph>
|
||||||
@Composable
|
@Composable
|
||||||
fun KernelFlashScreen(
|
fun KernelFlashScreen(
|
||||||
@@ -76,15 +86,7 @@ fun KernelFlashScreen(
|
|||||||
kpmUndoPatch: Boolean = false
|
kpmUndoPatch: Boolean = false
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val shouldAutoExit = remember {
|
|
||||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
|
||||||
sharedPref.getBoolean("auto_exit_after_flash", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var logText by rememberSaveable { mutableStateOf("") }
|
var logText by rememberSaveable { mutableStateOf("") }
|
||||||
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -109,19 +111,27 @@ fun KernelFlashScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val flashState by horizonKernelState.state.collectAsState()
|
val flashState by horizonKernelState.state.collectAsState()
|
||||||
val logSavedString = stringResource(R.string.log_saved)
|
val activity = LocalActivity.current
|
||||||
|
|
||||||
val onFlashComplete = {
|
val onFlashComplete = {
|
||||||
showFloatAction = true
|
showFloatAction = true
|
||||||
KernelFlashStateHolder.isFlashing = false
|
KernelFlashStateHolder.isFlashing = false
|
||||||
|
}
|
||||||
|
|
||||||
// 如果需要自动退出,延迟1.5秒后退出
|
// 如果是从外部打开的内核刷写,延迟1.5秒后自动退出
|
||||||
if (shouldAutoExit) {
|
LaunchedEffect(flashState.isCompleted, flashState.error) {
|
||||||
scope.launch {
|
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)
|
delay(1500)
|
||||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
KernelFlashStateHolder.clear()
|
||||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
activity.finish()
|
||||||
(context as? ComponentActivity)?.finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,36 +181,30 @@ fun KernelFlashScreen(
|
|||||||
|
|
||||||
val onBack: () -> Unit = {
|
val onBack: () -> Unit = {
|
||||||
if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) {
|
if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) {
|
||||||
// 清理全局状态
|
|
||||||
if (flashState.isCompleted || flashState.error.isNotEmpty()) {
|
if (flashState.isCompleted || flashState.error.isNotEmpty()) {
|
||||||
KernelFlashStateHolder.currentState = null
|
KernelFlashStateHolder.clear()
|
||||||
KernelFlashStateHolder.currentUri = null
|
|
||||||
KernelFlashStateHolder.currentSlot = null
|
|
||||||
KernelFlashStateHolder.currentKpmPatchEnabled = false
|
|
||||||
KernelFlashStateHolder.currentKpmUndoPatch = false
|
|
||||||
KernelFlashStateHolder.isFlashing = false
|
|
||||||
}
|
}
|
||||||
navigator.popBackStack()
|
navigator.popBackStack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(shouldAutoExit) {
|
// 清理状态
|
||||||
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
if (shouldAutoExit) {
|
if (flashState.isCompleted || flashState.error.isNotEmpty()) {
|
||||||
KernelFlashStateHolder.currentState = null
|
KernelFlashStateHolder.clear()
|
||||||
KernelFlashStateHolder.currentUri = null
|
|
||||||
KernelFlashStateHolder.currentSlot = null
|
|
||||||
KernelFlashStateHolder.currentKpmPatchEnabled = false
|
|
||||||
KernelFlashStateHolder.currentKpmUndoPatch = false
|
|
||||||
KernelFlashStateHolder.isFlashing = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
BackHandler {
|
||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KeyEventBlocker {
|
||||||
|
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopBar(
|
TopBar(
|
||||||
@@ -215,15 +219,13 @@ fun KernelFlashScreen(
|
|||||||
"KernelSU_kernel_flash_log_${date}.log"
|
"KernelSU_kernel_flash_log_${date}.log"
|
||||||
)
|
)
|
||||||
file.writeText(logContent.toString())
|
file.writeText(logContent.toString())
|
||||||
snackBarHost.showSnackbar(logSavedString.format(file.absolutePath))
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (showFloatAction) {
|
if (showFloatAction) {
|
||||||
ExtendedFloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -231,34 +233,22 @@ fun KernelFlashScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = {
|
modifier = Modifier.padding(bottom = 20.dp, end = 20.dp)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Refresh,
|
Icons.Rounded.Refresh,
|
||||||
contentDescription = stringResource(id = R.string.reboot)
|
contentDescription = stringResource(id = R.string.reboot)
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
text = {
|
|
||||||
Text(text = stringResource(id = R.string.reboot))
|
|
||||||
},
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
expanded = true
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
popupHost = { }
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
) {
|
||||||
containerColor = MaterialTheme.colorScheme.background
|
|
||||||
) { innerPadding ->
|
|
||||||
KeyEventBlocker {
|
|
||||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
.padding(it)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.scrollEndHaptic(),
|
||||||
) {
|
) {
|
||||||
FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch)
|
FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch)
|
||||||
Box(
|
Box(
|
||||||
@@ -273,9 +263,8 @@ fun KernelFlashScreen(
|
|||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
text = logText,
|
text = logText,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,24 +277,21 @@ private fun FlashProgressIndicator(
|
|||||||
kpmPatchEnabled: Boolean = false,
|
kpmPatchEnabled: Boolean = false,
|
||||||
kpmUndoPatch: Boolean = false
|
kpmUndoPatch: Boolean = false
|
||||||
) {
|
) {
|
||||||
val progressColor = when {
|
val statusColor = when {
|
||||||
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
|
flashState.error.isNotEmpty() -> colorScheme.error
|
||||||
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
|
flashState.isCompleted -> colorScheme.primary
|
||||||
else -> MaterialTheme.colorScheme.primary
|
else -> colorScheme.primary
|
||||||
}
|
}
|
||||||
|
|
||||||
val progress = animateFloatAsState(
|
val progress = animateFloatAsState(
|
||||||
targetValue = flashState.progress,
|
targetValue = flashState.progress.coerceIn(0f, 1f),
|
||||||
label = "FlashProgress"
|
label = "FlashProgress"
|
||||||
)
|
)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -323,9 +309,9 @@ private fun FlashProgressIndicator(
|
|||||||
flashState.isCompleted -> stringResource(R.string.flash_success)
|
flashState.isCompleted -> stringResource(R.string.flash_success)
|
||||||
else -> stringResource(R.string.flashing)
|
else -> stringResource(R.string.flashing)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.titleMedium,
|
fontSize = MiuixTheme.textStyles.title4.fontSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Medium,
|
||||||
color = progressColor
|
color = statusColor
|
||||||
)
|
)
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -333,14 +319,14 @@ private fun FlashProgressIndicator(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Error,
|
imageVector = Icons.Default.Error,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = colorScheme.error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
flashState.isCompleted -> {
|
flashState.isCompleted -> {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.CheckCircle,
|
imageVector = Icons.Default.CheckCircle,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.tertiary
|
tint = colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,128 +334,87 @@ private fun FlashProgressIndicator(
|
|||||||
|
|
||||||
// KPM状态显示
|
// KPM状态显示
|
||||||
if (kpmPatchEnabled || kpmUndoPatch) {
|
if (kpmPatchEnabled || kpmUndoPatch) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode)
|
text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode)
|
||||||
else stringResource(R.string.kpm_patch_mode),
|
else stringResource(R.string.kpm_patch_mode),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||||
color = MaterialTheme.colorScheme.tertiary
|
color = colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
if (flashState.currentStep.isNotEmpty()) {
|
if (flashState.currentStep.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = flashState.currentStep,
|
text = flashState.currentStep,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { progress.value },
|
progress = progress.value,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp),
|
|
||||||
color = progressColor,
|
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (flashState.error.isNotEmpty()) {
|
if (flashState.error.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Error,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = flashState.error,
|
text = flashState.error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = colorScheme.onErrorContainer,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp)
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
|
colorScheme.errorContainer
|
||||||
shape = MaterialTheme.shapes.small
|
|
||||||
)
|
)
|
||||||
.padding(8.dp)
|
.padding(12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TopBar(
|
private fun TopBar(
|
||||||
flashState: FlashState,
|
flashState: FlashState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSave: () -> Unit = {},
|
onSave: () -> Unit = {}
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
|
||||||
) {
|
) {
|
||||||
val statusColor = when {
|
SmallTopAppBar(
|
||||||
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
|
title = stringResource(
|
||||||
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
|
|
||||||
else -> MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
|
||||||
colorScheme.surfaceContainerLow
|
|
||||||
} else {
|
|
||||||
colorScheme.background
|
|
||||||
}
|
|
||||||
val cardAlpha = CardConfig.cardAlpha
|
|
||||||
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
when {
|
when {
|
||||||
flashState.error.isNotEmpty() -> R.string.flash_failed
|
flashState.error.isNotEmpty() -> R.string.flash_failed
|
||||||
flashState.isCompleted -> R.string.flash_success
|
flashState.isCompleted -> R.string.flash_success
|
||||||
else -> R.string.kernel_flashing
|
else -> R.string.kernel_flashing
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = statusColor
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
onClick = onBack
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
MiuixIcons.Useful.Back,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
tint = colorScheme.onBackground
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onSave) {
|
IconButton(
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
onClick = onSave
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Save,
|
imageVector = MiuixIcons.Useful.Save,
|
||||||
contentDescription = stringResource(id = R.string.save_log),
|
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.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
@@ -6,11 +6,12 @@ import android.content.Context
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
import com.sukisu.ultra.network.RemoteToolsDownloader
|
import com.sukisu.ultra.ui.kernelFlash.util.AssetsUtil
|
||||||
|
import com.sukisu.ultra.ui.kernelFlash.util.RemoteToolsDownloader
|
||||||
|
import com.sukisu.ultra.ui.util.getRootShell
|
||||||
import com.sukisu.ultra.ui.util.install
|
import com.sukisu.ultra.ui.util.install
|
||||||
import com.sukisu.ultra.ui.util.rootAvailable
|
import com.sukisu.ultra.ui.util.rootAvailable
|
||||||
import com.sukisu.ultra.utils.AssetsUtil
|
import com.topjohnwu.superuser.ShellUtils
|
||||||
import com.topjohnwu.superuser.Shell
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -74,10 +75,6 @@ class HorizonKernelState {
|
|||||||
fun completeFlashing() {
|
fun completeFlashing() {
|
||||||
_state.update { it.copy(isCompleted = true, progress = 1f) }
|
_state.update { it.copy(isCompleted = true, progress = 1f) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
_state.value = FlashState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class HorizonKernelWorker(
|
class HorizonKernelWorker(
|
||||||
@@ -157,7 +154,12 @@ class HorizonKernelWorker(
|
|||||||
if (isAbDevice && slot != null) {
|
if (isAbDevice && slot != null) {
|
||||||
state.updateStep(context.getString(R.string.horizon_getting_original_slot))
|
state.updateStep(context.getString(R.string.horizon_getting_original_slot))
|
||||||
state.updateProgress(0.72f)
|
state.updateProgress(0.72f)
|
||||||
originalSlot = runCommandGetOutput("getprop ro.boot.slot_suffix")
|
originalSlot = try {
|
||||||
|
val shell = getRootShell()
|
||||||
|
ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
state.updateStep(context.getString(R.string.horizon_setting_target_slot))
|
state.updateStep(context.getString(R.string.horizon_setting_target_slot))
|
||||||
state.updateProgress(0.74f)
|
state.updateProgress(0.74f)
|
||||||
@@ -308,7 +310,12 @@ class HorizonKernelWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查找Image文件
|
// 查找Image文件
|
||||||
val findImageResult = runCommandGetOutput("find $extractDir -name '*Image*' -type f")
|
val findImageResult = try {
|
||||||
|
val shell = getRootShell()
|
||||||
|
ShellUtils.fastCmd(shell, "find $extractDir -name '*Image*' -type f").trim()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw IOException(context.getString(R.string.kpm_image_file_not_found))
|
||||||
|
}
|
||||||
if (findImageResult.isBlank()) {
|
if (findImageResult.isBlank()) {
|
||||||
throw IOException(context.getString(R.string.kpm_image_file_not_found))
|
throw IOException(context.getString(R.string.kpm_image_file_not_found))
|
||||||
}
|
}
|
||||||
@@ -398,11 +405,16 @@ class HorizonKernelWorker(
|
|||||||
|
|
||||||
// 检查设备是否为AB分区设备
|
// 检查设备是否为AB分区设备
|
||||||
private fun isAbDevice(): Boolean {
|
private fun isAbDevice(): Boolean {
|
||||||
val abUpdate = runCommandGetOutput("getprop ro.build.ab_update")
|
return try {
|
||||||
|
val shell = getRootShell()
|
||||||
|
val abUpdate = ShellUtils.fastCmd(shell, "getprop ro.build.ab_update").trim()
|
||||||
if (!abUpdate.toBoolean()) return false
|
if (!abUpdate.toBoolean()) return false
|
||||||
|
|
||||||
val slotSuffix = runCommandGetOutput("getprop ro.boot.slot_suffix")
|
val slotSuffix = ShellUtils.fastCmd(shell, "getprop ro.boot.slot_suffix").trim()
|
||||||
return slotSuffix.isNotEmpty()
|
slotSuffix.isNotEmpty()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanup() {
|
private fun cleanup() {
|
||||||
@@ -429,7 +441,12 @@ class HorizonKernelWorker(
|
|||||||
|
|
||||||
@SuppressLint("StringFormatInvalid")
|
@SuppressLint("StringFormatInvalid")
|
||||||
private fun patch() {
|
private fun patch() {
|
||||||
val kernelVersion = runCommandGetOutput("cat /proc/version")
|
val kernelVersion = try {
|
||||||
|
val shell = getRootShell()
|
||||||
|
ShellUtils.fastCmd(shell, "cat /proc/version")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
val versionRegex = """\d+\.\d+\.\d+""".toRegex()
|
val versionRegex = """\d+\.\d+\.\d+""".toRegex()
|
||||||
val version = kernelVersion.let { versionRegex.find(it) }?.value ?: ""
|
val version = kernelVersion.let { versionRegex.find(it) }?.value ?: ""
|
||||||
val toolName = if (version.isNotEmpty()) {
|
val toolName = if (version.isNotEmpty()) {
|
||||||
@@ -447,7 +464,9 @@ class HorizonKernelWorker(
|
|||||||
val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
|
val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
|
||||||
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
|
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
|
||||||
state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}")
|
state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}")
|
||||||
runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $toolPath \$AKHOME/tools;' $binaryPath")
|
runCommand(false,
|
||||||
|
$$"sed -i '/chmod -R 755 tools bin;/i cp -f $$toolPath $AKHOME/tools;' $$binaryPath"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun flash() {
|
private fun flash() {
|
||||||
@@ -517,8 +536,4 @@ class HorizonKernelWorker(
|
|||||||
process.destroy()
|
process.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runCommandGetOutput(cmd: String): String {
|
|
||||||
return Shell.cmd(cmd).exec().out.joinToString("\n").trim()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.sukisu.ultra.utils
|
package com.sukisu.ultra.ui.kernelFlash.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.sukisu.ultra.network
|
package com.sukisu.ultra.ui.kernelFlash.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.sukisu.ultra.ui.util.getRootShell
|
||||||
|
import com.topjohnwu.superuser.ShellUtils
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -23,8 +25,8 @@ class RemoteToolsDownloader(
|
|||||||
private const val KPIMG_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kpimg"
|
private const val KPIMG_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kpimg"
|
||||||
|
|
||||||
// 网络超时配置(毫秒)
|
// 网络超时配置(毫秒)
|
||||||
private const val CONNECTION_TIMEOUT = 15000 // 15秒连接超时
|
private const val CONNECTION_TIMEOUT = 10000
|
||||||
private const val READ_TIMEOUT = 30000 // 30秒读取超时
|
private const val READ_TIMEOUT = 20000
|
||||||
|
|
||||||
// 最大重试次数
|
// 最大重试次数
|
||||||
private const val MAX_RETRY_COUNT = 3
|
private const val MAX_RETRY_COUNT = 3
|
||||||
@@ -48,47 +50,26 @@ class RemoteToolsDownloader(
|
|||||||
|
|
||||||
|
|
||||||
suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) {
|
suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) {
|
||||||
val results = mutableMapOf<String, DownloadResult>()
|
|
||||||
|
|
||||||
listener?.onLog("Starting to prepare KPM tool files...")
|
listener?.onLog("Starting to prepare KPM tool files...")
|
||||||
|
|
||||||
try {
|
|
||||||
// 确保工作目录存在
|
|
||||||
File(workDir).mkdirs()
|
File(workDir).mkdirs()
|
||||||
|
|
||||||
// 并行下载两个工具文件
|
// 并行下载两个工具文件
|
||||||
val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) }
|
val results = mapOf(
|
||||||
val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
|
"kptools" to async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) },
|
||||||
|
"kpimg" to async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
|
||||||
|
).mapValues { it.value.await() }
|
||||||
|
|
||||||
// 等待所有下载完成
|
// 设置 kptools 执行权限
|
||||||
results["kptools"] = kptoolsDeferred.await()
|
File(workDir, "kptools").takeIf { it.exists() }?.let { file ->
|
||||||
results["kpimg"] = kpimgDeferred.await()
|
setExecutablePermission(file.absolutePath)
|
||||||
|
|
||||||
// 检查kptools执行权限
|
|
||||||
val kptoolsFile = File(workDir, "kptools")
|
|
||||||
if (kptoolsFile.exists()) {
|
|
||||||
setExecutablePermission(kptoolsFile.absolutePath)
|
|
||||||
listener?.onLog("Set kptools execution permission")
|
listener?.onLog("Set kptools execution permission")
|
||||||
}
|
}
|
||||||
|
|
||||||
val successCount = results.values.count { it.success }
|
val successCount = results.values.count { it.success }
|
||||||
val remoteCount = results.values.count { it.success && it.isRemoteSource }
|
val remoteCount = results.values.count { it.success && it.isRemoteSource }
|
||||||
|
|
||||||
listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount")
|
listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount")
|
||||||
|
|
||||||
} catch (e: Exception) {
|
results
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun downloadSingleTool(
|
private suspend fun downloadSingleTool(
|
||||||
@@ -96,43 +77,38 @@ class RemoteToolsDownloader(
|
|||||||
remoteUrl: String?,
|
remoteUrl: String?,
|
||||||
listener: DownloadProgressListener?
|
listener: DownloadProgressListener?
|
||||||
): DownloadResult = withContext(Dispatchers.IO) {
|
): DownloadResult = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
val targetFile = File(workDir, fileName)
|
val targetFile = File(workDir, fileName)
|
||||||
|
|
||||||
if (remoteUrl == null) {
|
if (remoteUrl == null) {
|
||||||
return@withContext useLocalVersion(fileName, targetFile, listener)
|
return@withContext useLocalVersion(fileName, targetFile, listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试从远程下载
|
|
||||||
listener?.onLog("Downloading $fileName from remote repository...")
|
listener?.onLog("Downloading $fileName from remote repository...")
|
||||||
|
|
||||||
var lastError = ""
|
|
||||||
|
|
||||||
// 重试机制
|
// 重试机制
|
||||||
|
var lastError = ""
|
||||||
repeat(MAX_RETRY_COUNT) { attempt ->
|
repeat(MAX_RETRY_COUNT) { attempt ->
|
||||||
try {
|
try {
|
||||||
val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener)
|
val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
listener?.onSuccess(fileName, true)
|
listener?.onSuccess(fileName, true)
|
||||||
return@withContext result
|
return@withContext result
|
||||||
}
|
} else {
|
||||||
lastError = result.errorMessage ?: "Unknown error"
|
lastError = result.errorMessage ?: "Unknown error"
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
lastError = e.message ?: "Network exception"
|
lastError = "Network exception"
|
||||||
Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e)
|
Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
if (attempt < MAX_RETRY_COUNT - 1) {
|
if (attempt < MAX_RETRY_COUNT - 1) {
|
||||||
listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...")
|
listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...")
|
||||||
delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L))
|
delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 所有重试都失败,回退到本地版本
|
|
||||||
listener?.onError(fileName, "Remote download failed: $lastError")
|
listener?.onError(fileName, "Remote download failed: $lastError")
|
||||||
listener?.onLog("$fileName remote download failed, falling back to local version...")
|
listener?.onLog("$fileName remote download failed, falling back to local version...")
|
||||||
|
|
||||||
useLocalVersion(fileName, targetFile, listener)
|
useLocalVersion(fileName, targetFile, listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,15 +118,10 @@ class RemoteToolsDownloader(
|
|||||||
targetFile: File,
|
targetFile: File,
|
||||||
listener: DownloadProgressListener?
|
listener: DownloadProgressListener?
|
||||||
): DownloadResult = withContext(Dispatchers.IO) {
|
): DownloadResult = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
var connection: HttpURLConnection? = null
|
var connection: HttpURLConnection? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val url = URL(remoteUrl)
|
connection = (URL(remoteUrl).openConnection() as HttpURLConnection).apply {
|
||||||
connection = url.openConnection() as HttpURLConnection
|
|
||||||
|
|
||||||
// 设置连接参数
|
|
||||||
connection.apply {
|
|
||||||
connectTimeout = CONNECTION_TIMEOUT
|
connectTimeout = CONNECTION_TIMEOUT
|
||||||
readTimeout = READ_TIMEOUT
|
readTimeout = READ_TIMEOUT
|
||||||
requestMethod = "GET"
|
requestMethod = "GET"
|
||||||
@@ -159,22 +130,17 @@ class RemoteToolsDownloader(
|
|||||||
setRequestProperty("Connection", "close")
|
setRequestProperty("Connection", "close")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 建立连接
|
|
||||||
connection.connect()
|
connection.connect()
|
||||||
|
|
||||||
val responseCode = connection.responseCode
|
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
|
||||||
return@withContext DownloadResult(
|
return@withContext DownloadResult(
|
||||||
false,
|
false,
|
||||||
isRemoteSource = false,
|
isRemoteSource = false,
|
||||||
errorMessage = "HTTP error code: $responseCode"
|
errorMessage = "HTTP error code: ${connection.responseCode}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val fileLength = connection.contentLength
|
val fileLength = connection.contentLength
|
||||||
Log.d(TAG, "$fileName remote file size: $fileLength bytes")
|
|
||||||
|
|
||||||
// 创建临时文件
|
|
||||||
val tempFile = File(targetFile.absolutePath + ".tmp")
|
val tempFile = File(targetFile.absolutePath + ".tmp")
|
||||||
|
|
||||||
// 下载文件
|
// 下载文件
|
||||||
@@ -182,40 +148,34 @@ class RemoteToolsDownloader(
|
|||||||
FileOutputStream(tempFile).use { output ->
|
FileOutputStream(tempFile).use { output ->
|
||||||
val buffer = ByteArray(8192)
|
val buffer = ByteArray(8192)
|
||||||
var totalBytes = 0
|
var totalBytes = 0
|
||||||
var bytesRead: Int
|
|
||||||
|
|
||||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
while (true) {
|
||||||
// 检查协程是否被取消
|
|
||||||
ensureActive()
|
ensureActive()
|
||||||
|
val bytesRead = input.read(buffer)
|
||||||
|
if (bytesRead == -1) break
|
||||||
|
|
||||||
output.write(buffer, 0, bytesRead)
|
output.write(buffer, 0, bytesRead)
|
||||||
totalBytes += bytesRead
|
totalBytes += bytesRead
|
||||||
|
|
||||||
// 更新下载进度
|
|
||||||
if (fileLength > 0) {
|
if (fileLength > 0) {
|
||||||
listener?.onProgress(fileName, totalBytes, fileLength)
|
listener?.onProgress(fileName, totalBytes, fileLength)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
output.flush()
|
output.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证下载的文件
|
// 验证并移动文件
|
||||||
if (!validateDownloadedFile(tempFile, fileName)) {
|
if (!validateDownloadedFile(tempFile, fileName)) {
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
return@withContext DownloadResult(
|
return@withContext DownloadResult(
|
||||||
success = false,
|
false,
|
||||||
isRemoteSource = false,
|
isRemoteSource = false,
|
||||||
errorMessage = "File verification failed"
|
errorMessage = "File verification failed"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动临时文件到目标位置
|
|
||||||
if (targetFile.exists()) {
|
|
||||||
targetFile.delete()
|
targetFile.delete()
|
||||||
}
|
|
||||||
|
|
||||||
if (!tempFile.renameTo(targetFile)) {
|
if (!tempFile.renameTo(targetFile)) {
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
return@withContext DownloadResult(
|
return@withContext DownloadResult(
|
||||||
@@ -227,7 +187,6 @@ class RemoteToolsDownloader(
|
|||||||
|
|
||||||
Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes")
|
Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes")
|
||||||
listener?.onLog("$fileName remote download successful")
|
listener?.onLog("$fileName remote download successful")
|
||||||
|
|
||||||
DownloadResult(true, isRemoteSource = true)
|
DownloadResult(true, isRemoteSource = true)
|
||||||
|
|
||||||
} catch (e: SocketTimeoutException) {
|
} catch (e: SocketTimeoutException) {
|
||||||
@@ -235,16 +194,10 @@ class RemoteToolsDownloader(
|
|||||||
DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout")
|
DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout")
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.w(TAG, "$fileName network IO exception", e)
|
Log.w(TAG, "$fileName network IO exception", e)
|
||||||
DownloadResult(false,
|
DownloadResult(false, isRemoteSource = false, errorMessage = "Network exception: ${e.message}")
|
||||||
isRemoteSource = false,
|
|
||||||
errorMessage = "Network connection exception: ${e.message}"
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "$fileName exception occurred during download", e)
|
Log.e(TAG, "$fileName exception occurred during download", e)
|
||||||
DownloadResult(false,
|
DownloadResult(false, isRemoteSource = false, errorMessage = "Download exception: ${e.message}")
|
||||||
isRemoteSource = false,
|
|
||||||
errorMessage = "Download exception: ${e.message}"
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
connection?.disconnect()
|
connection?.disconnect()
|
||||||
}
|
}
|
||||||
@@ -255,61 +208,42 @@ class RemoteToolsDownloader(
|
|||||||
targetFile: File,
|
targetFile: File,
|
||||||
listener: DownloadProgressListener?
|
listener: DownloadProgressListener?
|
||||||
): DownloadResult = withContext(Dispatchers.IO) {
|
): DownloadResult = withContext(Dispatchers.IO) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
com.sukisu.ultra.utils.AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath)
|
AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath)
|
||||||
|
|
||||||
if (!targetFile.exists()) {
|
if (!targetFile.exists() || !validateDownloadedFile(targetFile, fileName)) {
|
||||||
val errorMsg = "Local $fileName file extraction failed"
|
val errorMsg = if (!targetFile.exists()) {
|
||||||
listener?.onError(fileName, errorMsg)
|
"Local $fileName file extraction failed"
|
||||||
return@withContext DownloadResult(false,
|
} else {
|
||||||
isRemoteSource = false,
|
"Local $fileName file verification failed"
|
||||||
errorMessage = errorMsg
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateDownloadedFile(targetFile, fileName)) {
|
|
||||||
val errorMsg = "Local $fileName file verification failed"
|
|
||||||
listener?.onError(fileName, errorMsg)
|
listener?.onError(fileName, errorMsg)
|
||||||
return@withContext DownloadResult(
|
return@withContext DownloadResult(false, isRemoteSource = false, errorMessage = errorMsg)
|
||||||
success = false,
|
|
||||||
isRemoteSource = false,
|
|
||||||
errorMessage = errorMsg
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes")
|
Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes")
|
||||||
listener?.onLog("$fileName local version loaded successfully")
|
listener?.onLog("$fileName local version loaded successfully")
|
||||||
listener?.onSuccess(fileName, false)
|
listener?.onSuccess(fileName, false)
|
||||||
|
|
||||||
DownloadResult(true, isRemoteSource = false)
|
DownloadResult(true, isRemoteSource = false)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "$fileName local version loading failed", e)
|
Log.e(TAG, "$fileName local version loading failed", e)
|
||||||
val errorMsg = "Local version loading failed: ${e.message}"
|
val errorMsg = "Local version loading failed: ${e.message}"
|
||||||
listener?.onError(fileName, errorMsg)
|
listener?.onError(fileName, errorMsg)
|
||||||
DownloadResult(success = false, isRemoteSource = false, errorMessage = errorMsg)
|
DownloadResult(false, isRemoteSource = false, errorMessage = errorMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateDownloadedFile(file: File, fileName: String): Boolean {
|
private fun validateDownloadedFile(file: File, fileName: String): Boolean {
|
||||||
if (!file.exists()) {
|
if (!file.exists() || file.length() < MIN_FILE_SIZE) {
|
||||||
Log.w(TAG, "$fileName file does not exist")
|
Log.w(TAG, "$fileName file validation failed: exists=${file.exists()}, size=${file.length()}")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val fileSize = file.length()
|
return try {
|
||||||
if (fileSize < MIN_FILE_SIZE) {
|
|
||||||
Log.w(TAG, "$fileName file is too small: $fileSize bytes")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
file.inputStream().use { input ->
|
file.inputStream().use { input ->
|
||||||
val header = ByteArray(4)
|
val header = ByteArray(4)
|
||||||
val bytesRead = input.read(header)
|
if (input.read(header) < 4) {
|
||||||
|
|
||||||
if (bytesRead < 4) {
|
|
||||||
Log.w(TAG, "$fileName file header read incomplete")
|
Log.w(TAG, "$fileName file header read incomplete")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -324,20 +258,24 @@ class RemoteToolsDownloader(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF")
|
Log.d(TAG, "$fileName file verification passed, size: ${file.length()} bytes, ELF: $isELF")
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "$fileName file verification exception", e)
|
Log.w(TAG, "$fileName file verification exception", e)
|
||||||
return false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setExecutablePermission(filePath: String) {
|
private fun setExecutablePermission(filePath: String) {
|
||||||
try {
|
try {
|
||||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "chmod a+rx $filePath"))
|
val shell = getRootShell()
|
||||||
process.waitFor()
|
if (ShellUtils.fastCmdResult(shell, "chmod a+rx $filePath")) {
|
||||||
Log.d(TAG, "Set execution permission for $filePath")
|
Log.d(TAG, "Set execution permission for $filePath")
|
||||||
|
} else {
|
||||||
|
File(filePath).setExecutable(true, false)
|
||||||
|
Log.d(TAG, "Set execution permission using Java method for $filePath")
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to set execution permission: $filePath", e)
|
Log.w(TAG, "Failed to set execution permission: $filePath", e)
|
||||||
try {
|
try {
|
||||||
@@ -351,12 +289,10 @@ class RemoteToolsDownloader(
|
|||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
try {
|
try {
|
||||||
File(workDir).listFiles()?.forEach { file ->
|
File(workDir).listFiles()?.filter { it.name.endsWith(".tmp") }?.forEach { file ->
|
||||||
if (file.name.endsWith(".tmp")) {
|
|
||||||
file.delete()
|
file.delete()
|
||||||
Log.d(TAG, "Cleaned temporary file: ${file.name}")
|
Log.d(TAG, "Cleaned temporary file: ${file.name}")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to clean temporary files", e)
|
Log.w(TAG, "Failed to clean temporary files", e)
|
||||||
}
|
}
|
||||||
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
|
package com.sukisu.ultra.ui.screen
|
||||||
|
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import androidx.activity.compose.BackHandler
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.add
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
|
import androidx.compose.foundation.layout.captionBar
|
||||||
|
import androidx.compose.foundation.layout.displayCutout
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.dropUnlessResumed
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import com.sukisu.ultra.R
|
import dev.chrisbanes.haze.HazeState
|
||||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
import dev.chrisbanes.haze.HazeTint
|
||||||
import com.sukisu.ultra.ui.util.runModuleAction
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.sukisu.ultra.R
|
||||||
|
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||||
|
import com.sukisu.ultra.ui.util.runModuleAction
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Save
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Destination<RootGraph>
|
@Destination<RootGraph>
|
||||||
fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) {
|
fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) {
|
||||||
var text by rememberSaveable { mutableStateOf("") }
|
var text by rememberSaveable { mutableStateOf("") }
|
||||||
var tempText : String
|
var tempText: String
|
||||||
val logContent = rememberSaveable { StringBuilder() }
|
val logContent = rememberSaveable { StringBuilder() }
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
var isActionRunning by rememberSaveable { mutableStateOf(true) }
|
var actionResult: Boolean
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
BackHandler(enabled = isActionRunning) {
|
val hazeStyle = HazeStyle(
|
||||||
// Disable back button if action is running
|
backgroundColor = colorScheme.surface,
|
||||||
}
|
tint = HazeTint(colorScheme.surface.copy(0.8f))
|
||||||
|
)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (text.isNotEmpty()) {
|
if (text.isNotEmpty()) {
|
||||||
@@ -65,17 +101,20 @@ fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String
|
|||||||
onStderr = {
|
onStderr = {
|
||||||
logContent.append(it).append("\n")
|
logContent.append(it).append("\n")
|
||||||
}
|
}
|
||||||
)
|
).let {
|
||||||
|
actionResult = it
|
||||||
}
|
}
|
||||||
isActionRunning = false
|
}
|
||||||
|
if (actionResult) navigator.popBackStack()
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopBar(
|
TopBar(
|
||||||
isActionRunning = isActionRunning,
|
onBack = dropUnlessResumed {
|
||||||
|
navigator.popBackStack()
|
||||||
|
},
|
||||||
onSave = {
|
onSave = {
|
||||||
if (!isActionRunning) {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||||
val date = format.format(Date())
|
val date = format.format(Date())
|
||||||
@@ -84,62 +123,86 @@ fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String
|
|||||||
"KernelSU_module_action_log_${date}.log"
|
"KernelSU_module_action_log_${date}.log"
|
||||||
)
|
)
|
||||||
file.writeText(logContent.toString())
|
file.writeText(logContent.toString())
|
||||||
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
|
Toast.makeText(context, "Log saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
if (!isActionRunning) {
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
text = { Text(text = stringResource(R.string.close)) },
|
|
||||||
icon = { Icon(Icons.Filled.Close, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
navigator.popBackStack()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
contentWindowInsets = WindowInsets.safeDrawing,
|
hazeState = hazeState,
|
||||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
hazeStyle = hazeStyle,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
popupHost = { },
|
||||||
|
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
KeyEventBlocker {
|
KeyEventBlocker {
|
||||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(1f)
|
.fillMaxSize(1f)
|
||||||
.padding(innerPadding)
|
.scrollEndHaptic()
|
||||||
|
.hazeSource(state = hazeState)
|
||||||
|
.padding(
|
||||||
|
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||||
|
end = innerPadding.calculateStartPadding(layoutDirection),
|
||||||
|
)
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(text) {
|
LaunchedEffect(text) {
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
scrollState.animateScrollTo(scrollState.maxValue)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(8.dp),
|
modifier = Modifier.padding(8.dp),
|
||||||
text = text,
|
text = text,
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
fontSize = 12.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
)
|
||||||
|
Spacer(
|
||||||
|
Modifier.height(
|
||||||
|
12.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||||
|
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TopBar(isActionRunning: Boolean, onSave: () -> Unit = {}) {
|
private fun TopBar(
|
||||||
TopAppBar(
|
onBack: () -> Unit = {},
|
||||||
title = { Text(stringResource(R.string.action)) },
|
onSave: () -> Unit = {},
|
||||||
actions = {
|
hazeState: HazeState,
|
||||||
|
hazeStyle: HazeStyle,
|
||||||
|
) {
|
||||||
|
SmallTopAppBar(
|
||||||
|
modifier = Modifier.hazeEffect(hazeState) {
|
||||||
|
style = hazeStyle
|
||||||
|
blurRadius = 30.dp
|
||||||
|
noiseFactor = 0f
|
||||||
|
},
|
||||||
|
title = stringResource(R.string.action),
|
||||||
|
navigationIcon = {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onSave,
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
enabled = !isActionRunning
|
onClick = onBack
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Save,
|
imageVector = MiuixIcons.Useful.Back,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
onClick = onSave
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Save,
|
||||||
contentDescription = stringResource(id = R.string.save_log),
|
contentDescription = stringResource(id = R.string.save_log),
|
||||||
|
tint = colorScheme.onBackground
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,257 +1,136 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
package com.sukisu.ultra.ui.screen
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.activity.compose.BackHandler
|
import android.widget.Toast
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.add
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
|
import androidx.compose.foundation.layout.captionBar
|
||||||
|
import androidx.compose.foundation.layout.displayCutout
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.rounded.Refresh
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.material.icons.filled.Save
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.lifecycle.compose.dropUnlessResumed
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.generated.destinations.ModuleScreenDestination
|
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
|
||||||
import com.sukisu.ultra.R
|
|
||||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
|
||||||
import com.sukisu.ultra.ui.util.*
|
|
||||||
import com.sukisu.ultra.ui.viewmodel.ModuleViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import com.sukisu.ultra.R
|
||||||
|
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||||
|
import com.sukisu.ultra.ui.util.FlashResult
|
||||||
|
import com.sukisu.ultra.ui.util.LkmSelection
|
||||||
|
import com.sukisu.ultra.ui.util.flashModule
|
||||||
|
import com.sukisu.ultra.ui.util.installBoot
|
||||||
|
import com.sukisu.ultra.ui.util.reboot
|
||||||
|
import com.sukisu.ultra.ui.util.restoreBoot
|
||||||
|
import com.sukisu.ultra.ui.util.uninstallPermanently
|
||||||
|
import top.yukonga.miuix.kmp.basic.FloatingActionButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Move
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
import androidx.core.content.edit
|
import java.util.Locale
|
||||||
import com.sukisu.ultra.ui.util.module.ModuleOperationUtils
|
|
||||||
import com.sukisu.ultra.ui.util.module.ModuleUtils
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author ShirkNeko
|
* @author weishu
|
||||||
* @date 2025/5/31.
|
* @date 2023/1/1.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
enum class FlashingStatus {
|
enum class FlashingStatus {
|
||||||
FLASHING,
|
FLASHING,
|
||||||
SUCCESS,
|
SUCCESS,
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentFlashingStatus = mutableStateOf(FlashingStatus.FLASHING)
|
// Lets you flash modules sequentially when mutiple zipUris are selected
|
||||||
|
fun flashModulesSequentially(
|
||||||
// 添加模块安装状态跟踪
|
uris: List<Uri>,
|
||||||
data class ModuleInstallStatus(
|
onStdout: (String) -> Unit,
|
||||||
val totalModules: Int = 0,
|
onStderr: (String) -> Unit
|
||||||
val currentModule: Int = 0,
|
): FlashResult {
|
||||||
val currentModuleName: String = "",
|
for (uri in uris) {
|
||||||
val failedModules: MutableList<String> = mutableListOf(),
|
flashModule(uri, onStdout, onStderr).apply {
|
||||||
val verifiedModules: MutableList<String> = mutableListOf() // 添加已验证模块列表
|
if (code != 0) {
|
||||||
)
|
return FlashResult(code, err, showReboot)
|
||||||
|
|
||||||
private var moduleInstallStatus = mutableStateOf(ModuleInstallStatus())
|
|
||||||
|
|
||||||
// 存储模块URI和验证状态的映射
|
|
||||||
private var moduleVerificationMap = mutableMapOf<Uri, Boolean>()
|
|
||||||
|
|
||||||
fun setFlashingStatus(status: FlashingStatus) {
|
|
||||||
currentFlashingStatus.value = status
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateModuleInstallStatus(
|
|
||||||
totalModules: Int? = null,
|
|
||||||
currentModule: Int? = null,
|
|
||||||
currentModuleName: String? = null,
|
|
||||||
failedModule: String? = null,
|
|
||||||
verifiedModule: String? = null
|
|
||||||
) {
|
|
||||||
val current = moduleInstallStatus.value
|
|
||||||
moduleInstallStatus.value = current.copy(
|
|
||||||
totalModules = totalModules ?: current.totalModules,
|
|
||||||
currentModule = currentModule ?: current.currentModule,
|
|
||||||
currentModuleName = currentModuleName ?: current.currentModuleName
|
|
||||||
)
|
|
||||||
|
|
||||||
if (failedModule != null) {
|
|
||||||
val updatedFailedModules = current.failedModules.toMutableList()
|
|
||||||
updatedFailedModules.add(failedModule)
|
|
||||||
moduleInstallStatus.value = moduleInstallStatus.value.copy(
|
|
||||||
failedModules = updatedFailedModules
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (verifiedModule != null) {
|
|
||||||
val updatedVerifiedModules = current.verifiedModules.toMutableList()
|
|
||||||
updatedVerifiedModules.add(verifiedModule)
|
|
||||||
moduleInstallStatus.value = moduleInstallStatus.value.copy(
|
|
||||||
verifiedModules = updatedVerifiedModules
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return FlashResult(0, "", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setModuleVerificationStatus(uri: Uri, isVerified: Boolean) {
|
|
||||||
moduleVerificationMap[uri] = isVerified
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
@Destination<RootGraph>
|
@Destination<RootGraph>
|
||||||
fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
fun FlashScreen(
|
||||||
val context = LocalContext.current
|
navigator: DestinationsNavigator,
|
||||||
|
flashIt: FlashIt
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var text by rememberSaveable { mutableStateOf("") }
|
var text by rememberSaveable { mutableStateOf("") }
|
||||||
var tempText: String
|
var tempText: String
|
||||||
val logContent = rememberSaveable { StringBuilder() }
|
val logContent = rememberSaveable { StringBuilder() }
|
||||||
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
||||||
// 添加状态跟踪是否已经完成刷写
|
|
||||||
var hasFlashCompleted by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var hasExecuted by rememberSaveable { mutableStateOf(false) }
|
|
||||||
// 更新模块状态管理
|
|
||||||
var hasUpdateExecuted by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var hasUpdateCompleted by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
val context = LocalContext.current
|
||||||
|
val activity = LocalActivity.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
var flashing by rememberSaveable {
|
||||||
val viewModel: ModuleViewModel = viewModel()
|
mutableStateOf(FlashingStatus.FLASHING)
|
||||||
|
|
||||||
val errorCodeString = stringResource(R.string.error_code)
|
|
||||||
val checkLogString = stringResource(R.string.check_log)
|
|
||||||
val logSavedString = stringResource(R.string.log_saved)
|
|
||||||
val installingModuleString = stringResource(R.string.installing_module)
|
|
||||||
|
|
||||||
// 当前模块安装状态
|
|
||||||
val currentStatus = moduleInstallStatus.value
|
|
||||||
|
|
||||||
// 重置状态
|
|
||||||
LaunchedEffect(flashIt) {
|
|
||||||
when (flashIt) {
|
|
||||||
is FlashIt.FlashModules -> {
|
|
||||||
if (flashIt.currentIndex == 0) {
|
|
||||||
moduleInstallStatus.value = ModuleInstallStatus(
|
|
||||||
totalModules = flashIt.uris.size,
|
|
||||||
currentModule = 1
|
|
||||||
)
|
|
||||||
hasFlashCompleted = false
|
|
||||||
hasExecuted = false
|
|
||||||
moduleVerificationMap.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is FlashIt.FlashModuleUpdate -> {
|
|
||||||
hasUpdateCompleted = false
|
|
||||||
hasUpdateExecuted = false
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
hasFlashCompleted = false
|
|
||||||
hasExecuted = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理更新模块安装
|
LaunchedEffect(Unit) {
|
||||||
LaunchedEffect(flashIt) {
|
if (text.isNotEmpty()) {
|
||||||
if (flashIt !is FlashIt.FlashModuleUpdate) return@LaunchedEffect
|
|
||||||
if (hasUpdateExecuted || hasUpdateCompleted || text.isNotEmpty()) {
|
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
hasUpdateExecuted = true
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
setFlashingStatus(FlashingStatus.FLASHING)
|
flashIt(flashIt, onStdout = {
|
||||||
|
|
||||||
try {
|
|
||||||
logContent.append(text).append("\n")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
logContent.append(text).append("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
flashModuleUpdate(flashIt.uri, onFinish = { showReboot, code ->
|
|
||||||
if (code != 0) {
|
|
||||||
text += "$errorCodeString $code.\n$checkLogString\n"
|
|
||||||
setFlashingStatus(FlashingStatus.FAILED)
|
|
||||||
} else {
|
|
||||||
setFlashingStatus(FlashingStatus.SUCCESS)
|
|
||||||
|
|
||||||
// 处理模块更新成功后的验证标志
|
|
||||||
val isVerified = moduleVerificationMap[flashIt.uri] ?: false
|
|
||||||
ModuleOperationUtils.handleModuleUpdate(context, flashIt.uri, isVerified)
|
|
||||||
|
|
||||||
viewModel.markNeedRefresh()
|
|
||||||
}
|
|
||||||
if (showReboot) {
|
|
||||||
text += "\n\n\n"
|
|
||||||
showFloatAction = true
|
|
||||||
|
|
||||||
// 如果是内部安装,显示重启按钮后不自动返回
|
|
||||||
if (isExternalInstall) {
|
|
||||||
return@flashModuleUpdate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hasUpdateCompleted = true
|
|
||||||
|
|
||||||
// 如果是外部安装或需要自动退出的模块更新且不需要重启,延迟后自动返回
|
|
||||||
if (isExternalInstall || shouldAutoExit) {
|
|
||||||
scope.launch {
|
|
||||||
kotlinx.coroutines.delay(1000)
|
|
||||||
if (shouldAutoExit) {
|
|
||||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
|
||||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
|
||||||
}
|
|
||||||
(context as? ComponentActivity)?.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, onStdout = {
|
|
||||||
tempText = "$it\n"
|
tempText = "$it\n"
|
||||||
if (tempText.startsWith("[H[J")) { // clear command
|
if (tempText.startsWith("[H[J")) { // clear command
|
||||||
text = tempText.substring(6)
|
text = tempText.substring(6)
|
||||||
@@ -261,156 +140,41 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
|||||||
logContent.append(it).append("\n")
|
logContent.append(it).append("\n")
|
||||||
}, onStderr = {
|
}, onStderr = {
|
||||||
logContent.append(it).append("\n")
|
logContent.append(it).append("\n")
|
||||||
})
|
}).apply {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 安装但排除更新模块
|
|
||||||
LaunchedEffect(flashIt) {
|
|
||||||
if (flashIt is FlashIt.FlashModuleUpdate) return@LaunchedEffect
|
|
||||||
if (hasExecuted || hasFlashCompleted || text.isNotEmpty()) {
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
hasExecuted = true
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
setFlashingStatus(FlashingStatus.FLASHING)
|
|
||||||
|
|
||||||
if (flashIt is FlashIt.FlashModules) {
|
|
||||||
try {
|
|
||||||
val currentUri = flashIt.uris[flashIt.currentIndex]
|
|
||||||
val moduleName = getModuleNameFromUri(context, currentUri)
|
|
||||||
updateModuleInstallStatus(
|
|
||||||
currentModuleName = moduleName
|
|
||||||
)
|
|
||||||
text = installingModuleString.format(flashIt.currentIndex + 1, flashIt.uris.size, moduleName)
|
|
||||||
logContent.append(text).append("\n")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
text = installingModuleString.format(flashIt.currentIndex + 1, flashIt.uris.size, "Module")
|
|
||||||
logContent.append(text).append("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flashIt(flashIt, onFinish = { showReboot, code ->
|
|
||||||
if (code != 0) {
|
if (code != 0) {
|
||||||
text += "$errorCodeString $code.\n$checkLogString\n"
|
text += "Error code: $code.\n $err Please save and check the log.\n"
|
||||||
setFlashingStatus(FlashingStatus.FAILED)
|
|
||||||
|
|
||||||
if (flashIt is FlashIt.FlashModules) {
|
|
||||||
updateModuleInstallStatus(
|
|
||||||
failedModule = moduleInstallStatus.value.currentModuleName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setFlashingStatus(FlashingStatus.SUCCESS)
|
|
||||||
|
|
||||||
// 处理模块安装成功后的验证标志
|
|
||||||
when (flashIt) {
|
|
||||||
is FlashIt.FlashModule -> {
|
|
||||||
val isVerified = moduleVerificationMap[flashIt.uri] ?: false
|
|
||||||
ModuleOperationUtils.handleModuleInstallSuccess(context, flashIt.uri, isVerified)
|
|
||||||
if (isVerified) {
|
|
||||||
updateModuleInstallStatus(verifiedModule = moduleInstallStatus.value.currentModuleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is FlashIt.FlashModules -> {
|
|
||||||
val currentUri = flashIt.uris[flashIt.currentIndex]
|
|
||||||
val isVerified = moduleVerificationMap[currentUri] ?: false
|
|
||||||
ModuleOperationUtils.handleModuleInstallSuccess(context, currentUri, isVerified)
|
|
||||||
if (isVerified) {
|
|
||||||
updateModuleInstallStatus(verifiedModule = moduleInstallStatus.value.currentModuleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.markNeedRefresh()
|
|
||||||
}
|
}
|
||||||
if (showReboot) {
|
if (showReboot) {
|
||||||
text += "\n\n\n"
|
text += "\n\n\n"
|
||||||
showFloatAction = true
|
showFloatAction = true
|
||||||
}
|
}
|
||||||
|
flashing = if (code == 0) FlashingStatus.SUCCESS else FlashingStatus.FAILED
|
||||||
hasFlashCompleted = true
|
|
||||||
|
|
||||||
if (flashIt is FlashIt.FlashModules && flashIt.currentIndex < flashIt.uris.size - 1) {
|
|
||||||
val nextFlashIt = flashIt.copy(
|
|
||||||
currentIndex = flashIt.currentIndex + 1
|
|
||||||
)
|
|
||||||
scope.launch {
|
|
||||||
kotlinx.coroutines.delay(500)
|
|
||||||
navigator.navigate(FlashScreenDestination(nextFlashIt))
|
|
||||||
}
|
|
||||||
} else if ((isExternalInstall || shouldAutoExit) && flashIt is FlashIt.FlashModules && flashIt.currentIndex >= flashIt.uris.size - 1) {
|
|
||||||
// 如果是外部安装或需要自动退出且是最后一个模块,安装完成后自动返回
|
|
||||||
scope.launch {
|
|
||||||
kotlinx.coroutines.delay(1000)
|
|
||||||
if (shouldAutoExit) {
|
|
||||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
|
||||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
|
||||||
}
|
|
||||||
(context as? ComponentActivity)?.finish()
|
|
||||||
}
|
|
||||||
} else if ((isExternalInstall || shouldAutoExit) && flashIt is FlashIt.FlashModule) {
|
|
||||||
// 如果是外部安装或需要自动退出的单个模块,安装完成后自动返回
|
|
||||||
scope.launch {
|
|
||||||
kotlinx.coroutines.delay(1000)
|
|
||||||
if (shouldAutoExit) {
|
|
||||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
|
||||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
|
||||||
}
|
|
||||||
(context as? ComponentActivity)?.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, onStdout = {
|
|
||||||
tempText = "$it\n"
|
|
||||||
if (tempText.startsWith("[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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
// 如果是从外部打开的模块安装,延迟1秒后自动退出
|
||||||
onBack()
|
LaunchedEffect(flashing, flashIt) {
|
||||||
|
if (flashing == FlashingStatus.SUCCESS && flashIt is FlashIt.FlashModules) {
|
||||||
|
val intent = activity?.intent
|
||||||
|
val isFromExternalIntent = intent?.action?.let { action ->
|
||||||
|
action == Intent.ACTION_VIEW ||
|
||||||
|
action == Intent.ACTION_SEND ||
|
||||||
|
action == Intent.ACTION_SEND_MULTIPLE
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
if (isFromExternalIntent) {
|
||||||
|
delay(1000)
|
||||||
|
activity.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopBar(
|
TopBar(
|
||||||
currentFlashingStatus.value,
|
flashing,
|
||||||
currentStatus,
|
onBack = dropUnlessResumed { navigator.popBackStack() },
|
||||||
onBack = onBack,
|
|
||||||
onSave = {
|
onSave = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||||
@@ -420,15 +184,22 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
|||||||
"KernelSU_install_log_${date}.log"
|
"KernelSU_install_log_${date}.log"
|
||||||
)
|
)
|
||||||
file.writeText(logContent.toString())
|
file.writeText(logContent.toString())
|
||||||
snackBarHost.showSnackbar(logSavedString.format(file.absolutePath))
|
Toast.makeText(context, "Log saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (showFloatAction) {
|
if (showFloatAction) {
|
||||||
ExtendedFloatingActionButton(
|
val reboot = stringResource(id = R.string.reboot)
|
||||||
|
FloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||||
|
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding() + 20.dp,
|
||||||
|
end = 20.dp
|
||||||
|
)
|
||||||
|
.border(0.05.dp, colorScheme.outline.copy(alpha = 0.5f), CircleShape),
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -436,25 +207,22 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = {
|
shadowElevation = 0.dp,
|
||||||
|
content = {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Refresh,
|
Icons.Rounded.Refresh,
|
||||||
contentDescription = stringResource(id = R.string.reboot)
|
reboot,
|
||||||
|
Modifier.size(40.dp),
|
||||||
|
tint = Color.White
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
|
||||||
Text(text = stringResource(id = R.string.reboot))
|
|
||||||
},
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
expanded = true
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
popupHost = { },
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||||
containerColor = MaterialTheme.colorScheme.background
|
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
KeyEventBlocker {
|
KeyEventBlocker {
|
||||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||||
}
|
}
|
||||||
@@ -462,307 +230,107 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(1f)
|
.fillMaxSize(1f)
|
||||||
.padding(innerPadding)
|
.scrollEndHaptic()
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.padding(
|
||||||
) {
|
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||||
if (flashIt is FlashIt.FlashModules) {
|
end = innerPadding.calculateStartPadding(layoutDirection),
|
||||||
ModuleInstallProgressBar(
|
|
||||||
currentIndex = flashIt.currentIndex + 1,
|
|
||||||
totalCount = flashIt.uris.size,
|
|
||||||
currentModuleName = currentStatus.currentModuleName,
|
|
||||||
status = currentFlashingStatus.value,
|
|
||||||
failedModules = currentStatus.failedModules
|
|
||||||
)
|
)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f)
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(text) {
|
LaunchedEffect(text) {
|
||||||
scrollState.animateScrollTo(scrollState.maxValue)
|
scrollState.animateScrollTo(scrollState.maxValue)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(8.dp),
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
fontSize = 12.sp,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
)
|
||||||
}
|
Spacer(
|
||||||
}
|
Modifier.height(
|
||||||
}
|
12.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||||
}
|
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||||
|
|
||||||
// 显示模块安装进度条和状态
|
|
||||||
@Composable
|
|
||||||
fun ModuleInstallProgressBar(
|
|
||||||
currentIndex: Int,
|
|
||||||
totalCount: Int,
|
|
||||||
currentModuleName: String,
|
|
||||||
status: FlashingStatus,
|
|
||||||
failedModules: List<String>
|
|
||||||
) {
|
|
||||||
val progressColor = when(status) {
|
|
||||||
FlashingStatus.FLASHING -> MaterialTheme.colorScheme.primary
|
|
||||||
FlashingStatus.SUCCESS -> MaterialTheme.colorScheme.tertiary
|
|
||||||
FlashingStatus.FAILED -> MaterialTheme.colorScheme.error
|
|
||||||
}
|
|
||||||
|
|
||||||
val progress = animateFloatAsState(
|
|
||||||
targetValue = currentIndex.toFloat() / totalCount.toFloat(),
|
|
||||||
label = "InstallProgress"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
)
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
// 模块名称和进度
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = currentModuleName.ifEmpty { stringResource(R.string.module) },
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "$currentIndex/$totalCount",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// 进度条
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { progress.value },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp),
|
|
||||||
color = progressColor,
|
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = 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
|
@Parcelize
|
||||||
sealed class FlashIt : Parcelable {
|
sealed class FlashIt : Parcelable {
|
||||||
data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) : FlashIt()
|
data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) :
|
||||||
data class FlashModule(val uri: Uri) : FlashIt()
|
FlashIt()
|
||||||
data class FlashModules(val uris: List<Uri>, val currentIndex: Int = 0) : FlashIt()
|
|
||||||
data class FlashModuleUpdate(val uri: Uri) : FlashIt() // 模块更新
|
|
||||||
data object FlashRestore : FlashIt()
|
|
||||||
data object FlashUninstall : FlashIt()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模块更新刷写
|
data class FlashModules(val uris: List<Uri>) : FlashIt()
|
||||||
fun flashModuleUpdate(
|
|
||||||
uri: Uri,
|
data object FlashRestore : FlashIt()
|
||||||
onFinish: (Boolean, Int) -> Unit,
|
|
||||||
onStdout: (String) -> Unit,
|
data object FlashUninstall : FlashIt()
|
||||||
onStderr: (String) -> Unit
|
|
||||||
) {
|
|
||||||
flashModule(uri, onFinish, onStdout, onStderr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun flashIt(
|
fun flashIt(
|
||||||
flashIt: FlashIt,
|
flashIt: FlashIt,
|
||||||
onFinish: (Boolean, Int) -> Unit,
|
|
||||||
onStdout: (String) -> Unit,
|
onStdout: (String) -> Unit,
|
||||||
onStderr: (String) -> Unit
|
onStderr: (String) -> Unit
|
||||||
) {
|
): FlashResult {
|
||||||
when (flashIt) {
|
return when (flashIt) {
|
||||||
is FlashIt.FlashBoot -> installBoot(
|
is FlashIt.FlashBoot -> installBoot(
|
||||||
flashIt.boot,
|
flashIt.boot,
|
||||||
flashIt.lkm,
|
flashIt.lkm,
|
||||||
flashIt.ota,
|
flashIt.ota,
|
||||||
flashIt.partition,
|
flashIt.partition,
|
||||||
onFinish,
|
|
||||||
onStdout,
|
onStdout,
|
||||||
onStderr
|
onStderr
|
||||||
)
|
)
|
||||||
is FlashIt.FlashModule -> flashModule(flashIt.uri, onFinish, onStdout, onStderr)
|
|
||||||
is FlashIt.FlashModules -> {
|
is FlashIt.FlashModules -> {
|
||||||
if (flashIt.uris.isEmpty() || flashIt.currentIndex >= flashIt.uris.size) {
|
flashModulesSequentially(flashIt.uris, onStdout, onStderr)
|
||||||
onFinish(false, 0)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentUri = flashIt.uris[flashIt.currentIndex]
|
FlashIt.FlashRestore -> restoreBoot(onStdout, onStderr)
|
||||||
onStdout("\n")
|
|
||||||
|
|
||||||
flashModule(currentUri, onFinish, onStdout, onStderr)
|
FlashIt.FlashUninstall -> uninstallPermanently(onStdout, onStderr)
|
||||||
}
|
|
||||||
is FlashIt.FlashModuleUpdate -> {
|
|
||||||
onFinish(false, 0)
|
|
||||||
}
|
|
||||||
FlashIt.FlashRestore -> restoreBoot(onFinish, onStdout, onStderr)
|
|
||||||
FlashIt.FlashUninstall -> uninstallPermanently(onFinish, onStdout, onStderr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FlashScreenPreview() {
|
private fun TopBar(
|
||||||
FlashScreen(EmptyDestinationsNavigator, FlashIt.FlashUninstall)
|
status: FlashingStatus,
|
||||||
|
onBack: () -> Unit = {},
|
||||||
|
onSave: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
SmallTopAppBar(
|
||||||
|
title = stringResource(
|
||||||
|
when (status) {
|
||||||
|
FlashingStatus.FLASHING -> R.string.flashing
|
||||||
|
FlashingStatus.SUCCESS -> R.string.flash_success
|
||||||
|
FlashingStatus.FAILED -> R.string.flash_failed
|
||||||
|
}
|
||||||
|
),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
|
onClick = onBack
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
MiuixIcons.Useful.Back,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
onClick = onSave
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Move,
|
||||||
|
contentDescription = stringResource(id = R.string.save_log),
|
||||||
|
tint = colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,47 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
package com.sukisu.ultra.ui.screen
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Process.myUid
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.edit
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
import com.sukisu.ultra.ui.component.*
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
|
|
||||||
import com.sukisu.ultra.ui.theme.getCardColors
|
|
||||||
import com.sukisu.ultra.ui.theme.getCardElevation
|
|
||||||
import com.sukisu.ultra.ui.util.*
|
import com.sukisu.ultra.ui.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import top.yukonga.miuix.kmp.basic.*
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Refresh
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.basic.Search
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
import java.time.*
|
import java.time.*
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import android.os.Process.myUid
|
|
||||||
import androidx.core.content.edit
|
|
||||||
|
|
||||||
private val SPACING_SMALL = 4.dp
|
private val SPACING_SMALL = 4.dp
|
||||||
private val SPACING_MEDIUM = 8.dp
|
private val SPACING_MEDIUM = 8.dp
|
||||||
@@ -104,12 +102,9 @@ private fun loadExcludedSubTypes(context: Context): Set<LogExclType> {
|
|||||||
}.toSet()
|
}.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
@Destination<RootGraph>
|
||||||
@Composable
|
@Composable
|
||||||
fun LogViewerScreen(navigator: DestinationsNavigator) {
|
fun LogViewer(navigator: DestinationsNavigator) {
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -141,10 +136,8 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
|
|||||||
entry.details.contains(searchQuery, ignoreCase = true) ||
|
entry.details.contains(searchQuery, ignoreCase = true) ||
|
||||||
entry.uid.contains(searchQuery, ignoreCase = true)
|
entry.uid.contains(searchQuery, ignoreCase = true)
|
||||||
|
|
||||||
// 排除本应用
|
|
||||||
if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false
|
if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false
|
||||||
|
|
||||||
// 排除 SYSCALL 子类型
|
|
||||||
if (entry.type == LogType.SYSCALL) {
|
if (entry.type == LogType.SYSCALL) {
|
||||||
val detail = entry.details
|
val detail = entry.details
|
||||||
if (LogExclType.PRCTL_STAR in excludedSubTypes && detail.startsWith("Syscall: prctl") && !detail.startsWith("Syscall: prctl_unknown")) return@filter false
|
if (LogExclType.PRCTL_STAR in excludedSubTypes && detail.startsWith("Syscall: prctl") && !detail.startsWith("Syscall: prctl_unknown")) return@filter false
|
||||||
@@ -152,14 +145,12 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
|
|||||||
if (LogExclType.SETUID in excludedSubTypes && detail.startsWith("Syscall: setuid")) return@filter false
|
if (LogExclType.SETUID in excludedSubTypes && detail.startsWith("Syscall: setuid")) return@filter false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 普通类型筛选
|
|
||||||
val matchesFilter = filterType == null || entry.type == filterType
|
val matchesFilter = filterType == null || entry.type == filterType
|
||||||
matchesFilter && matchesSearch
|
matchesFilter && matchesSearch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val loadingDialog = rememberLoadingDialog()
|
var showClearDialog by remember { mutableStateOf(false) }
|
||||||
val confirmDialog = rememberConfirmDialog()
|
|
||||||
|
|
||||||
val loadPage: (Int, Boolean) -> Unit = { page, forceRefresh ->
|
val loadPage: (Int, Boolean) -> Unit = { page, forceRefresh ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -216,39 +207,65 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
LogViewerTopBar(
|
TopAppBar(
|
||||||
scrollBehavior = scrollBehavior,
|
title = stringResource(R.string.log_viewer_title),
|
||||||
onBackClick = { navigator.navigateUp() },
|
navigationIcon = {
|
||||||
showSearchBar = showSearchBar,
|
IconButton(
|
||||||
searchQuery = searchQuery,
|
onClick = { navigator.navigateUp() },
|
||||||
onSearchQueryChange = { searchQuery = it },
|
modifier = Modifier.padding(start = 12.dp)
|
||||||
onSearchToggle = { showSearchBar = !showSearchBar },
|
) {
|
||||||
onRefresh = onManualRefresh,
|
Icon(
|
||||||
onClearLogs = {
|
imageVector = MiuixIcons.Useful.Back,
|
||||||
scope.launch {
|
contentDescription = stringResource(R.string.log_viewer_back)
|
||||||
val result = confirmDialog.awaitConfirm(
|
|
||||||
title = context.getString(R.string.log_viewer_clear_logs),
|
|
||||||
content = context.getString(R.string.log_viewer_clear_logs_confirm)
|
|
||||||
)
|
)
|
||||||
if (result == ConfirmResult.Confirmed) {
|
|
||||||
loadingDialog.withLoading {
|
|
||||||
clearLogs()
|
|
||||||
loadPage(0, true)
|
|
||||||
}
|
}
|
||||||
snackBarHost.showSnackbar(context.getString(R.string.log_viewer_logs_cleared))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackBarHost) },
|
actions = {
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
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 ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
|
||||||
) {
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSearchBar,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||||
|
label = stringResource(R.string.log_viewer_search_placeholder)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LogControlPanel(
|
LogControlPanel(
|
||||||
filterType = filterType,
|
filterType = filterType,
|
||||||
onFilterTypeSelected = { filterType = it },
|
onFilterTypeSelected = { filterType = it },
|
||||||
@@ -264,7 +281,6 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 日志列表
|
|
||||||
if (isLoading && logEntries.isEmpty()) {
|
if (isLoading && logEntries.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -288,8 +304,71 @@ fun LogViewerScreen(navigator: DestinationsNavigator) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val showClearDialogState = remember { mutableStateOf(showClearDialog) }
|
||||||
|
|
||||||
|
LaunchedEffect(showClearDialog) {
|
||||||
|
showClearDialogState.value = showClearDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(showClearDialogState.value) {
|
||||||
|
showClearDialog = showClearDialogState.value
|
||||||
|
}
|
||||||
|
|
||||||
|
SuperDialog(
|
||||||
|
show = showClearDialogState,
|
||||||
|
onDismissRequest = { showClearDialog = false },
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 24.dp, bottom = 12.dp),
|
||||||
|
text = stringResource(R.string.log_viewer_clear_logs),
|
||||||
|
style = MiuixTheme.textStyles.title4,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
color = MiuixTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
text = stringResource(R.string.log_viewer_clear_logs_confirm),
|
||||||
|
style = MiuixTheme.textStyles.body2,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(android.R.string.cancel),
|
||||||
|
onClick = { showClearDialog = false },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(android.R.string.ok),
|
||||||
|
onClick = {
|
||||||
|
showClearDialog = false
|
||||||
|
scope.launch {
|
||||||
|
clearLogs()
|
||||||
|
loadPage(0, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val CONTROL_PANEL_SPACING_SMALL = 4.dp
|
||||||
|
private val CONTROL_PANEL_SPACING_MEDIUM = 8.dp
|
||||||
|
private val CONTROL_PANEL_SPACING_LARGE = 12.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LogControlPanel(
|
private fun LogControlPanel(
|
||||||
filterType: LogType?,
|
filterType: LogType?,
|
||||||
@@ -305,31 +384,17 @@ private fun LogControlPanel(
|
|||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM)
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
|
||||||
elevation = getCardElevation()
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
// 标题栏(点击展开/收起)
|
SuperArrow(
|
||||||
Row(
|
title = stringResource(R.string.log_viewer_settings),
|
||||||
modifier = Modifier
|
onClick = { isExpanded = !isExpanded },
|
||||||
.fillMaxWidth()
|
summary = if (isExpanded)
|
||||||
.clickable { isExpanded = !isExpanded }
|
stringResource(R.string.log_viewer_collapse)
|
||||||
.padding(SPACING_LARGE),
|
else
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
stringResource(R.string.log_viewer_expand)
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
)
|
||||||
Icon(
|
|
||||||
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isExpanded,
|
visible = isExpanded,
|
||||||
@@ -337,77 +402,80 @@ private fun LogControlPanel(
|
|||||||
exit = shrinkVertically() + fadeOut()
|
exit = shrinkVertically() + fadeOut()
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = SPACING_LARGE)
|
modifier = Modifier.padding(horizontal = SPACING_MEDIUM)
|
||||||
) {
|
) {
|
||||||
// 类型过滤
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.log_viewer_filter_type),
|
text = stringResource(R.string.log_viewer_filter_type),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MiuixTheme.textStyles.subtitle,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_MEDIUM))
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_MEDIUM)) {
|
||||||
item {
|
item {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
onClick = { onFilterTypeSelected(null) },
|
text = stringResource(R.string.log_viewer_all_types),
|
||||||
label = { Text(stringResource(R.string.log_viewer_all_types)) },
|
selected = filterType == null,
|
||||||
selected = filterType == null
|
onClick = { onFilterTypeSelected(null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(LogType.entries.toTypedArray()) { type ->
|
items(LogType.entries.toTypedArray()) { type ->
|
||||||
FilterChip(
|
Row(
|
||||||
onClick = { onFilterTypeSelected(if (filterType == type) null else type) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
label = { Text(type.displayName) },
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
selected = filterType == type,
|
) {
|
||||||
leadingIcon = {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(6.dp)
|
||||||
.background(type.color, RoundedCornerShape(4.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(
|
||||||
text = stringResource(R.string.log_viewer_exclude_subtypes),
|
text = stringResource(R.string.log_viewer_exclude_subtypes),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MiuixTheme.textStyles.subtitle,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_MEDIUM))
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_MEDIUM)) {
|
||||||
items(LogExclType.entries.toTypedArray()) { excl ->
|
items(LogExclType.entries.toTypedArray()) { excl ->
|
||||||
val label = if (excl == LogExclType.CURRENT_APP)
|
val label = if (excl == LogExclType.CURRENT_APP)
|
||||||
stringResource(R.string.log_viewer_exclude_current_app)
|
stringResource(R.string.log_viewer_exclude_current_app)
|
||||||
else excl.displayName
|
else excl.displayName
|
||||||
|
|
||||||
FilterChip(
|
Row(
|
||||||
onClick = { onExcludeToggle(excl) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
label = { Text(label) },
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
selected = excl in excludedSubTypes,
|
) {
|
||||||
leadingIcon = {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(6.dp)
|
||||||
.background(excl.color, RoundedCornerShape(4.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(CONTROL_PANEL_SPACING_SMALL)) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount),
|
text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
if (pageInfo.totalPages > 0) {
|
if (pageInfo.totalPages > 0) {
|
||||||
Text(
|
Text(
|
||||||
@@ -417,20 +485,20 @@ private fun LogControlPanel(
|
|||||||
pageInfo.totalPages,
|
pageInfo.totalPages,
|
||||||
pageInfo.totalLogs
|
pageInfo.totalLogs
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) {
|
if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS),
|
text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = Color(0xFFE53935)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(SPACING_LARGE))
|
Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_LARGE))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,13 +519,12 @@ private fun LogList(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||||
verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)
|
verticalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
|
||||||
) {
|
) {
|
||||||
items(entries) { entry ->
|
items(entries) { entry ->
|
||||||
LogEntryCard(entry = entry)
|
LogEntryCard(entry = entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载更多按钮或加载指示器
|
|
||||||
if (pageInfo.hasMore) {
|
if (pageInfo.hasMore) {
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(
|
||||||
@@ -475,12 +542,6 @@ private fun LogList(
|
|||||||
onClick = onLoadMore,
|
onClick = onLoadMore,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.ExpandMore,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
|
||||||
Text(stringResource(R.string.log_viewer_load_more))
|
Text(stringResource(R.string.log_viewer_load_more))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,8 +557,8 @@ private fun LogList(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.log_viewer_all_logs_loaded),
|
text = stringResource(R.string.log_viewer_all_logs_loaded),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,14 +571,11 @@ private fun LogEntryCard(entry: LogEntry) {
|
|||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
onClick = { expanded = !expanded }
|
||||||
.clickable { expanded = !expanded },
|
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(SPACING_MEDIUM)
|
modifier = Modifier.padding(SPACING_LARGE)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -535,14 +593,14 @@ private fun LogEntryCard(entry: LogEntry) {
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = entry.type.displayName,
|
text = entry.type.displayName,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MiuixTheme.textStyles.subtitle,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = entry.timestamp,
|
text = entry.timestamp,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,19 +612,19 @@ private fun LogEntryCard(entry: LogEntry) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "UID: ${entry.uid}",
|
text = "UID: ${entry.uid}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "PID: ${entry.pid}",
|
text = "PID: ${entry.pid}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = entry.comm,
|
text = entry.comm,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MiuixTheme.textStyles.body1,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
maxLines = if (expanded) Int.MAX_VALUE else 1,
|
maxLines = if (expanded) Int.MAX_VALUE else 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -576,8 +634,8 @@ private fun LogEntryCard(entry: LogEntry) {
|
|||||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||||
Text(
|
Text(
|
||||||
text = entry.details,
|
text = entry.details,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -590,19 +648,19 @@ private fun LogEntryCard(entry: LogEntry) {
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
HorizontalDivider()
|
||||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.log_viewer_raw_log),
|
text = stringResource(R.string.log_viewer_raw_log),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MiuixTheme.textStyles.subtitle,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MiuixTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||||
Text(
|
Text(
|
||||||
text = entry.rawLine,
|
text = entry.rawLine,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MiuixTheme.textStyles.body2,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,141 +681,30 @@ private fun EmptyLogState(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(SPACING_LARGE)
|
verticalArrangement = Arrangement.spacedBy(SPACING_LARGE)
|
||||||
) {
|
) {
|
||||||
Icon(
|
|
||||||
imageVector = if (hasLogs) Icons.Filled.FilterList else Icons.Filled.Description,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(64.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = stringResource(
|
||||||
if (hasLogs) R.string.log_viewer_no_matching_logs
|
if (hasLogs) R.string.log_viewer_no_matching_logs
|
||||||
else R.string.log_viewer_no_logs
|
else R.string.log_viewer_no_logs
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MiuixTheme.textStyles.headline2,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||||
)
|
)
|
||||||
Button(onClick = onRefresh) {
|
Button(
|
||||||
Icon(
|
onClick = onRefresh
|
||||||
imageVector = Icons.Filled.Refresh,
|
) {
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
|
||||||
Text(stringResource(R.string.log_viewer_refresh))
|
Text(stringResource(R.string.log_viewer_refresh))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
private suspend fun checkForNewLogs(lastHash: String): Boolean {
|
||||||
@Composable
|
|
||||||
private fun LogViewerTopBar(
|
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
|
||||||
onBackClick: () -> Unit,
|
|
||||||
showSearchBar: Boolean,
|
|
||||||
searchQuery: String,
|
|
||||||
onSearchQueryChange: (String) -> Unit,
|
|
||||||
onSearchToggle: () -> Unit,
|
|
||||||
onRefresh: () -> Unit,
|
|
||||||
onClearLogs: () -> Unit
|
|
||||||
) {
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
|
||||||
colorScheme.surfaceContainerLow
|
|
||||||
} else {
|
|
||||||
colorScheme.background
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.log_viewer_title),
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBackClick) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.log_viewer_back)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = onSearchToggle) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (showSearchBar) Icons.Filled.SearchOff else Icons.Filled.Search,
|
|
||||||
contentDescription = stringResource(R.string.log_viewer_search)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onRefresh) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Refresh,
|
|
||||||
contentDescription = stringResource(R.string.log_viewer_refresh)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onClearLogs) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.DeleteSweep,
|
|
||||||
contentDescription = stringResource(R.string.log_viewer_clear_logs)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
|
||||||
scrollBehavior = scrollBehavior
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showSearchBar,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = searchQuery,
|
|
||||||
onValueChange = onSearchQueryChange,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
|
||||||
placeholder = { Text(stringResource(R.string.log_viewer_search_placeholder)) },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Search,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
if (searchQuery.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { onSearchQueryChange("") }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Clear,
|
|
||||||
contentDescription = stringResource(R.string.log_viewer_clear_search)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
singleLine = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkForNewLogs(
|
|
||||||
lastHash: String
|
|
||||||
): Boolean {
|
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val shell = getRootShell()
|
val shell = getRootShell()
|
||||||
val logPath = "/data/adb/ksu/log/sulog.log"
|
val logPath = "/data/adb/ksu/log/sulog.log"
|
||||||
|
|
||||||
val result = runCmd(shell, "stat -c '%Y %s' $logPath 2>/dev/null || echo '0 0'")
|
val result = runCmd(shell, "stat -c '%Y %s' $logPath 2>/dev/null || echo '0 0'")
|
||||||
val currentHash = result.trim()
|
val currentHash = result.trim()
|
||||||
|
|
||||||
currentHash != lastHash && currentHash != "0 0"
|
currentHash != lastHash && currentHash != "0 0"
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
@@ -774,8 +721,6 @@ private suspend fun loadLogsWithPagination(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val shell = getRootShell()
|
val shell = getRootShell()
|
||||||
|
|
||||||
// 获取文件信息
|
|
||||||
val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'")
|
val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'")
|
||||||
val currentHash = statResult.trim()
|
val currentHash = statResult.trim()
|
||||||
|
|
||||||
@@ -786,7 +731,6 @@ private suspend fun loadLogsWithPagination(
|
|||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取总行数
|
|
||||||
val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'")
|
val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'")
|
||||||
val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0
|
val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0
|
||||||
|
|
||||||
@@ -797,11 +741,9 @@ private suspend fun loadLogsWithPagination(
|
|||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制最大日志数量
|
|
||||||
val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS)
|
val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS)
|
||||||
val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE
|
val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE
|
||||||
|
|
||||||
// 计算要读取的行数范围
|
|
||||||
val startLine = if (page == 0) {
|
val startLine = if (page == 0) {
|
||||||
maxOf(1, totalLines - effectiveTotal + 1)
|
maxOf(1, totalLines - effectiveTotal + 1)
|
||||||
} else {
|
} else {
|
||||||
@@ -860,6 +802,7 @@ private fun parseLogEntries(logContent: String): List<LogEntry> {
|
|||||||
|
|
||||||
return entries.reversed()
|
return entries.reversed()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun utcToLocal(utc: String): String {
|
private fun utcToLocal(utc: String): String {
|
||||||
return try {
|
return try {
|
||||||
val instant = LocalDateTime.parse(utc, utcFormatter).atOffset(ZoneOffset.UTC).toInstant()
|
val instant = LocalDateTime.parse(utc, utcFormatter).atOffset(ZoneOffset.UTC).toInstant()
|
||||||
@@ -871,7 +814,6 @@ private fun utcToLocal(utc: String): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun parseLogLine(line: String): LogEntry? {
|
private fun parseLogLine(line: String): LogEntry? {
|
||||||
// 解析格式: [timestamp] TYPE: UID=xxx COMM=xxx ...
|
|
||||||
val timestampRegex = """\[(.*?)]""".toRegex()
|
val timestampRegex = """\[(.*?)]""".toRegex()
|
||||||
val timestampMatch = timestampRegex.find(line) ?: return null
|
val timestampMatch = timestampRegex.find(line) ?: return null
|
||||||
val timestamp = utcToLocal(timestampMatch.groupValues[1])
|
val timestamp = utcToLocal(timestampMatch.groupValues[1])
|
||||||
@@ -895,7 +837,6 @@ private fun parseLogLine(line: String): LogEntry? {
|
|||||||
val comm: String = extractValue(details, "COMM") ?: ""
|
val comm: String = extractValue(details, "COMM") ?: ""
|
||||||
val pid: String = extractValue(details, "PID") ?: ""
|
val pid: String = extractValue(details, "PID") ?: ""
|
||||||
|
|
||||||
// 构建详细信息字符串
|
|
||||||
val detailsStr = when (type) {
|
val detailsStr = when (type) {
|
||||||
LogType.SU_GRANT -> {
|
LogType.SU_GRANT -> {
|
||||||
val method: String = extractValue(details, "METHOD") ?: ""
|
val method: String = extractValue(details, "METHOD") ?: ""
|
||||||
@@ -939,3 +880,22 @@ private fun extractValue(text: String, key: String): String? {
|
|||||||
val regex = """$key=(\S+)""".toRegex()
|
val regex = """$key=(\S+)""".toRegex()
|
||||||
return regex.find(text)?.groupValues?.get(1)
|
return regex.find(text)?.groupValues?.get(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterChip(
|
||||||
|
text: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
text = text,
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
colors = if (selected) {
|
||||||
|
ButtonDefaults.textButtonColorsPrimary()
|
||||||
|
} else {
|
||||||
|
ButtonDefaults.textButtonColors()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
package com.sukisu.ultra.ui.screen
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.add
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.calculateEndPadding
|
||||||
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
|
import androidx.compose.foundation.layout.captionBar
|
||||||
|
import androidx.compose.foundation.layout.displayCutout
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.outlined.Fingerprint
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.outlined.Group
|
||||||
import androidx.compose.material.icons.filled.ImportExport
|
import androidx.compose.material.icons.outlined.Shield
|
||||||
import androidx.compose.material.icons.filled.Sync
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import androidx.lifecycle.compose.dropUnlessResumed
|
import androidx.lifecycle.compose.dropUnlessResumed
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
@@ -35,27 +68,57 @@ import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScr
|
|||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import com.ramcosta.composedestinations.result.ResultRecipient
|
import com.ramcosta.composedestinations.result.ResultRecipient
|
||||||
import com.ramcosta.composedestinations.result.getOr
|
import com.ramcosta.composedestinations.result.getOr
|
||||||
import com.sukisu.ultra.R
|
import dev.chrisbanes.haze.HazeState
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
import dev.chrisbanes.haze.HazeTint
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import com.sukisu.ultra.R
|
||||||
|
import com.sukisu.ultra.ui.component.DropdownItem
|
||||||
|
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
||||||
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
|
import top.yukonga.miuix.kmp.basic.FloatingActionButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.HorizontalDivider
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.ListPopup
|
||||||
|
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||||
|
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||||
|
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||||
|
import top.yukonga.miuix.kmp.basic.PullToRefresh
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Copy
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Refresh
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
import top.yukonga.miuix.kmp.utils.PressFeedbackType
|
||||||
|
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||||
|
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||||
|
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author weishu
|
* @author weishu
|
||||||
* @date 2023/10/20.
|
* @date 2023/10/20.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@Destination<RootGraph>
|
||||||
fun AppProfileTemplateScreen(
|
fun AppProfileTemplateScreen(
|
||||||
navigator: DestinationsNavigator,
|
navigator: DestinationsNavigator,
|
||||||
resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean>
|
resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean>
|
||||||
) {
|
) {
|
||||||
val viewModel = viewModel<TemplateViewModel>()
|
val viewModel = viewModel<TemplateViewModel>()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = MiuixScrollBehavior()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (viewModel.templateList.isEmpty()) {
|
if (viewModel.templateList.isEmpty()) {
|
||||||
@@ -70,10 +133,45 @@ fun AppProfileTemplateScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
var fabVisible by remember { mutableStateOf(true) }
|
||||||
|
var scrollDistance by remember { mutableFloatStateOf(0f) }
|
||||||
|
val nestedScrollConnection = remember {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||||
|
val isScrolledToEnd =
|
||||||
|
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == listState.layoutInfo.totalItemsCount - 1
|
||||||
|
&& (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.size
|
||||||
|
?: 0) < listState.layoutInfo.viewportEndOffset)
|
||||||
|
val delta = available.y
|
||||||
|
if (!isScrolledToEnd) {
|
||||||
|
scrollDistance += delta
|
||||||
|
if (scrollDistance < -50f) {
|
||||||
|
if (fabVisible) fabVisible = false
|
||||||
|
scrollDistance = 0f
|
||||||
|
} else if (scrollDistance > 50f) {
|
||||||
|
if (!fabVisible) fabVisible = true
|
||||||
|
scrollDistance = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val offsetHeight by animateDpAsState(
|
||||||
|
targetValue = if (fabVisible) 0.dp else 100.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(),
|
||||||
|
animationSpec = tween(durationMillis = 350)
|
||||||
|
)
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
val hazeStyle = HazeStyle(
|
||||||
|
backgroundColor = colorScheme.surface,
|
||||||
|
tint = HazeTint(colorScheme.surface.copy(0.8f))
|
||||||
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clipboardManager = context.getSystemService<ClipboardManager>()
|
|
||||||
val showToast = fun(msg: String) {
|
val showToast = fun(msg: String) {
|
||||||
scope.launch(Dispatchers.Main) {
|
scope.launch(Dispatchers.Main) {
|
||||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||||
@@ -85,21 +183,21 @@ fun AppProfileTemplateScreen(
|
|||||||
scope.launch { viewModel.fetchTemplates(true) }
|
scope.launch { viewModel.fetchTemplates(true) }
|
||||||
},
|
},
|
||||||
onImport = {
|
onImport = {
|
||||||
scope.launch {
|
clipboardManager.getText()?.text?.let {
|
||||||
val clipboardText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString()
|
if (it.isEmpty()) {
|
||||||
if (clipboardText.isNullOrEmpty()) {
|
|
||||||
showToast(context.getString(R.string.app_profile_template_import_empty))
|
showToast(context.getString(R.string.app_profile_template_import_empty))
|
||||||
return@launch
|
return@let
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
viewModel.importTemplates(
|
viewModel.importTemplates(
|
||||||
clipboardText,
|
it, {
|
||||||
{
|
|
||||||
showToast(context.getString(R.string.app_profile_template_import_success))
|
showToast(context.getString(R.string.app_profile_template_import_success))
|
||||||
viewModel.fetchTemplates(false)
|
viewModel.fetchTemplates(false)
|
||||||
},
|
},
|
||||||
showToast
|
showToast
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onExport = {
|
onExport = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -107,176 +205,300 @@ fun AppProfileTemplateScreen(
|
|||||||
{
|
{
|
||||||
showToast(context.getString(R.string.app_profile_template_export_empty))
|
showToast(context.getString(R.string.app_profile_template_export_empty))
|
||||||
}
|
}
|
||||||
) { text ->
|
) {
|
||||||
clipboardManager?.setPrimaryClip(ClipData.newPlainText("", text))
|
clipboardManager.setText(AnnotatedString(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior,
|
||||||
|
hazeState = hazeState,
|
||||||
|
hazeStyle = hazeStyle,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
ExtendedFloatingActionButton(
|
FloatingActionButton(
|
||||||
|
containerColor = colorScheme.primary,
|
||||||
|
shadowElevation = 0.dp,
|
||||||
onClick = {
|
onClick = {
|
||||||
navigator.navigate(
|
navigator.navigate(TemplateEditorScreenDestination(TemplateViewModel.TemplateInfo(), false)) {
|
||||||
TemplateEditorScreenDestination(
|
launchSingleTop = true
|
||||||
TemplateViewModel.TemplateInfo(),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
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() }
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
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(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.height(getWindowSize().height.dp)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.scrollEndHaptic()
|
||||||
contentPadding = remember {
|
.overScrollVertical()
|
||||||
PaddingValues(bottom = 16.dp + 56.dp + 16.dp /* Scaffold Fab Spacing + Fab container height */)
|
.nestedScroll(nestedScrollConnection)
|
||||||
}
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
|
.hazeSource(state = hazeState)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
contentPadding = innerPadding,
|
||||||
|
overscrollEffect = null
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
items(viewModel.templateList, key = { it.id }) { app ->
|
items(viewModel.templateList, key = { it.id }) { app ->
|
||||||
TemplateItem(navigator, app)
|
TemplateItem(navigator, app)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
Spacer(
|
||||||
|
Modifier.height(
|
||||||
|
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||||
|
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TemplateItem(
|
private fun TemplateItem(
|
||||||
navigator: DestinationsNavigator,
|
navigator: DestinationsNavigator,
|
||||||
template: TemplateViewModel.TemplateInfo
|
template: TemplateViewModel.TemplateInfo
|
||||||
) {
|
) {
|
||||||
ListItem(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
.clickable {
|
onClick = {
|
||||||
navigator.navigate(TemplateEditorScreenDestination(template, !template.local))
|
navigator.navigate(TemplateEditorScreenDestination(template, !template.local)) {
|
||||||
|
popUpTo(TemplateEditorScreenDestination) {
|
||||||
|
inclusive = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
headlineContent = { Text(template.name) },
|
showIndication = true,
|
||||||
supportingContent = {
|
pressFeedbackType = PressFeedbackType.Sink
|
||||||
Column {
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "${template.id}${if (template.author.isEmpty()) "" else "@${template.author}"}",
|
text = template.name,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontWeight = FontWeight(550),
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
color = colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(template.description)
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
FlowRow {
|
|
||||||
LabelText(label = "UID: ${template.uid}")
|
|
||||||
LabelText(label = "GID: ${template.gid}")
|
|
||||||
LabelText(label = template.context)
|
|
||||||
if (template.local) {
|
if (template.local) {
|
||||||
LabelText(label = "local")
|
Text(
|
||||||
} else {
|
text = "LOCAL",
|
||||||
LabelText(label = "remote")
|
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
|
@Composable
|
||||||
private fun TopBar(
|
private fun TopBar(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
onImport: () -> Unit = {},
|
onImport: () -> Unit = {},
|
||||||
onExport: () -> Unit = {},
|
onExport: () -> Unit = {},
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
scrollBehavior: ScrollBehavior,
|
||||||
|
hazeState: HazeState,
|
||||||
|
hazeStyle: HazeStyle,
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
|
||||||
colorScheme.surfaceContainerLow
|
|
||||||
} else {
|
|
||||||
colorScheme.background
|
|
||||||
}
|
|
||||||
val cardAlpha = CardConfig.cardAlpha
|
|
||||||
|
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
modifier = Modifier.hazeEffect(hazeState) {
|
||||||
Text(stringResource(R.string.settings_profile_template))
|
style = hazeStyle
|
||||||
|
blurRadius = 30.dp
|
||||||
|
noiseFactor = 0f
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
color = Color.Transparent,
|
||||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
title = stringResource(R.string.settings_profile_template),
|
||||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
|
||||||
),
|
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
onClick = onBack
|
onClick = onBack
|
||||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Back,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onSync) {
|
IconButton(
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
onClick = onSync
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Sync,
|
imageVector = MiuixIcons.Useful.Refresh,
|
||||||
contentDescription = stringResource(id = R.string.app_profile_template_sync)
|
contentDescription = stringResource(id = R.string.app_profile_template_sync),
|
||||||
|
tint = colorScheme.onBackground
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var showDropdown by remember { mutableStateOf(false) }
|
val showTopPopup = remember { mutableStateOf(false) }
|
||||||
IconButton(onClick = {
|
ListPopup(
|
||||||
showDropdown = true
|
show = showTopPopup,
|
||||||
}) {
|
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||||
Icon(
|
alignment = PopupPositionProvider.Align.TopRight,
|
||||||
imageVector = Icons.Filled.ImportExport,
|
onDismissRequest = {
|
||||||
contentDescription = stringResource(id = R.string.app_profile_import_export)
|
showTopPopup.value = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
ListPopupColumn {
|
||||||
|
val items = listOf(
|
||||||
|
stringResource(id = R.string.app_profile_import_from_clipboard),
|
||||||
|
stringResource(id = R.string.app_profile_export_to_clipboard)
|
||||||
)
|
)
|
||||||
|
items.forEachIndexed { index, text ->
|
||||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
DropdownItem(
|
||||||
showDropdown = false
|
text = text,
|
||||||
}) {
|
optionSize = items.size,
|
||||||
DropdownMenuItem(text = {
|
index = index,
|
||||||
Text(stringResource(id = R.string.app_profile_import_from_clipboard))
|
onSelectedIndexChange = { selectedIndex ->
|
||||||
}, onClick = {
|
if (selectedIndex == 0) {
|
||||||
onImport()
|
onImport()
|
||||||
showDropdown = false
|
} else {
|
||||||
})
|
|
||||||
DropdownMenuItem(text = {
|
|
||||||
Text(stringResource(id = R.string.app_profile_export_to_clipboard))
|
|
||||||
}, onClick = {
|
|
||||||
onExport()
|
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
|
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 android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.add
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.captionBar
|
||||||
|
import androidx.compose.foundation.layout.displayCutout
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.material.icons.filled.DeleteForever
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.dropUnlessResumed
|
import androidx.lifecycle.compose.dropUnlessResumed
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
import com.ramcosta.composedestinations.result.ResultBackNavigator
|
import com.ramcosta.composedestinations.result.ResultBackNavigator
|
||||||
|
import dev.chrisbanes.haze.HazeState
|
||||||
|
import dev.chrisbanes.haze.HazeStyle
|
||||||
|
import dev.chrisbanes.haze.HazeTint
|
||||||
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import com.sukisu.ultra.Natives
|
import com.sukisu.ultra.Natives
|
||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
|
import com.sukisu.ultra.ui.component.EditText
|
||||||
import com.sukisu.ultra.ui.component.profile.RootProfileConfig
|
import com.sukisu.ultra.ui.component.profile.RootProfileConfig
|
||||||
import com.sukisu.ultra.ui.util.deleteAppProfileTemplate
|
import com.sukisu.ultra.ui.util.deleteAppProfileTemplate
|
||||||
import com.sukisu.ultra.ui.util.getAppProfileTemplate
|
import com.sukisu.ultra.ui.util.getAppProfileTemplate
|
||||||
import com.sukisu.ultra.ui.util.setAppProfileTemplate
|
import com.sukisu.ultra.ui.util.setAppProfileTemplate
|
||||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
||||||
import com.sukisu.ultra.ui.viewmodel.toJSON
|
import com.sukisu.ultra.ui.viewmodel.toJSON
|
||||||
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Confirm
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||||
|
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||||
|
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author weishu
|
* @author weishu
|
||||||
* @date 2023/10/20.
|
* @date 2023/10/20.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@Destination<RootGraph>
|
||||||
fun TemplateEditorScreen(
|
fun TemplateEditorScreen(
|
||||||
navigator: ResultBackNavigator<Boolean>,
|
navigator: ResultBackNavigator<Boolean>,
|
||||||
initialTemplate: TemplateViewModel.TemplateInfo,
|
initialTemplate: TemplateViewModel.TemplateInfo,
|
||||||
@@ -56,7 +84,12 @@ fun TemplateEditorScreen(
|
|||||||
mutableStateOf(initialTemplate)
|
mutableStateOf(initialTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = MiuixScrollBehavior()
|
||||||
|
val hazeState = remember { HazeState() }
|
||||||
|
val hazeStyle = HazeStyle(
|
||||||
|
backgroundColor = colorScheme.surface,
|
||||||
|
tint = HazeTint(colorScheme.surface.copy(0.8f))
|
||||||
|
)
|
||||||
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
navigator.navigateBack(result = !readOnly)
|
navigator.navigateBack(result = !readOnly)
|
||||||
@@ -64,15 +97,9 @@ fun TemplateEditorScreen(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
val author =
|
|
||||||
if (initialTemplate.author.isNotEmpty()) "@${initialTemplate.author}" else ""
|
|
||||||
val readOnlyHint = if (readOnly) {
|
|
||||||
" - ${stringResource(id = R.string.app_profile_template_readonly)}"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
val titleSummary = "${initialTemplate.id}$author$readOnlyHint"
|
|
||||||
val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
|
val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
|
||||||
|
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
|
||||||
|
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
TopBar(
|
TopBar(
|
||||||
@@ -84,7 +111,7 @@ fun TemplateEditorScreen(
|
|||||||
stringResource(R.string.app_profile_template_edit)
|
stringResource(R.string.app_profile_template_edit)
|
||||||
},
|
},
|
||||||
readOnly = readOnly,
|
readOnly = readOnly,
|
||||||
summary = titleSummary,
|
isCreation = isCreation,
|
||||||
onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) },
|
onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) },
|
||||||
onDelete = {
|
onDelete = {
|
||||||
if (deleteAppProfileTemplate(template.id)) {
|
if (deleteAppProfileTemplate(template.id)) {
|
||||||
@@ -92,49 +119,54 @@ fun TemplateEditorScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSave = {
|
onSave = {
|
||||||
|
when (idCheck(template.id)) {
|
||||||
|
0 -> Unit
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
Toast.makeText(context, idConflictError, Toast.LENGTH_SHORT).show()
|
||||||
|
return@TopBar
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> {
|
||||||
|
Toast.makeText(context, idInvalidError, Toast.LENGTH_SHORT).show()
|
||||||
|
return@TopBar
|
||||||
|
}
|
||||||
|
}
|
||||||
if (saveTemplate(template, isCreation)) {
|
if (saveTemplate(template, isCreation)) {
|
||||||
navigator.navigateBack(result = true)
|
navigator.navigateBack(result = true)
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior,
|
||||||
|
hazeState = hazeState,
|
||||||
|
hazeStyle = hazeStyle,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
popupHost = { },
|
||||||
|
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.height(getWindowSize().height.dp)
|
||||||
|
.scrollEndHaptic()
|
||||||
|
.overScrollVertical()
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
.verticalScroll(rememberScrollState())
|
.hazeSource(state = hazeState)
|
||||||
.pointerInteropFilter {
|
.pointerInteropFilter {
|
||||||
// disable click and ripple if readOnly
|
// disable click and ripple if readOnly
|
||||||
readOnly
|
readOnly
|
||||||
}
|
},
|
||||||
|
contentPadding = innerPadding,
|
||||||
|
overscrollEffect = null
|
||||||
) {
|
) {
|
||||||
if (isCreation) {
|
item {
|
||||||
var errorHint by remember {
|
Card(
|
||||||
mutableStateOf("")
|
modifier = Modifier
|
||||||
}
|
.fillMaxWidth()
|
||||||
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
|
.padding(12.dp),
|
||||||
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
|
) {
|
||||||
TextEdit(
|
var errorHint by rememberSaveable { mutableStateOf(false) }
|
||||||
label = stringResource(id = R.string.app_profile_template_id),
|
|
||||||
text = template.id,
|
|
||||||
errorHint = errorHint,
|
|
||||||
isError = errorHint.isNotEmpty()
|
|
||||||
) { value ->
|
|
||||||
errorHint = if (isTemplateExist(value)) {
|
|
||||||
idConflictError
|
|
||||||
} else if (!isValidTemplateId(value)) {
|
|
||||||
idInvalidError
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
template = template.copy(id = value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextEdit(
|
TextEdit(
|
||||||
label = stringResource(id = R.string.app_profile_template_name),
|
label = stringResource(id = R.string.app_profile_template_name),
|
||||||
@@ -150,6 +182,30 @@ fun TemplateEditorScreen(
|
|||||||
template = this
|
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(
|
TextEdit(
|
||||||
label = stringResource(id = R.string.app_profile_template_description),
|
label = stringResource(id = R.string.app_profile_template_description),
|
||||||
text = template.description
|
text = template.description
|
||||||
@@ -165,7 +221,8 @@ fun TemplateEditorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RootProfileConfig(fixedName = true,
|
RootProfileConfig(
|
||||||
|
fixedName = true,
|
||||||
profile = toNativeProfile(template),
|
profile = toNativeProfile(template),
|
||||||
onProfileChange = {
|
onProfileChange = {
|
||||||
template.copy(
|
template.copy(
|
||||||
@@ -185,13 +242,23 @@ fun TemplateEditorScreen(
|
|||||||
}
|
}
|
||||||
template = this
|
template = this
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(
|
||||||
|
Modifier.height(
|
||||||
|
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||||
|
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile {
|
fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile {
|
||||||
return Natives.Profile().copy(rootTemplate = templateInfo.id,
|
return Natives.Profile().copy(
|
||||||
|
rootTemplate = templateInfo.id,
|
||||||
uid = templateInfo.uid,
|
uid = templateInfo.uid,
|
||||||
gid = templateInfo.gid,
|
gid = templateInfo.gid,
|
||||||
groups = templateInfo.groups,
|
groups = templateInfo.groups,
|
||||||
@@ -213,6 +280,10 @@ fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun idCheck(value: String): Int {
|
||||||
|
return if (value.isEmpty()) 0 else if (isTemplateExist(value)) 1 else if (!isValidTemplateId(value)) 2 else 0
|
||||||
|
}
|
||||||
|
|
||||||
fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean {
|
fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean {
|
||||||
if (!isTemplateValid(template)) {
|
if (!isTemplateValid(template)) {
|
||||||
return false
|
return false
|
||||||
@@ -227,50 +298,67 @@ fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean =
|
|||||||
return setAppProfileTemplate(template.id, json.toString())
|
return setAppProfileTemplate(template.id, json.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TopBar(
|
private fun TopBar(
|
||||||
title: String,
|
title: String,
|
||||||
readOnly: Boolean,
|
readOnly: Boolean,
|
||||||
summary: String = "",
|
isCreation: Boolean,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onSave: () -> Unit = {},
|
onSave: () -> Unit = {},
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
scrollBehavior: ScrollBehavior,
|
||||||
|
hazeState: HazeState,
|
||||||
|
hazeStyle: HazeStyle,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
modifier = Modifier.hazeEffect(hazeState) {
|
||||||
Column {
|
style = hazeStyle
|
||||||
Text(title)
|
blurRadius = 30.dp
|
||||||
if (summary.isNotBlank()) {
|
noiseFactor = 0f
|
||||||
Text(
|
},
|
||||||
text = summary,
|
color = Color.Transparent,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
title = title,
|
||||||
)
|
navigationIcon = {
|
||||||
}
|
|
||||||
}
|
|
||||||
}, navigationIcon = {
|
|
||||||
IconButton(
|
IconButton(
|
||||||
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
onClick = onBack
|
onClick = onBack
|
||||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
) {
|
||||||
}, actions = {
|
|
||||||
if (readOnly) {
|
|
||||||
return@TopAppBar
|
|
||||||
}
|
|
||||||
IconButton(onClick = onDelete) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.DeleteForever,
|
imageVector = MiuixIcons.Useful.Back,
|
||||||
contentDescription = stringResource(id = R.string.app_profile_template_delete)
|
contentDescription = null,
|
||||||
)
|
tint = colorScheme.onSurface
|
||||||
}
|
|
||||||
IconButton(onClick = onSave) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Save,
|
|
||||||
contentDescription = stringResource(id = R.string.app_profile_template_save)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
actions = {
|
||||||
|
when {
|
||||||
|
!readOnly && !isCreation -> {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
onClick = onDelete
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Delete,
|
||||||
|
contentDescription = stringResource(id = R.string.app_profile_template_delete),
|
||||||
|
tint = colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreation -> {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
onClick = onSave
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Confirm,
|
||||||
|
contentDescription = stringResource(id = R.string.app_profile_template_save),
|
||||||
|
tint = colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
scrollBehavior = scrollBehavior
|
scrollBehavior = scrollBehavior
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -279,35 +367,22 @@ private fun TopBar(
|
|||||||
private fun TextEdit(
|
private fun TextEdit(
|
||||||
label: String,
|
label: String,
|
||||||
text: String,
|
text: String,
|
||||||
errorHint: String = "",
|
|
||||||
isError: Boolean = false,
|
isError: Boolean = false,
|
||||||
onValueChange: (String) -> Unit = {}
|
onValueChange: (String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
ListItem(headlineContent = {
|
val editText = remember { mutableStateOf(text) }
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
EditText(
|
||||||
OutlinedTextField(
|
title = label.uppercase(),
|
||||||
value = text,
|
textValue = editText,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onTextValueChange = { newText ->
|
||||||
label = { Text(label) },
|
editText.value = newText
|
||||||
suffix = {
|
onValueChange(newText)
|
||||||
if (errorHint.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = if (isError) errorHint else "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isError = isError,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next
|
keyboardType = KeyboardType.Ascii,
|
||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(onDone = {
|
isError = isError,
|
||||||
keyboardController?.hide()
|
|
||||||
}),
|
|
||||||
onValueChange = onValueChange
|
|
||||||
)
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidTemplateId(id: String): Boolean {
|
private fun isValidTemplateId(id: String): Boolean {
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
package com.sukisu.ultra.ui.screen
|
package com.sukisu.ultra.ui.screen
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.outlined.Folder
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.outlined.Info
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material.icons.rounded.Add
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.ramcosta.composedestinations.annotation.Destination
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
@@ -24,13 +24,30 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
|||||||
import com.sukisu.ultra.R
|
import com.sukisu.ultra.R
|
||||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
||||||
import com.sukisu.ultra.ui.component.ConfirmResult
|
import com.sukisu.ultra.ui.component.ConfirmResult
|
||||||
import com.sukisu.ultra.ui.theme.CardConfig
|
|
||||||
import com.sukisu.ultra.ui.theme.getCardColors
|
|
||||||
import com.sukisu.ultra.ui.theme.getCardElevation
|
|
||||||
import com.sukisu.ultra.ui.util.*
|
import com.sukisu.ultra.ui.util.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import top.yukonga.miuix.kmp.basic.Button
|
||||||
|
import top.yukonga.miuix.kmp.basic.Card
|
||||||
|
import top.yukonga.miuix.kmp.basic.FloatingActionButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.Icon
|
||||||
|
import top.yukonga.miuix.kmp.basic.IconButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||||
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextButton
|
||||||
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
|
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||||
|
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
|
||||||
|
import top.yukonga.miuix.kmp.icon.icons.useful.Refresh
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||||
|
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||||
|
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||||
|
|
||||||
private val SPACING_SMALL = 3.dp
|
private val SPACING_SMALL = 3.dp
|
||||||
private val SPACING_MEDIUM = 8.dp
|
private val SPACING_MEDIUM = 8.dp
|
||||||
@@ -38,16 +55,13 @@ private val SPACING_LARGE = 16.dp
|
|||||||
|
|
||||||
data class UmountPathEntry(
|
data class UmountPathEntry(
|
||||||
val path: String,
|
val path: String,
|
||||||
val flags: Int,
|
val flags: Int
|
||||||
val isDefault: Boolean
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Destination<RootGraph>
|
@Destination<RootGraph>
|
||||||
@Composable
|
@Composable
|
||||||
fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
fun UmountManager(navigator: DestinationsNavigator) {
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = MiuixScrollBehavior()
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val confirmDialog = rememberConfirmDialog()
|
val confirmDialog = rememberConfirmDialog()
|
||||||
@@ -75,59 +89,64 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.umount_path_manager)) },
|
title = stringResource(R.string.umount_path_manager),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navigator.navigateUp() }) {
|
IconButton(onClick = { navigator.navigateUp() }) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Back,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { loadPaths() }) {
|
IconButton(onClick = { loadPaths() }) {
|
||||||
Icon(Icons.Filled.Refresh, contentDescription = null)
|
Icon(
|
||||||
|
imageVector = MiuixIcons.Useful.Refresh,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
color = Color.Transparent,
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
scrollBehavior = scrollBehavior
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(
|
|
||||||
alpha = CardConfig.cardAlpha
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { showAddDialog = true }
|
onClick = { showAddDialog = true }
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Add, contentDescription = null)
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
|
.height(getWindowSize().height.dp)
|
||||||
|
.scrollEndHaptic()
|
||||||
|
.overScrollVertical()
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(SPACING_LARGE),
|
.padding(SPACING_LARGE)
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
elevation = getCardElevation()
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier.padding(SPACING_LARGE)
|
modifier = Modifier.padding(SPACING_LARGE),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Info,
|
imageVector = Icons.Outlined.Info,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
tint = colorScheme.primary
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.umount_path_restart_notice),
|
text = stringResource(R.string.umount_path_restart_notice)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +156,7 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator()
|
top.yukonga.miuix.kmp.basic.CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -153,14 +172,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
|||||||
val success = removeUmountPath(entry.path)
|
val success = removeUmountPath(entry.path)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (success) {
|
if (success) {
|
||||||
snackBarHost.showSnackbar(
|
Toast.makeText(
|
||||||
context.getString(R.string.umount_path_removed)
|
context,
|
||||||
)
|
context.getString(R.string.umount_path_removed),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
loadPaths()
|
loadPaths()
|
||||||
} else {
|
} else {
|
||||||
snackBarHost.showSnackbar(
|
Toast.makeText(
|
||||||
context.getString(R.string.operation_failed)
|
context,
|
||||||
)
|
context.getString(R.string.operation_failed),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,14 +213,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
|||||||
val success = clearCustomUmountPaths()
|
val success = clearCustomUmountPaths()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (success) {
|
if (success) {
|
||||||
snackBarHost.showSnackbar(
|
Toast.makeText(
|
||||||
context.getString(R.string.custom_paths_cleared)
|
context,
|
||||||
)
|
context.getString(R.string.custom_paths_cleared),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
loadPaths()
|
loadPaths()
|
||||||
} else {
|
} else {
|
||||||
snackBarHost.showSnackbar(
|
Toast.makeText(
|
||||||
context.getString(R.string.operation_failed)
|
context,
|
||||||
)
|
context.getString(R.string.operation_failed),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,9 +233,7 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
|||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.DeleteForever, contentDescription = null)
|
Text(text = stringResource(R.string.clear_custom_paths))
|
||||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
|
||||||
Text(stringResource(R.string.clear_custom_paths))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
@@ -217,22 +242,24 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
|||||||
val success = applyUmountConfigToKernel()
|
val success = applyUmountConfigToKernel()
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (success) {
|
if (success) {
|
||||||
snackBarHost.showSnackbar(
|
Toast.makeText(
|
||||||
context.getString(R.string.config_applied)
|
context,
|
||||||
)
|
context.getString(R.string.config_applied),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
} else {
|
} else {
|
||||||
snackBarHost.showSnackbar(
|
Toast.makeText(
|
||||||
context.getString(R.string.operation_failed)
|
context,
|
||||||
)
|
context.getString(R.string.operation_failed),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Check, contentDescription = null)
|
Text(text = stringResource(R.string.apply_config))
|
||||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
|
||||||
Text(stringResource(R.string.apply_config))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,14 +278,18 @@ fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
|||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (success) {
|
if (success) {
|
||||||
saveUmountConfig()
|
saveUmountConfig()
|
||||||
snackBarHost.showSnackbar(
|
Toast.makeText(
|
||||||
context.getString(R.string.umount_path_added)
|
context,
|
||||||
)
|
context.getString(R.string.umount_path_added),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
loadPaths()
|
loadPaths()
|
||||||
} else {
|
} else {
|
||||||
snackBarHost.showSnackbar(
|
Toast.makeText(
|
||||||
context.getString(R.string.operation_failed)
|
context,
|
||||||
)
|
context.getString(R.string.operation_failed),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,9 +309,7 @@ fun UmountPathCard(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()
|
||||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
|
||||||
elevation = getCardElevation()
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -289,12 +318,9 @@ fun UmountPathCard(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Folder,
|
imageVector = Icons.Outlined.Folder,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (entry.isDefault)
|
tint = colorScheme.primary,
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.secondary,
|
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -302,8 +328,7 @@ fun UmountPathCard(
|
|||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = entry.path,
|
text = entry.path
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||||
Text(
|
Text(
|
||||||
@@ -311,17 +336,11 @@ fun UmountPathCard(
|
|||||||
append(context.getString(R.string.flags))
|
append(context.getString(R.string.flags))
|
||||||
append(": ")
|
append(": ")
|
||||||
append(entry.flags.toUmountFlagName(context))
|
append(entry.flags.toUmountFlagName(context))
|
||||||
if (entry.isDefault) {
|
|
||||||
append(" | ")
|
|
||||||
append(context.getString(R.string.default_entry))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
color = colorScheme.onSurfaceVariantSummary
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entry.isDefault) {
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -335,14 +354,13 @@ fun UmountPathCard(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Delete,
|
imageVector = MiuixIcons.Useful.Delete,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = colorScheme.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -352,50 +370,69 @@ fun AddUmountPathDialog(
|
|||||||
) {
|
) {
|
||||||
var path by rememberSaveable { mutableStateOf("") }
|
var path by rememberSaveable { mutableStateOf("") }
|
||||||
var flags by rememberSaveable { mutableStateOf("-1") }
|
var flags by rememberSaveable { mutableStateOf("-1") }
|
||||||
|
val showDialog = remember { mutableStateOf(true) }
|
||||||
|
|
||||||
AlertDialog(
|
SuperDialog(
|
||||||
onDismissRequest = onDismiss,
|
show = showDialog,
|
||||||
title = { Text(stringResource(R.string.add_umount_path)) },
|
title = stringResource(R.string.add_umount_path),
|
||||||
text = {
|
onDismissRequest = {
|
||||||
Column {
|
showDialog.value = false
|
||||||
OutlinedTextField(
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
value = path,
|
value = path,
|
||||||
onValueChange = { path = it },
|
onValueChange = { path = it },
|
||||||
label = { Text(stringResource(R.string.mount_path)) },
|
label = stringResource(R.string.mount_path),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
|
|
||||||
OutlinedTextField(
|
TextField(
|
||||||
value = flags,
|
value = flags,
|
||||||
onValueChange = { flags = it },
|
onValueChange = { flags = it },
|
||||||
label = { Text(stringResource(R.string.umount_flags)) },
|
label = stringResource(R.string.umount_flags),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
supportingText = { Text(stringResource(R.string.umount_flags_hint)) }
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||||
confirmButton = {
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.umount_flags_hint),
|
||||||
|
color = colorScheme.onSurfaceVariantSummary,
|
||||||
|
modifier = Modifier.padding(start = SPACING_MEDIUM)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
|
text = stringResource(android.R.string.cancel),
|
||||||
|
onClick = {
|
||||||
|
showDialog.value = false
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(20.dp))
|
||||||
|
TextButton(
|
||||||
|
text = stringResource(android.R.string.ok),
|
||||||
onClick = {
|
onClick = {
|
||||||
val flagsInt = flags.toIntOrNull() ?: -1
|
val flagsInt = flags.toIntOrNull() ?: -1
|
||||||
|
showDialog.value = false
|
||||||
onConfirm(path, flagsInt)
|
onConfirm(path, flagsInt)
|
||||||
},
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
enabled = path.isNotBlank()
|
enabled = path.isNotBlank()
|
||||||
) {
|
|
||||||
Text(stringResource(android.R.string.ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseUmountPaths(output: String): List<UmountPathEntry> {
|
private fun parseUmountPaths(output: String): List<UmountPathEntry> {
|
||||||
@@ -404,11 +441,10 @@ private fun parseUmountPaths(output: String): List<UmountPathEntry> {
|
|||||||
|
|
||||||
return lines.drop(2).mapNotNull { line ->
|
return lines.drop(2).mapNotNull { line ->
|
||||||
val parts = line.trim().split(Regex("\\s+"))
|
val parts = line.trim().split(Regex("\\s+"))
|
||||||
if (parts.size >= 3) {
|
if (parts.size >= 2) {
|
||||||
UmountPathEntry(
|
UmountPathEntry(
|
||||||
path = parts[0],
|
path = parts[0],
|
||||||
flags = parts[1].toIntOrNull() ?: -1,
|
flags = parts[1].toIntOrNull() ?: -1
|
||||||
isDefault = parts[2].equals("Yes", ignoreCase = true)
|
|
||||||
)
|
)
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
@@ -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_DIR="$$LOG_DIR"
|
||||||
LOG_FILE="${'$'}LOG_DIR/$logFileName"
|
LOG_FILE="$LOG_DIR/$$logFileName"
|
||||||
|
|
||||||
# 创建日志目录
|
# 创建日志目录
|
||||||
mkdir -p "${'$'}LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
# 获取当前时间
|
# 获取当前时间
|
||||||
get_current_time() {
|
get_current_time() {
|
||||||
@@ -41,11 +41,11 @@ object ScriptGenerator {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
// 二进制文件检查的通用脚本片段
|
// 二进制文件检查的通用脚本片段
|
||||||
private fun generateBinaryCheck(targetPath: String): String = """
|
private fun generateBinaryCheck(targetPath: String): String = $$"""
|
||||||
# 检查SuSFS二进制文件
|
# 检查SuSFS二进制文件
|
||||||
SUSFS_BIN="$targetPath"
|
SUSFS_BIN="$$targetPath"
|
||||||
if [ ! -f "${'$'}SUSFS_BIN" ]; then
|
if [ ! -f "$SUSFS_BIN" ]; then
|
||||||
echo "$(get_current_time): SuSFS二进制文件未找到: ${'$'}SUSFS_BIN" >> "${'$'}LOG_FILE"
|
echo "$(get_current_time): SuSFS二进制文件未找到: $SUSFS_BIN" >> "$LOG_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
@@ -66,8 +66,8 @@ object ScriptGenerator {
|
|||||||
appendLine()
|
appendLine()
|
||||||
|
|
||||||
if (shouldConfigureInService(config)) {
|
if (shouldConfigureInService(config)) {
|
||||||
// 添加SUS路径 (仅在不支持隐藏挂载时)
|
// 添加SUS路径
|
||||||
if (!config.support158 && config.susPaths.isNotEmpty()) {
|
if (config.susPaths.isNotEmpty()) {
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
|
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
|
||||||
appendLine("sleep 45")
|
appendLine("sleep 45")
|
||||||
@@ -94,7 +94,7 @@ object ScriptGenerator {
|
|||||||
generateCleanupResidueSection()
|
generateCleanupResidueSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
appendLine("echo \"$(get_current_time): Service脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): Service脚本执行完成\" >> \"$LOG_FILE\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,16 +112,16 @@ object ScriptGenerator {
|
|||||||
private fun StringBuilder.generateLogSettingSection(enableLog: Boolean) {
|
private fun StringBuilder.generateLogSettingSection(enableLog: Boolean) {
|
||||||
appendLine("# 设置日志启用状态")
|
appendLine("# 设置日志启用状态")
|
||||||
val logValue = if (enableLog) 1 else 0
|
val logValue = if (enableLog) 1 else 0
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" enable_log $logValue")
|
appendLine($$"\"$SUSFS_BIN\" enable_log $$logValue")
|
||||||
appendLine("echo \"$(get_current_time): 日志功能设置为: ${if (enableLog) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 日志功能设置为: $${if (enableLog) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun StringBuilder.generateAvcLogSpoofingSection(enableAvcLogSpoofing: Boolean) {
|
private fun StringBuilder.generateAvcLogSpoofingSection(enableAvcLogSpoofing: Boolean) {
|
||||||
appendLine("# 设置AVC日志欺骗状态")
|
appendLine("# 设置AVC日志欺骗状态")
|
||||||
val avcLogValue = if (enableAvcLogSpoofing) 1 else 0
|
val avcLogValue = if (enableAvcLogSpoofing) 1 else 0
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" enable_avc_log_spoofing $avcLogValue")
|
appendLine($$"\"$SUSFS_BIN\" enable_avc_log_spoofing $$avcLogValue")
|
||||||
appendLine("echo \"$(get_current_time): AVC日志欺骗功能设置为: ${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): AVC日志欺骗功能设置为: $${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +129,8 @@ object ScriptGenerator {
|
|||||||
if (susPaths.isNotEmpty()) {
|
if (susPaths.isNotEmpty()) {
|
||||||
appendLine("# 添加SUS路径")
|
appendLine("# 添加SUS路径")
|
||||||
susPaths.forEach { path ->
|
susPaths.forEach { path ->
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path '$path'")
|
appendLine($$"\"$SUSFS_BIN\" add_sus_path '$$path'")
|
||||||
appendLine("echo \"$(get_current_time): 添加SUS路径: $path\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 添加SUS路径: $$path\" >> \"$LOG_FILE\"")
|
||||||
}
|
}
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
@@ -140,8 +140,8 @@ object ScriptGenerator {
|
|||||||
if (susLoopPaths.isNotEmpty()) {
|
if (susLoopPaths.isNotEmpty()) {
|
||||||
appendLine("# 添加SUS循环路径")
|
appendLine("# 添加SUS循环路径")
|
||||||
susLoopPaths.forEach { path ->
|
susLoopPaths.forEach { path ->
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path_loop '$path'")
|
appendLine($$"\"$SUSFS_BIN\" add_sus_path_loop '$$path'")
|
||||||
appendLine("echo \"$(get_current_time): 添加SUS循环路径: $path\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 添加SUS循环路径: $$path\" >> \"$LOG_FILE\"")
|
||||||
}
|
}
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
@@ -156,8 +156,8 @@ object ScriptGenerator {
|
|||||||
if (addKstatPaths.isNotEmpty()) {
|
if (addKstatPaths.isNotEmpty()) {
|
||||||
appendLine("# 添加Kstat路径")
|
appendLine("# 添加Kstat路径")
|
||||||
addKstatPaths.forEach { path ->
|
addKstatPaths.forEach { path ->
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat '$path'")
|
appendLine($$"\"$SUSFS_BIN\" add_sus_kstat '$$path'")
|
||||||
appendLine("echo \"$(get_current_time): 添加Kstat路径: $path\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 添加Kstat路径: $$path\" >> \"$LOG_FILE\"")
|
||||||
}
|
}
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
@@ -171,11 +171,11 @@ object ScriptGenerator {
|
|||||||
val path = parts[0]
|
val path = parts[0]
|
||||||
val params = parts.drop(1).joinToString("' '", "'", "'")
|
val params = parts.drop(1).joinToString("' '", "'", "'")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat_statically '$path' $params")
|
appendLine($$"\"$SUSFS_BIN\" add_sus_kstat_statically '$$path' $$params")
|
||||||
appendLine("echo \"$(get_current_time): 添加Kstat静态配置: $path\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 添加Kstat静态配置: $$path\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" update_sus_kstat '$path'")
|
appendLine($$"\"$SUSFS_BIN\" update_sus_kstat '$$path'")
|
||||||
appendLine("echo \"$(get_current_time): 更新Kstat配置: $path\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 更新Kstat配置: $$path\" >> \"$LOG_FILE\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
appendLine()
|
appendLine()
|
||||||
@@ -185,8 +185,8 @@ object ScriptGenerator {
|
|||||||
private fun StringBuilder.generateUnameSection(config: SuSFSManager.ModuleConfig) {
|
private fun StringBuilder.generateUnameSection(config: SuSFSManager.ModuleConfig) {
|
||||||
if (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
|
if (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
|
||||||
appendLine("# 设置uname和构建时间")
|
appendLine("# 设置uname和构建时间")
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'")
|
appendLine($$"\"$SUSFS_BIN\" set_uname '$${config.unameValue}' '$${config.buildTimeValue}'")
|
||||||
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 设置uname为: $${config.unameValue}, 构建时间为: $${config.buildTimeValue}\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,44 +194,44 @@ object ScriptGenerator {
|
|||||||
private fun StringBuilder.generateHideBlSection() {
|
private fun StringBuilder.generateHideBlSection() {
|
||||||
appendLine("# 隐藏BL 来自 Shamiko 脚本")
|
appendLine("# 隐藏BL 来自 Shamiko 脚本")
|
||||||
appendLine(
|
appendLine(
|
||||||
"""
|
$$"""
|
||||||
RESETPROP_BIN="/data/adb/ksu/bin/resetprop"
|
RESETPROP_BIN="/data/adb/ksu/bin/resetprop"
|
||||||
|
|
||||||
check_reset_prop() {
|
check_reset_prop() {
|
||||||
local NAME=$1
|
local NAME=$1
|
||||||
local EXPECTED=$2
|
local EXPECTED=$2
|
||||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
local VALUE=$("$RESETPROP_BIN" $NAME)
|
||||||
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
[ -z $VALUE ] || [ $VALUE = $EXPECTED ] || "$RESETPROP_BIN" $NAME $EXPECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
check_missing_prop() {
|
check_missing_prop() {
|
||||||
local NAME=$1
|
local NAME=$1
|
||||||
local EXPECTED=$2
|
local EXPECTED=$2
|
||||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
local VALUE=$("$RESETPROP_BIN" $NAME)
|
||||||
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
[ -z $VALUE ] && "$RESETPROP_BIN" $NAME $EXPECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
check_missing_match_prop() {
|
check_missing_match_prop() {
|
||||||
local NAME=$1
|
local NAME=$1
|
||||||
local EXPECTED=$2
|
local EXPECTED=$2
|
||||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
local VALUE=$("$RESETPROP_BIN" $NAME)
|
||||||
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
[ -z $VALUE ] || [ $VALUE = $EXPECTED ] || "$RESETPROP_BIN" $NAME $EXPECTED
|
||||||
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
[ -z $VALUE ] && "$RESETPROP_BIN" $NAME $EXPECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
contains_reset_prop() {
|
contains_reset_prop() {
|
||||||
local NAME=$1
|
local NAME=$1
|
||||||
local CONTAINS=$2
|
local CONTAINS=$2
|
||||||
local NEWVAL=$3
|
local NEWVAL=$3
|
||||||
case "$("${'$'}RESETPROP_BIN" ${'$'}NAME)" in
|
case "$("$RESETPROP_BIN" $NAME)" in
|
||||||
*"${'$'}CONTAINS"*) "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}NEWVAL ;;
|
*"$CONTAINS"*) "$RESETPROP_BIN" $NAME $NEWVAL ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("sleep 30")
|
appendLine("sleep 30")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("\"${'$'}RESETPROP_BIN\" -w sys.boot_completed 0")
|
appendLine($$"\"$RESETPROP_BIN\" -w sys.boot_completed 0")
|
||||||
|
|
||||||
// 添加所有系统属性重置
|
// 添加所有系统属性重置
|
||||||
val systemProps = listOf(
|
val systemProps = listOf(
|
||||||
@@ -292,27 +292,28 @@ object ScriptGenerator {
|
|||||||
// 清理残留脚本生成
|
// 清理残留脚本生成
|
||||||
private fun StringBuilder.generateCleanupResidueSection() {
|
private fun StringBuilder.generateCleanupResidueSection() {
|
||||||
appendLine("# 清理工具残留文件")
|
appendLine("# 清理工具残留文件")
|
||||||
appendLine("echo \"$(get_current_time): 开始清理工具残留\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 开始清理工具残留\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
|
|
||||||
// 定义清理函数
|
// 定义清理函数
|
||||||
appendLine("""
|
appendLine(
|
||||||
|
$$"""
|
||||||
cleanup_path() {
|
cleanup_path() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
local desc="$2"
|
local desc="$2"
|
||||||
local current="$3"
|
local current="$3"
|
||||||
local total="$4"
|
local total="$4"
|
||||||
|
|
||||||
if [ -n "${'$'}desc" ]; then
|
if [ -n "$desc" ]; then
|
||||||
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path (${'$'}desc)" >> "${'$'}LOG_FILE"
|
echo "$(get_current_time): [$current/$total] 清理: $path ($desc)" >> "$LOG_FILE"
|
||||||
else
|
else
|
||||||
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path" >> "${'$'}LOG_FILE"
|
echo "$(get_current_time): [$current/$total] 清理: $path" >> "$LOG_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if rm -rf "${'$'}path" 2>/dev/null; then
|
if rm -rf "$path" 2>/dev/null; then
|
||||||
echo "$(get_current_time): ✓ 成功清理: ${'$'}path" >> "${'$'}LOG_FILE"
|
echo "$(get_current_time): ✓ 成功清理: $path" >> "$LOG_FILE"
|
||||||
else
|
else
|
||||||
echo "$(get_current_time): ✗ 清理失败或不存在: ${'$'}path" >> "${'$'}LOG_FILE"
|
echo "$(get_current_time): ✗ 清理失败或不存在: $path" >> "$LOG_FILE"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
""".trimIndent())
|
""".trimIndent())
|
||||||
@@ -360,11 +361,11 @@ object ScriptGenerator {
|
|||||||
|
|
||||||
cleanupPaths.forEachIndexed { index, (path, desc) ->
|
cleanupPaths.forEachIndexed { index, (path, desc) ->
|
||||||
val current = index + 1
|
val current = index + 1
|
||||||
appendLine("cleanup_path '$path' '$desc' $current \$TOTAL")
|
appendLine($$"cleanup_path '$$path' '$$desc' $$current $TOTAL")
|
||||||
}
|
}
|
||||||
|
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("echo \"$(get_current_time): 工具残留清理完成\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 工具残留清理完成\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,36 +382,34 @@ object ScriptGenerator {
|
|||||||
appendLine()
|
appendLine()
|
||||||
appendLine(generateBinaryCheck(config.targetPath))
|
appendLine(generateBinaryCheck(config.targetPath))
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
|
|
||||||
// 设置uname和构建时间 - 只有在选择在post-fs-data中执行时才执行
|
// 设置uname和构建时间 - 只有在选择在post-fs-data中执行时才执行
|
||||||
if (config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
|
if (config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
|
||||||
appendLine("# 设置uname和构建时间")
|
appendLine("# 设置uname和构建时间")
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'")
|
appendLine($$"\"$SUSFS_BIN\" set_uname '$${config.unameValue}' '$${config.buildTimeValue}'")
|
||||||
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 设置uname为: $${config.unameValue}, 构建时间为: $${config.buildTimeValue}\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
|
|
||||||
generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService, config.support158)
|
generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService)
|
||||||
|
|
||||||
// 添加AVC日志欺骗设置
|
// 添加AVC日志欺骗设置
|
||||||
generateAvcLogSpoofingSection(config.enableAvcLogSpoofing)
|
generateAvcLogSpoofingSection(config.enableAvcLogSpoofing)
|
||||||
|
|
||||||
appendLine("echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"$LOG_FILE\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加新的生成方法
|
// 添加新的生成方法
|
||||||
private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean, support158: Boolean) {
|
private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean) {
|
||||||
if (support158) {
|
|
||||||
appendLine("# 设置Zygote隔离服务卸载状态")
|
appendLine("# 设置Zygote隔离服务卸载状态")
|
||||||
val umountValue = if (umountForZygoteIsoService) 1 else 0
|
val umountValue = if (umountForZygoteIsoService) 1 else 0
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" umount_for_zygote_iso_service $umountValue")
|
appendLine($$"\"$SUSFS_BIN\" umount_for_zygote_iso_service $$umountValue")
|
||||||
appendLine("echo \"$(get_current_time): Zygote隔离服务卸载设置为: ${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): Zygote隔离服务卸载设置为: $${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成post-mount.sh脚本内容
|
* 生成post-mount.sh脚本内容
|
||||||
@@ -423,37 +422,12 @@ object ScriptGenerator {
|
|||||||
appendLine()
|
appendLine()
|
||||||
appendLine(generateLogSetup("susfs_post_mount.log"))
|
appendLine(generateLogSetup("susfs_post_mount.log"))
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine(generateBinaryCheck(config.targetPath))
|
appendLine(generateBinaryCheck(config.targetPath))
|
||||||
appendLine()
|
appendLine()
|
||||||
|
|
||||||
// 添加SUS挂载
|
appendLine($$"echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"$LOG_FILE\"")
|
||||||
if (config.susMounts.isNotEmpty()) {
|
|
||||||
appendLine("# 添加SUS挂载")
|
|
||||||
config.susMounts.forEach { mount ->
|
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_mount '$mount'")
|
|
||||||
appendLine("echo \"$(get_current_time): 添加SUS挂载: $mount\" >> \"${'$'}LOG_FILE\"")
|
|
||||||
}
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加尝试卸载
|
|
||||||
if (config.tryUmounts.isNotEmpty()) {
|
|
||||||
appendLine("# 添加尝试卸载")
|
|
||||||
config.tryUmounts.forEach { umount ->
|
|
||||||
val parts = umount.split("|")
|
|
||||||
if (parts.size == 2) {
|
|
||||||
val path = parts[0]
|
|
||||||
val mode = parts[1]
|
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" add_try_umount '$path' $mode")
|
|
||||||
appendLine("echo \"$(get_current_time): 添加尝试卸载: $path (模式: $mode)\" >> \"${'$'}LOG_FILE\"")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appendLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
appendLine("echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,18 +443,16 @@ object ScriptGenerator {
|
|||||||
appendLine()
|
appendLine()
|
||||||
appendLine(generateLogSetup("susfs_boot_completed.log"))
|
appendLine(generateLogSetup("susfs_boot_completed.log"))
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine(generateBinaryCheck(config.targetPath))
|
appendLine(generateBinaryCheck(config.targetPath))
|
||||||
appendLine()
|
appendLine()
|
||||||
|
|
||||||
// 仅在支持隐藏挂载功能时执行相关配置
|
|
||||||
if (config.support158) {
|
|
||||||
// SUS挂载隐藏控制
|
// SUS挂载隐藏控制
|
||||||
val hideValue = if (config.hideSusMountsForAllProcs) 1 else 0
|
val hideValue = if (config.hideSusMountsForAllProcs) 1 else 0
|
||||||
appendLine("# 设置SUS挂载隐藏控制")
|
appendLine("# 设置SUS挂载隐藏控制")
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" hide_sus_mnts_for_all_procs $hideValue")
|
appendLine($$"\"$SUSFS_BIN\" hide_sus_mnts_for_all_procs $$hideValue")
|
||||||
appendLine("echo \"$(get_current_time): SUS挂载隐藏控制设置为: ${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): SUS挂载隐藏控制设置为: $${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
|
|
||||||
// 路径设置和SUS路径设置
|
// 路径设置和SUS路径设置
|
||||||
@@ -502,9 +474,8 @@ object ScriptGenerator {
|
|||||||
generateSusMapsSection(config.susMaps)
|
generateSusMapsSection(config.susMaps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
appendLine("echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"$LOG_FILE\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,8 +483,8 @@ object ScriptGenerator {
|
|||||||
if (susMaps.isNotEmpty()) {
|
if (susMaps.isNotEmpty()) {
|
||||||
appendLine("# 添加SUS映射")
|
appendLine("# 添加SUS映射")
|
||||||
susMaps.forEach { map ->
|
susMaps.forEach { map ->
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_map '$map'")
|
appendLine($$"\"$SUSFS_BIN\" add_sus_map '$$map'")
|
||||||
appendLine("echo \"$(get_current_time): 添加SUS映射: $map\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): 添加SUS映射: $$map\" >> \"$LOG_FILE\"")
|
||||||
}
|
}
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
@@ -526,12 +497,12 @@ object ScriptGenerator {
|
|||||||
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
|
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
|
||||||
appendLine("sleep 60")
|
appendLine("sleep 60")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" set_android_data_root_path '$androidDataPath'")
|
appendLine($$"\"$SUSFS_BIN\" set_android_data_root_path '$$androidDataPath'")
|
||||||
appendLine("echo \"$(get_current_time): Android Data路径设置为: $androidDataPath\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): Android Data路径设置为: $$androidDataPath\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
appendLine("# 设置SD卡路径")
|
appendLine("# 设置SD卡路径")
|
||||||
appendLine("\"${'$'}SUSFS_BIN\" set_sdcard_root_path '$sdcardPath'")
|
appendLine($$"\"$SUSFS_BIN\" set_sdcard_root_path '$$sdcardPath'")
|
||||||
appendLine("echo \"$(get_current_time): SD卡路径设置为: $sdcardPath\" >> \"${'$'}LOG_FILE\"")
|
appendLine($$"echo \"$(get_current_time): SD卡路径设置为: $$sdcardPath\" >> \"$LOG_FILE\"")
|
||||||
appendLine()
|
appendLine()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,8 +510,8 @@ object ScriptGenerator {
|
|||||||
* 生成module.prop文件内容
|
* 生成module.prop文件内容
|
||||||
*/
|
*/
|
||||||
fun generateModuleProp(moduleId: String): String {
|
fun generateModuleProp(moduleId: String): String {
|
||||||
val moduleVersion = "v1.0.2"
|
val moduleVersion = "v4.0.0"
|
||||||
val moduleVersionCode = "1002"
|
val moduleVersionCode = "40000"
|
||||||
|
|
||||||
return """
|
return """
|
||||||
id=$moduleId
|
id=$moduleId
|
||||||
|
|||||||
@@ -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
|
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.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
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.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import com.sukisu.ultra.ui.webui.MonetColorsProvider.UpdateCss
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun KernelSUTheme(
|
fun KernelSUTheme(
|
||||||
darkTheme: Boolean = when(ThemeConfig.forceDarkMode) {
|
colorMode: Int = 0,
|
||||||
true -> true
|
keyColor: Color? = null,
|
||||||
false -> false
|
|
||||||
null -> isSystemInDarkTheme()
|
|
||||||
},
|
|
||||||
dynamicColor: Boolean = ThemeConfig.useDynamicColor,
|
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val isDark = isSystemInDarkTheme()
|
||||||
val systemIsDark = isSystemInDarkTheme()
|
val controller = when (colorMode) {
|
||||||
|
1 -> ThemeController(ColorSchemeMode.Light)
|
||||||
|
2 -> ThemeController(ColorSchemeMode.Dark)
|
||||||
|
3 -> ThemeController(
|
||||||
|
ColorSchemeMode.MonetSystem,
|
||||||
|
keyColor = keyColor,
|
||||||
|
isDark = isDark
|
||||||
|
)
|
||||||
|
|
||||||
// 初始化主题
|
4 -> ThemeController(
|
||||||
ThemeInitializer(context = context, systemIsDark = systemIsDark)
|
ColorSchemeMode.MonetLight,
|
||||||
|
keyColor = keyColor,
|
||||||
|
)
|
||||||
|
|
||||||
// 创建颜色方案
|
5 -> ThemeController(
|
||||||
val colorScheme = createColorScheme(context, darkTheme, dynamicColor)
|
ColorSchemeMode.MonetDark,
|
||||||
|
keyColor = keyColor,
|
||||||
|
)
|
||||||
|
|
||||||
// 系统栏样式
|
else -> ThemeController(ColorSchemeMode.System)
|
||||||
SystemBarController(darkTheme)
|
}
|
||||||
|
return MiuixTheme(
|
||||||
MaterialTheme(
|
controller = controller,
|
||||||
colorScheme = colorScheme,
|
content = {
|
||||||
typography = Typography
|
UpdateCss()
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// 背景层
|
|
||||||
BackgroundLayer(darkTheme)
|
|
||||||
// 内容层
|
|
||||||
Box(modifier = Modifier.fillMaxSize().zIndex(1f)) {
|
|
||||||
content()
|
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
|
@Composable
|
||||||
private fun createColorScheme(
|
@ReadOnlyComposable
|
||||||
context: Context,
|
fun isInDarkTheme(themeMode: Int): Boolean {
|
||||||
darkTheme: Boolean,
|
return when (themeMode) {
|
||||||
dynamicColor: Boolean
|
1, 4 -> false // Force light mode
|
||||||
): ColorScheme {
|
2, 5 -> true // Force dark mode
|
||||||
return when {
|
else -> isSystemInDarkTheme() // Follow system (0 or default)
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
if (darkTheme) createDynamicDarkColorScheme(context)
|
|
||||||
else createDynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
darkTheme -> createDarkColorScheme()
|
|
||||||
else -> createLightColorScheme()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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