Updated main branch

This commit is contained in:
Fr4nz D13trich 2025-11-20 21:30:08 +01:00
parent 9a3987bc1e
commit 3f2388004d
350 changed files with 79360 additions and 0 deletions

2
manager/app/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/build
/release/

View file

@ -0,0 +1,168 @@
@file:Suppress("UnstableApiUsage")
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
import com.android.build.gradle.tasks.PackageAndroidArtifact
plugins {
alias(libs.plugins.agp.app)
alias(libs.plugins.kotlin)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
alias(libs.plugins.lsplugin.apksign)
id("kotlin-parcelize")
}
val managerVersionCode: Int by rootProject.extra
val managerVersionName: String by rootProject.extra
val androidCmakeVersion: String by rootProject.extra
apksign {
storeFileProperty = "KEYSTORE_FILE"
storePasswordProperty = "KEYSTORE_PASSWORD"
keyAliasProperty = "KEY_ALIAS"
keyPasswordProperty = "KEY_PASSWORD"
}
android {
/**signingConfigs {
create("Debug") {
storeFile = file("D:\\other\\AndroidTool\\android_key\\keystore\\release-key.keystore")
storePassword = ""
keyAlias = ""
keyPassword = ""
}
}**/
namespace = "com.sukisu.ultra"
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
vcsInfo.include = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
/**debug {
signingConfig = signingConfigs.named("Debug").get() as ApkSigningConfig
}**/
}
buildFeatures {
aidl = true
buildConfig = true
compose = true
prefab = true
}
packaging {
jniLibs {
useLegacyPackaging = true
}
resources {
// https://stackoverflow.com/a/58956288
// It will break Layout Inspector, but it's unused for release build.
excludes += "META-INF/*.version"
// https://github.com/Kotlin/kotlinx.coroutines?tab=readme-ov-file#avoiding-including-the-debug-infrastructure-in-the-resulting-apk
excludes += "DebugProbesKt.bin"
// https://issueantenna.com/repo/kotlin/kotlinx.coroutines/issues/3158
excludes += "kotlin-tooling-metadata.json"
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = androidCmakeVersion
}
}
applicationVariants.all {
outputs.forEach {
val output = it as BaseVariantOutputImpl
output.outputFileName = "SukiSU_${managerVersionName}_${managerVersionCode}-$name.apk"
}
kotlin.sourceSets {
getByName(name) {
kotlin.srcDir("build/generated/ksp/$name/kotlin")
}
}
}
// https://stackoverflow.com/a/77745844
tasks.withType<PackageAndroidArtifact> {
doFirst { appMetadata.asFile.orNull?.writeText("") }
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
androidResources {
generateLocaleConfig = true
}
}
ksp {
arg("compose-destinations.defaultTransitions", "none")
}
dependencies {
implementation(libs.gson)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.foundation)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.compose.foundation)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.compose.destinations.core)
ksp(libs.compose.destinations.ksp)
implementation(libs.com.github.topjohnwu.libsu.core)
implementation(libs.com.github.topjohnwu.libsu.service)
implementation(libs.com.github.topjohnwu.libsu.io)
implementation(libs.dev.rikka.rikkax.parcelablelist)
implementation(libs.io.coil.kt.coil.compose)
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.androidx.webkit)
implementation(libs.lsposed.cxx)
implementation(libs.com.github.topjohnwu.libsu.core)
implementation(libs.mmrl.platform)
compileOnly(libs.mmrl.hidden.api)
implementation(libs.mmrl.webui)
implementation(libs.mmrl.ui)
implementation(libs.accompanist.drawablepainter)
}

48
manager/app/proguard-rules.pro vendored Normal file
View file

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

View file

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

View file

@ -0,0 +1,10 @@
// IKsuInterface.aidl
package com.sukisu.zako;
import android.content.pm.PackageInfo;
import java.util.List;
interface IKsuInterface {
int getPackageCount();
List<PackageInfo> getPackages(int start, int maxCount);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,28 @@
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html
# Sets the minimum version of CMake required to build the native library.
cmake_minimum_required(VERSION 3.18.1)
project("kernelsu")
add_library(kernelsu
SHARED
jni.c
ksu.c
legacy.c
)
find_library(log-lib log)
if(ANDROID_ABI STREQUAL "arm64-v8a")
set(zakosign-lib ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libzakosign.so)
elseif(ANDROID_ABI STREQUAL "armeabi-v7a")
set(zakosign-lib ${CMAKE_SOURCE_DIR}/../jniLibs/armeabi-v7a/libzakosign.so)
endif()
if(ANDROID_ABI STREQUAL "arm64-v8a" OR ANDROID_ABI STREQUAL "armeabi-v7a")
target_link_libraries(kernelsu ${log-lib} ${zakosign-lib})
else()
target_link_libraries(kernelsu ${log-lib})
endif()

View file

@ -0,0 +1,452 @@
#include "prelude.h"
#include "ksu.h"
#include <jni.h>
#include <sys/prctl.h>
#include <android/log.h>
#include <string.h>
#include <linux/capability.h>
#include <pwd.h>
NativeBridgeNP(getVersion, jint) {
uint32_t version = get_version();
if (version > 0) {
return (jint)version;
}
// try legacy method as fallback
return legacy_get_info().version;
}
// get VERSION FULL
NativeBridgeNP(getFullVersion, jstring) {
char buff[255] = { 0 };
get_full_version((char *) &buff);
return GetEnvironment()->NewStringUTF(env, buff);
}
NativeBridgeNP(getAllowList, jintArray) {
struct ksu_get_allow_list_cmd cmd = {};
bool result = get_allow_list(&cmd);
if (result) {
jsize array_size = (jsize)cmd.count;
if (array_size < 0 || (unsigned int)array_size != cmd.count) {
LogDebug("Invalid array size: %u", cmd.count);
return GetEnvironment()->NewIntArray(env, 0);
}
jintArray array = GetEnvironment()->NewIntArray(env, array_size);
GetEnvironment()->SetIntArrayRegion(env, array, 0, array_size, (const jint *)(cmd.uids));
return array;
}
return GetEnvironment()->NewIntArray(env, 0);
}
NativeBridgeNP(isSafeMode, jboolean) {
return is_safe_mode();
}
NativeBridgeNP(isLkmMode, jboolean) {
return is_lkm_mode();
}
NativeBridgeNP(isManager, jboolean) {
return is_manager();
}
static void fillIntArray(JNIEnv *env, jobject list, int *data, int count) {
jclass cls = GetEnvironment()->GetObjectClass(env, list);
jmethodID add = GetEnvironment()->GetMethodID(env, cls, "add", "(Ljava/lang/Object;)Z");
jclass integerCls = GetEnvironment()->FindClass(env, "java/lang/Integer");
jmethodID constructor = GetEnvironment()->GetMethodID(env, integerCls, "<init>", "(I)V");
for (int i = 0; i < count; ++i) {
jobject integer = GetEnvironment()->NewObject(env, integerCls, constructor, data[i]);
GetEnvironment()->CallBooleanMethod(env, list, add, integer);
}
}
static void addIntToList(JNIEnv *env, jobject list, int ele) {
jclass cls = GetEnvironment()->GetObjectClass(env, list);
jmethodID add = GetEnvironment()->GetMethodID(env, cls, "add", "(Ljava/lang/Object;)Z");
jclass integerCls = GetEnvironment()->FindClass(env, "java/lang/Integer");
jmethodID constructor = GetEnvironment()->GetMethodID(env, integerCls, "<init>", "(I)V");
jobject integer = GetEnvironment()->NewObject(env, integerCls, constructor, ele);
GetEnvironment()->CallBooleanMethod(env, list, add, integer);
}
static uint64_t capListToBits(JNIEnv *env, jobject list) {
jclass cls = GetEnvironment()->GetObjectClass(env, list);
jmethodID get = GetEnvironment()->GetMethodID(env, cls, "get", "(I)Ljava/lang/Object;");
jmethodID size = GetEnvironment()->GetMethodID(env, cls, "size", "()I");
jint listSize = GetEnvironment()->CallIntMethod(env, list, size);
jclass integerCls = GetEnvironment()->FindClass(env, "java/lang/Integer");
jmethodID intValue = GetEnvironment()->GetMethodID(env, integerCls, "intValue", "()I");
uint64_t result = 0;
for (int i = 0; i < listSize; ++i) {
jobject integer = GetEnvironment()->CallObjectMethod(env, list, get, i);
int data = GetEnvironment()->CallIntMethod(env, integer, intValue);
if (cap_valid(data)) {
result |= (1ULL << data);
}
}
return result;
}
static int getListSize(JNIEnv *env, jobject list) {
jclass cls = GetEnvironment()->GetObjectClass(env, list);
jmethodID size = GetEnvironment()->GetMethodID(env, cls, "size", "()I");
return GetEnvironment()->CallIntMethod(env, list, size);
}
static void fillArrayWithList(JNIEnv *env, jobject list, int *data, int count) {
jclass cls = GetEnvironment()->GetObjectClass(env, list);
jmethodID get = GetEnvironment()->GetMethodID(env, cls, "get", "(I)Ljava/lang/Object;");
jclass integerCls = GetEnvironment()->FindClass(env, "java/lang/Integer");
jmethodID intValue = GetEnvironment()->GetMethodID(env, integerCls, "intValue", "()I");
for (int i = 0; i < count; ++i) {
jobject integer = GetEnvironment()->CallObjectMethod(env, list, get, i);
data[i] = GetEnvironment()->CallIntMethod(env, integer, intValue);
}
}
NativeBridge(getAppProfile, jobject, jstring pkg, jint uid) {
if (GetEnvironment()->GetStringLength(env, pkg) > KSU_MAX_PACKAGE_NAME) {
return NULL;
}
char key[KSU_MAX_PACKAGE_NAME] = { 0 };
const char* cpkg = GetEnvironment()->GetStringUTFChars(env, pkg, nullptr);
strcpy(key, cpkg);
GetEnvironment()->ReleaseStringUTFChars(env, pkg, cpkg);
struct app_profile profile = { 0 };
profile.version = KSU_APP_PROFILE_VER;
strcpy(profile.key, key);
profile.current_uid = uid;
bool useDefaultProfile = get_app_profile(&profile) != 0;
jclass cls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$Profile");
jmethodID constructor = GetEnvironment()->GetMethodID(env, cls, "<init>", "()V");
jobject obj = GetEnvironment()->NewObject(env, cls, constructor);
jfieldID keyField = GetEnvironment()->GetFieldID(env, cls, "name", "Ljava/lang/String;");
jfieldID currentUidField = GetEnvironment()->GetFieldID(env, cls, "currentUid", "I");
jfieldID allowSuField = GetEnvironment()->GetFieldID(env, cls, "allowSu", "Z");
jfieldID rootUseDefaultField = GetEnvironment()->GetFieldID(env, cls, "rootUseDefault", "Z");
jfieldID rootTemplateField = GetEnvironment()->GetFieldID(env, cls, "rootTemplate", "Ljava/lang/String;");
jfieldID uidField = GetEnvironment()->GetFieldID(env, cls, "uid", "I");
jfieldID gidField = GetEnvironment()->GetFieldID(env, cls, "gid", "I");
jfieldID groupsField = GetEnvironment()->GetFieldID(env, cls, "groups", "Ljava/util/List;");
jfieldID capabilitiesField = GetEnvironment()->GetFieldID(env, cls, "capabilities", "Ljava/util/List;");
jfieldID domainField = GetEnvironment()->GetFieldID(env, cls, "context", "Ljava/lang/String;");
jfieldID namespacesField = GetEnvironment()->GetFieldID(env, cls, "namespace", "I");
jfieldID nonRootUseDefaultField = GetEnvironment()->GetFieldID(env, cls, "nonRootUseDefault", "Z");
jfieldID umountModulesField = GetEnvironment()->GetFieldID(env, cls, "umountModules", "Z");
GetEnvironment()->SetObjectField(env, obj, keyField, GetEnvironment()->NewStringUTF(env, profile.key));
GetEnvironment()->SetIntField(env, obj, currentUidField, profile.current_uid);
if (useDefaultProfile) {
// no profile found, so just use default profile:
// don't allow root and use default profile!
LogDebug("use default profile for: %s, %d", key, uid);
// allow_su = false
// non root use default = true
GetEnvironment()->SetBooleanField(env, obj, allowSuField, false);
GetEnvironment()->SetBooleanField(env, obj, nonRootUseDefaultField, true);
return obj;
}
bool allowSu = profile.allow_su;
if (allowSu) {
GetEnvironment()->SetBooleanField(env, obj, rootUseDefaultField, (jboolean) profile.rp_config.use_default);
if (strlen(profile.rp_config.template_name) > 0) {
GetEnvironment()->SetObjectField(env, obj, rootTemplateField,
GetEnvironment()->NewStringUTF(env, profile.rp_config.template_name));
}
GetEnvironment()->SetIntField(env, obj, uidField, profile.rp_config.profile.uid);
GetEnvironment()->SetIntField(env, obj, gidField, profile.rp_config.profile.gid);
jobject groupList = GetEnvironment()->GetObjectField(env, obj, groupsField);
int groupCount = profile.rp_config.profile.groups_count;
if (groupCount > KSU_MAX_GROUPS) {
LogDebug("kernel group count too large: %d???", groupCount);
groupCount = KSU_MAX_GROUPS;
}
fillIntArray(env, groupList, profile.rp_config.profile.groups, groupCount);
jobject capList = GetEnvironment()->GetObjectField(env, obj, capabilitiesField);
for (int i = 0; i <= CAP_LAST_CAP; i++) {
if (profile.rp_config.profile.capabilities.effective & (1ULL << i)) {
addIntToList(env, capList, i);
}
}
GetEnvironment()->SetObjectField(env, obj, domainField,
GetEnvironment()->NewStringUTF(env, profile.rp_config.profile.selinux_domain));
GetEnvironment()->SetIntField(env, obj, namespacesField, profile.rp_config.profile.namespaces);
GetEnvironment()->SetBooleanField(env, obj, allowSuField, profile.allow_su);
} else {
GetEnvironment()->SetBooleanField(env, obj, nonRootUseDefaultField, profile.nrp_config.use_default);
GetEnvironment()->SetBooleanField(env, obj, umountModulesField, profile.nrp_config.profile.umount_modules);
}
return obj;
}
NativeBridge(setAppProfile, jboolean, jobject profile) {
jclass cls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$Profile");
jfieldID keyField = GetEnvironment()->GetFieldID(env, cls, "name", "Ljava/lang/String;");
jfieldID currentUidField = GetEnvironment()->GetFieldID(env, cls, "currentUid", "I");
jfieldID allowSuField = GetEnvironment()->GetFieldID(env, cls, "allowSu", "Z");
jfieldID rootUseDefaultField = GetEnvironment()->GetFieldID(env, cls, "rootUseDefault", "Z");
jfieldID rootTemplateField = GetEnvironment()->GetFieldID(env, cls, "rootTemplate", "Ljava/lang/String;");
jfieldID uidField = GetEnvironment()->GetFieldID(env, cls, "uid", "I");
jfieldID gidField = GetEnvironment()->GetFieldID(env, cls, "gid", "I");
jfieldID groupsField = GetEnvironment()->GetFieldID(env, cls, "groups", "Ljava/util/List;");
jfieldID capabilitiesField = GetEnvironment()->GetFieldID(env, cls, "capabilities", "Ljava/util/List;");
jfieldID domainField = GetEnvironment()->GetFieldID(env, cls, "context", "Ljava/lang/String;");
jfieldID namespacesField = GetEnvironment()->GetFieldID(env, cls, "namespace", "I");
jfieldID nonRootUseDefaultField = GetEnvironment()->GetFieldID(env, cls, "nonRootUseDefault", "Z");
jfieldID umountModulesField = GetEnvironment()->GetFieldID(env, cls, "umountModules", "Z");
jobject key = GetEnvironment()->GetObjectField(env, profile, keyField);
if (!key) {
return false;
}
if (GetEnvironment()->GetStringLength(env, (jstring) key) > KSU_MAX_PACKAGE_NAME) {
return false;
}
const char* cpkg = GetEnvironment()->GetStringUTFChars(env, (jstring) key, nullptr);
char p_key[KSU_MAX_PACKAGE_NAME] = { 0 };
strcpy(p_key, cpkg);
GetEnvironment()->ReleaseStringUTFChars(env, (jstring) key, cpkg);
jint currentUid = GetEnvironment()->GetIntField(env, profile, currentUidField);
jint uid = GetEnvironment()->GetIntField(env, profile, uidField);
jint gid = GetEnvironment()->GetIntField(env, profile, gidField);
jobject groups = GetEnvironment()->GetObjectField(env, profile, groupsField);
jobject capabilities = GetEnvironment()->GetObjectField(env, profile, capabilitiesField);
jobject domain = GetEnvironment()->GetObjectField(env, profile, domainField);
jboolean allowSu = GetEnvironment()->GetBooleanField(env, profile, allowSuField);
jboolean umountModules = GetEnvironment()->GetBooleanField(env, profile, umountModulesField);
struct app_profile p = { 0 };
p.version = KSU_APP_PROFILE_VER;
strcpy(p.key, p_key);
p.allow_su = allowSu;
p.current_uid = currentUid;
if (allowSu) {
p.rp_config.use_default = GetEnvironment()->GetBooleanField(env, profile, rootUseDefaultField);
jobject templateName = GetEnvironment()->GetObjectField(env, profile, rootTemplateField);
if (templateName) {
const char* ctemplateName = GetEnvironment()->GetStringUTFChars(env, (jstring) templateName, nullptr);
strcpy(p.rp_config.template_name, ctemplateName);
GetEnvironment()->ReleaseStringUTFChars(env, (jstring) templateName, ctemplateName);
}
p.rp_config.profile.uid = uid;
p.rp_config.profile.gid = gid;
int groups_count = getListSize(env, groups);
if (groups_count > KSU_MAX_GROUPS) {
LogDebug("groups count too large: %d", groups_count);
return false;
}
p.rp_config.profile.groups_count = groups_count;
fillArrayWithList(env, groups, p.rp_config.profile.groups, groups_count);
p.rp_config.profile.capabilities.effective = capListToBits(env, capabilities);
const char* cdomain = GetEnvironment()->GetStringUTFChars(env, (jstring) domain, nullptr);
strcpy(p.rp_config.profile.selinux_domain, cdomain);
GetEnvironment()->ReleaseStringUTFChars(env, (jstring) domain, cdomain);
p.rp_config.profile.namespaces = GetEnvironment()->GetIntField(env, profile, namespacesField);
} else {
p.nrp_config.use_default = GetEnvironment()->GetBooleanField(env, profile, nonRootUseDefaultField);
p.nrp_config.profile.umount_modules = umountModules;
}
return set_app_profile(&p);
}
NativeBridge(uidShouldUmount, jboolean, jint uid) {
return uid_should_umount(uid);
}
NativeBridgeNP(isSuEnabled, jboolean) {
return is_su_enabled();
}
NativeBridge(setSuEnabled, jboolean, jboolean enabled) {
return set_su_enabled(enabled);
}
NativeBridgeNP(isKernelUmountEnabled, jboolean) {
return is_kernel_umount_enabled();
}
NativeBridge(setKernelUmountEnabled, jboolean, jboolean enabled) {
return set_kernel_umount_enabled(enabled);
}
NativeBridgeNP(isEnhancedSecurityEnabled, jboolean) {
return is_enhanced_security_enabled();
}
NativeBridge(setEnhancedSecurityEnabled, jboolean, jboolean enabled) {
return set_enhanced_security_enabled(enabled);
}
NativeBridgeNP(isSuLogEnabled, jboolean) {
return is_sulog_enabled();
}
NativeBridge(setSuLogEnabled, jboolean, jboolean enabled) {
return set_sulog_enabled(enabled);
}
NativeBridge(getUserName, jstring, jint uid) {
struct passwd *pw = getpwuid((uid_t) uid);
if (pw && pw->pw_name && pw->pw_name[0] != '\0') {
return GetEnvironment()->NewStringUTF(env, pw->pw_name);
}
return NULL;
}
// Check if KPM is enabled
NativeBridgeNP(isKPMEnabled, jboolean) {
return is_KPM_enable();
}
// Get HOOK type
NativeBridgeNP(getHookType, jstring) {
char hook_type[32] = { 0 };
get_hook_type((char *) &hook_type);
return GetEnvironment()->NewStringUTF(env, hook_type);
}
// dynamic manager
NativeBridge(setDynamicManager, jboolean, jint size, jstring hash) {
if (!hash) {
LogDebug("setDynamicManager: hash is null");
return false;
}
const char* chash = GetEnvironment()->GetStringUTFChars(env, hash, nullptr);
bool result = set_dynamic_manager((unsigned int)size, chash);
GetEnvironment()->ReleaseStringUTFChars(env, hash, chash);
LogDebug("setDynamicManager: size=0x%x, result=%d", size, result);
return result;
}
NativeBridgeNP(getDynamicManager, jobject) {
struct dynamic_manager_user_config config;
bool result = get_dynamic_manager(&config);
if (!result) {
LogDebug("getDynamicManager: failed to get dynamic manager config");
return NULL;
}
jobject obj = CREATE_JAVA_OBJECT("com/sukisu/ultra/Natives$DynamicManagerConfig");
jclass cls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$DynamicManagerConfig");
SET_INT_FIELD(obj, cls, size, (jint)config.size);
SET_STRING_FIELD(obj, cls, hash, config.hash);
LogDebug("getDynamicManager: size=0x%x, hash=%.16s...", config.size, config.hash);
return obj;
}
NativeBridgeNP(clearDynamicManager, jboolean) {
bool result = clear_dynamic_manager();
LogDebug("clearDynamicManager: result=%d", result);
return result;
}
// Get a list of active managers
NativeBridgeNP(getManagersList, jobject) {
struct manager_list_info managerListInfo;
bool result = get_managers_list(&managerListInfo);
if (!result) {
LogDebug("getManagersList: failed to get active managers list");
return NULL;
}
jobject obj = CREATE_JAVA_OBJECT("com/sukisu/ultra/Natives$ManagersList");
jclass managerListCls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$ManagersList");
SET_INT_FIELD(obj, managerListCls, count, (jint)managerListInfo.count);
jobject managersList = CREATE_ARRAYLIST();
for (int i = 0; i < managerListInfo.count; i++) {
jobject managerInfo = CREATE_JAVA_OBJECT_WITH_PARAMS(
"com/sukisu/ultra/Natives$ManagerInfo",
"(II)V",
(jint)managerListInfo.managers[i].uid,
(jint)managerListInfo.managers[i].signature_index
);
ADD_TO_LIST(managersList, managerInfo);
}
SET_OBJECT_FIELD(obj, managerListCls, managers, managersList);
LogDebug("getManagersList: count=%d", managerListInfo.count);
return obj;
}
NativeBridge(verifyModuleSignature, jboolean, jstring modulePath) {
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
if (!modulePath) {
LogDebug("verifyModuleSignature: modulePath is null");
return false;
}
const char* cModulePath = GetEnvironment()->GetStringUTFChars(env, modulePath, nullptr);
bool result = verify_module_signature(cModulePath);
GetEnvironment()->ReleaseStringUTFChars(env, modulePath, cModulePath);
LogDebug("verifyModuleSignature: path=%s, result=%d", cModulePath, result);
return result;
#else
LogDebug("verifyModuleSignature: not supported on non-ARM architecture");
return false;
#endif
}
NativeBridgeNP(isUidScannerEnabled, jboolean) {
return is_uid_scanner_enabled();
}
NativeBridge(setUidScannerEnabled, jboolean, jboolean enabled) {
return set_uid_scanner_enabled(enabled);
}
NativeBridgeNP(clearUidScannerEnvironment, jboolean) {
return clear_uid_scanner_environment();
}

View file

@ -0,0 +1,406 @@
//
// Created by weishu on 2022/12/9.
//
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <android/log.h>
#include <dirent.h>
#include <stdlib.h>
#include <limits.h>
#include "prelude.h"
#include "ksu.h"
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
// Zako extern declarations
#define ZAKO_ESV_IMPORTANT_ERROR 1 << 31
extern int zako_sys_file_open(const char* path);
extern uint32_t zako_file_verify_esig(int fd, uint32_t flags);
extern const char* zako_file_verrcidx2str(uint8_t index);
#endif // __aarch64__ || _M_ARM64 || __arm__ || _M_ARM
static int fd = -1;
static inline int scan_driver_fd() {
const char *kName = "[ksu_driver]";
DIR *fd_dir = opendir("/proc/self/fd");
if (!fd_dir) {
return -1;
}
int found = -1;
struct dirent *de;
char path[64];
char target[PATH_MAX];
while ((de = readdir(fd_dir)) != NULL) {
if (de->d_name[0] == '.') {
continue;
}
char *endptr = nullptr;
long fd_long = strtol(de->d_name, &endptr, 10);
if (!de->d_name[0] || *endptr != '\0' || fd_long < 0 || fd_long > INT_MAX) {
continue;
}
snprintf(path, sizeof(path), "/proc/self/fd/%s", de->d_name);
ssize_t n = readlink(path, target, sizeof(target) - 1);
if (n < 0) {
continue;
}
target[n] = '\0';
const char *base = strrchr(target, '/');
base = base ? base + 1 : target;
if (strstr(base, kName)) {
found = (int)fd_long;
break;
}
}
closedir(fd_dir);
return found;
}
static int ksuctl(unsigned long op, void* arg) {
if (fd < 0) {
fd = scan_driver_fd();
}
return ioctl(fd, op, arg);
}
static struct ksu_get_info_cmd g_version = {0};
struct ksu_get_info_cmd get_info() {
if (!g_version.version) {
ksuctl(KSU_IOCTL_GET_INFO, &g_version);
}
return g_version;
}
uint32_t get_version() {
auto info = get_info();
return info.version;
}
bool get_allow_list(struct ksu_get_allow_list_cmd *cmd) {
if (ksuctl(KSU_IOCTL_GET_ALLOW_LIST, cmd) == 0) {
return true;
}
// fallback to legacy
int size = 0;
int uids[1024];
if (legacy_get_allow_list(uids, &size)) {
cmd->count = size;
memcpy(cmd->uids, uids, sizeof(int) * size);
return true;
}
return false;
}
bool is_safe_mode() {
struct ksu_check_safemode_cmd cmd = {};
if (ksuctl(KSU_IOCTL_CHECK_SAFEMODE, &cmd) == 0) {
return cmd.in_safe_mode;
}
// fallback
return legacy_is_safe_mode();
}
bool is_lkm_mode() {
auto info = get_info();
if (info.version > 0) {
return (info.flags & 0x1) != 0;
}
// Legacy Compatible
return (legacy_get_info().flags & 0x1) != 0;
}
bool is_manager() {
auto info = get_info();
if (info.version > 0) {
return (info.flags & 0x2) != 0;
}
// Legacy Compatible
return legacy_get_info().version > 0;
}
bool uid_should_umount(int uid) {
struct ksu_uid_should_umount_cmd cmd = {};
cmd.uid = uid;
if (ksuctl(KSU_IOCTL_UID_SHOULD_UMOUNT, &cmd) == 0) {
return cmd.should_umount;
}
return legacy_uid_should_umount(uid);
}
bool set_app_profile(const struct app_profile *profile) {
struct ksu_set_app_profile_cmd cmd = {};
cmd.profile = *profile;
if (ksuctl(KSU_IOCTL_SET_APP_PROFILE, &cmd) == 0) {
return true;
}
return legacy_set_app_profile(profile);
}
int get_app_profile(struct app_profile *profile) {
struct ksu_get_app_profile_cmd cmd = {.profile = *profile};
int ret = ksuctl(KSU_IOCTL_GET_APP_PROFILE, &cmd);
if (ret == 0) {
*profile = cmd.profile;
return 0;
}
return legacy_get_app_profile(profile->key, profile) ? 0 : -1;
}
bool set_su_enabled(bool enabled) {
struct ksu_set_feature_cmd cmd = {};
cmd.feature_id = KSU_FEATURE_SU_COMPAT;
cmd.value = enabled ? 1 : 0;
if (ksuctl(KSU_IOCTL_SET_FEATURE, &cmd) == 0) {
return true;
}
return legacy_set_su_enabled(enabled);
}
bool is_su_enabled() {
struct ksu_get_feature_cmd cmd = {};
cmd.feature_id = KSU_FEATURE_SU_COMPAT;
if (ksuctl(KSU_IOCTL_GET_FEATURE, &cmd) == 0 && cmd.supported) {
return cmd.value != 0;
}
return legacy_is_su_enabled();
}
static inline bool get_feature(uint32_t feature_id, uint64_t *out_value, bool *out_supported) {
struct ksu_get_feature_cmd cmd = {};
cmd.feature_id = feature_id;
if (ksuctl(KSU_IOCTL_GET_FEATURE, &cmd) != 0) {
return false;
}
if (out_value) *out_value = cmd.value;
if (out_supported) *out_supported = cmd.supported;
return true;
}
static inline bool set_feature(uint32_t feature_id, uint64_t value) {
struct ksu_set_feature_cmd cmd = {};
cmd.feature_id = feature_id;
cmd.value = value;
return ksuctl(KSU_IOCTL_SET_FEATURE, &cmd) == 0;
}
bool set_kernel_umount_enabled(bool enabled) {
return set_feature(KSU_FEATURE_KERNEL_UMOUNT, enabled ? 1 : 0);
}
bool is_kernel_umount_enabled() {
uint64_t value = 0;
bool supported = false;
if (!get_feature(KSU_FEATURE_KERNEL_UMOUNT, &value, &supported)) {
return false;
}
if (!supported) {
return false;
}
return value != 0;
}
bool set_enhanced_security_enabled(bool enabled) {
return set_feature(KSU_FEATURE_ENHANCED_SECURITY, enabled ? 1 : 0);
}
bool is_enhanced_security_enabled() {
uint64_t value = 0;
bool supported = false;
if (!get_feature(KSU_FEATURE_ENHANCED_SECURITY, &value, &supported)) {
return false;
}
if (!supported) {
return false;
}
return value != 0;
}
bool set_sulog_enabled(bool enabled) {
return set_feature(KSU_FEATURE_SULOG, enabled ? 1 : 0);
}
bool is_sulog_enabled() {
uint64_t value = 0;
bool supported = false;
if (!get_feature(KSU_FEATURE_SULOG, &value, &supported)) {
return false;
}
if (!supported) {
return false;
}
return value != 0;
}
void get_full_version(char* buff) {
struct ksu_get_full_version_cmd cmd = {0};
if (ksuctl(KSU_IOCTL_GET_FULL_VERSION, &cmd) == 0) {
strncpy(buff, cmd.version_full, KSU_FULL_VERSION_STRING - 1);
buff[KSU_FULL_VERSION_STRING - 1] = '\0';
} else {
return legacy_get_full_version(buff);
}
}
bool is_KPM_enable(void) {
struct ksu_enable_kpm_cmd cmd = {};
if (ksuctl(KSU_IOCTL_ENABLE_KPM, &cmd) == 0 && cmd.enabled) {
return true;
}
return legacy_is_KPM_enable();
}
void get_hook_type(char *buff) {
struct ksu_hook_type_cmd cmd = {0};
if (ksuctl(KSU_IOCTL_HOOK_TYPE, &cmd) == 0) {
strncpy(buff, cmd.hook_type, 32 - 1);
buff[32 - 1] = '\0';
} else {
legacy_get_hook_type(buff, 32);
}
}
bool set_dynamic_manager(unsigned int size, const char *hash)
{
struct ksu_dynamic_manager_cmd cmd = {0};
cmd.config.operation = DYNAMIC_MANAGER_OP_SET;
cmd.config.size = size;
strlcpy(cmd.config.hash, hash, sizeof(cmd.config.hash));
return ksuctl(KSU_IOCTL_DYNAMIC_MANAGER, &cmd) == 0;
}
bool get_dynamic_manager(struct dynamic_manager_user_config *cfg)
{
if (!cfg)
return false;
struct ksu_dynamic_manager_cmd cmd = {0};
cmd.config.operation = DYNAMIC_MANAGER_OP_GET;
if (ksuctl(KSU_IOCTL_DYNAMIC_MANAGER, &cmd) != 0)
return false;
*cfg = cmd.config;
return true;
}
bool clear_dynamic_manager(void)
{
struct ksu_dynamic_manager_cmd cmd = {0};
cmd.config.operation = DYNAMIC_MANAGER_OP_CLEAR;
return ksuctl(KSU_IOCTL_DYNAMIC_MANAGER, &cmd) == 0;
}
bool get_managers_list(struct manager_list_info *info)
{
if (!info)
return false;
struct ksu_get_managers_cmd cmd = {0};
if (ksuctl(KSU_IOCTL_GET_MANAGERS, &cmd) != 0)
return false;
*info = cmd.manager_info;
return true;
}
bool is_uid_scanner_enabled(void)
{
bool status = false;
struct ksu_enable_uid_scanner_cmd cmd = {
.operation = UID_SCANNER_OP_GET_STATUS,
.status_ptr = (__u64)(uintptr_t)&status
};
return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd) == 0 != 0 && status;
}
bool set_uid_scanner_enabled(bool enabled)
{
struct ksu_enable_uid_scanner_cmd cmd = {
.operation = UID_SCANNER_OP_TOGGLE,
.enabled = enabled
};
return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd);
}
bool clear_uid_scanner_environment(void)
{
struct ksu_enable_uid_scanner_cmd cmd = {
.operation = UID_SCANNER_OP_CLEAR_ENV
};
return ksuctl(KSU_IOCTL_ENABLE_UID_SCANNER, &cmd);
}
bool verify_module_signature(const char* input) {
#if defined(__aarch64__) || defined(_M_ARM64) || defined(__arm__) || defined(_M_ARM)
if (input == NULL) {
LogDebug("verify_module_signature: input path is null");
return false;
}
int file_fd = zako_sys_file_open(input);
if (file_fd < 0) {
LogDebug("verify_module_signature: failed to open file: %s", input);
return false;
}
uint32_t results = zako_file_verify_esig(file_fd, 0);
if (results != 0) {
/* If important error occured, verification process should
be considered as failed due to unexpected modification
potentially happened. */
if ((results & ZAKO_ESV_IMPORTANT_ERROR) != 0) {
LogDebug("verify_module_signature: Verification failed! (important error)");
} else {
/* This is for manager that doesn't want to do certificate checks */
LogDebug("verify_module_signature: Verification partially passed");
}
} else {
LogDebug("verify_module_signature: Verification passed!");
goto exit;
}
/* Go through all bit fields */
for (size_t i = 0; i < sizeof(uint32_t) * 8; i++) {
if ((results & (1 << i)) == 0) {
continue;
}
/* Convert error bit field index into human readable string */
const char* message = zako_file_verrcidx2str((uint8_t)i);
// Error message: message
if (message != NULL) {
LogDebug("verify_module_signature: Error bit %zu: %s", i, message);
} else {
LogDebug("verify_module_signature: Error bit %zu: Unknown error", i);
}
}
exit:
close(file_fd);
LogDebug("verify_module_signature: path=%s, results=0x%x, success=%s",
input, results, (results == 0) ? "true" : "false");
return results == 0;
#else
LogDebug("verify_module_signature: not supported on non-ARM architecture, path=%s", input ? input : "null");
return false;
#endif
}

View file

@ -0,0 +1,300 @@
//
// Created by weishu on 2022/12/9.
//
#ifndef KERNELSU_KSU_H
#define KERNELSU_KSU_H
#include "prelude.h"
#include <stdint.h>
#include <sys/types.h>
#include <sys/ioctl.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#define KSU_FULL_VERSION_STRING 255
uint32_t get_version();
bool uid_should_umount(int uid);
bool is_safe_mode();
bool is_lkm_mode();
bool is_manager();
void get_full_version(char* buff);
#define KSU_APP_PROFILE_VER 2
#define KSU_MAX_PACKAGE_NAME 256
// NGROUPS_MAX for Linux is 65535 generally, but we only supports 32 groups.
#define KSU_MAX_GROUPS 32
#define KSU_SELINUX_DOMAIN 64
#define DYNAMIC_MANAGER_OP_SET 0
#define DYNAMIC_MANAGER_OP_GET 1
#define DYNAMIC_MANAGER_OP_CLEAR 2
#define UID_SCANNER_OP_GET_STATUS 0
#define UID_SCANNER_OP_TOGGLE 1
#define UID_SCANNER_OP_CLEAR_ENV 2
struct dynamic_manager_user_config {
unsigned int operation;
unsigned int size;
char hash[65];
};
struct root_profile {
int32_t uid;
int32_t gid;
int32_t groups_count;
int32_t groups[KSU_MAX_GROUPS];
// kernel_cap_t is u32[2] for capabilities v3
struct {
uint64_t effective;
uint64_t permitted;
uint64_t inheritable;
} capabilities;
char selinux_domain[KSU_SELINUX_DOMAIN];
int32_t namespaces;
};
struct non_root_profile {
bool umount_modules;
};
struct app_profile {
// It may be utilized for backward compatibility, although we have never explicitly made any promises regarding this.
uint32_t version;
// this is usually the package of the app, but can be other value for special apps
char key[KSU_MAX_PACKAGE_NAME];
int32_t current_uid;
bool allow_su;
union {
struct {
bool use_default;
char template_name[KSU_MAX_PACKAGE_NAME];
struct root_profile profile;
} rp_config;
struct {
bool use_default;
struct non_root_profile profile;
} nrp_config;
};
};
struct manager_list_info {
int count;
struct {
uid_t uid;
int signature_index;
} managers[2];
};
bool set_app_profile(const struct app_profile* profile);
int get_app_profile(struct app_profile* profile);
bool is_KPM_enable();
void get_hook_type(char* hook_type);
bool set_dynamic_manager(unsigned int size, const char* hash);
bool get_dynamic_manager(struct dynamic_manager_user_config* config);
bool clear_dynamic_manager();
bool get_managers_list(struct manager_list_info* info);
bool verify_module_signature(const char* input);
bool is_uid_scanner_enabled();
bool set_uid_scanner_enabled(bool enabled);
bool clear_uid_scanner_environment();
// Feature IDs
enum ksu_feature_id {
KSU_FEATURE_SU_COMPAT = 0,
KSU_FEATURE_KERNEL_UMOUNT = 1,
KSU_FEATURE_ENHANCED_SECURITY = 2,
KSU_FEATURE_SULOG = 3,
};
// Generic feature API
struct ksu_get_feature_cmd {
uint32_t feature_id; // Input: feature ID
uint64_t value; // Output: feature value/state
uint8_t supported; // Output: whether the feature is supported
};
struct ksu_set_feature_cmd {
uint32_t feature_id; // Input: feature ID
uint64_t value; // Input: feature value/state to set
};
struct ksu_become_daemon_cmd {
uint8_t token[65]; // Input: daemon token (null-terminated)
};
struct ksu_get_info_cmd {
uint32_t version; // Output: KERNEL_SU_VERSION
uint32_t flags; // Output: flags (bit 0: MODULE mode)
uint32_t features; // Output: max feature ID supported (KSU_FEATURE_MAX)
};
struct ksu_report_event_cmd {
uint32_t event; // Input: EVENT_POST_FS_DATA, EVENT_BOOT_COMPLETED, etc.
};
struct ksu_set_sepolicy_cmd {
uint64_t cmd; // Input: sepolicy command
uint64_t arg; // Input: sepolicy argument pointer
};
struct ksu_check_safemode_cmd {
uint8_t in_safe_mode; // Output: true if in safe mode, false otherwise
};
struct ksu_get_allow_list_cmd {
uint32_t uids[128]; // Output: array of allowed/denied UIDs
uint32_t count; // Output: number of UIDs in array
uint8_t allow; // Input: true for allow list, false for deny list
};
struct ksu_uid_granted_root_cmd {
uint32_t uid; // Input: target UID to check
uint8_t granted; // Output: true if granted, false otherwise
};
struct ksu_uid_should_umount_cmd {
uint32_t uid; // Input: target UID to check
uint8_t should_umount; // Output: true if should umount, false otherwise
};
struct ksu_get_manager_uid_cmd {
uint32_t uid; // Output: manager UID
};
struct ksu_set_manager_uid_cmd {
uint32_t uid; // Input: new manager UID
};
struct ksu_get_app_profile_cmd {
struct app_profile profile; // Input/Output: app profile structure
};
struct ksu_set_app_profile_cmd {
struct app_profile profile; // Input: app profile structure
};
// Su compat
bool set_su_enabled(bool enabled);
bool is_su_enabled();
// Kernel umount
bool set_kernel_umount_enabled(bool enabled);
bool is_kernel_umount_enabled();
// Enhanced security
bool set_enhanced_security_enabled(bool enabled);
bool is_enhanced_security_enabled();
// Su log
bool set_sulog_enabled(bool enabled);
bool is_sulog_enabled();
// Other command structures
struct ksu_get_full_version_cmd {
char version_full[KSU_FULL_VERSION_STRING]; // Output: full version string
};
struct ksu_hook_type_cmd {
char hook_type[32]; // Output: hook type string
};
struct ksu_enable_kpm_cmd {
uint8_t enabled; // Output: true if KPM is enabled
};
struct ksu_dynamic_manager_cmd {
struct dynamic_manager_user_config config; // Input/Output: dynamic manager config
};
struct ksu_get_managers_cmd {
struct manager_list_info manager_info; // Output: manager list information
};
struct ksu_enable_uid_scanner_cmd {
uint32_t operation; // Input: operation type (UID_SCANNER_OP_GET_STATUS, UID_SCANNER_OP_TOGGLE, UID_SCANNER_OP_CLEAR_ENV)
uint32_t enabled; // Input: enable or disable (for UID_SCANNER_OP_TOGGLE)
uint64_t status_ptr; // Input: pointer to store status (for UID_SCANNER_OP_GET_STATUS)
};
// IOCTL command definitions
#define KSU_IOCTL_GRANT_ROOT _IOC(_IOC_NONE, 'K', 1, 0)
#define KSU_IOCTL_GET_INFO _IOC(_IOC_READ, 'K', 2, 0)
#define KSU_IOCTL_REPORT_EVENT _IOC(_IOC_WRITE, 'K', 3, 0)
#define KSU_IOCTL_SET_SEPOLICY _IOC(_IOC_READ|_IOC_WRITE, 'K', 4, 0)
#define KSU_IOCTL_CHECK_SAFEMODE _IOC(_IOC_READ, 'K', 5, 0)
#define KSU_IOCTL_GET_ALLOW_LIST _IOC(_IOC_READ|_IOC_WRITE, 'K', 6, 0)
#define KSU_IOCTL_GET_DENY_LIST _IOC(_IOC_READ|_IOC_WRITE, 'K', 7, 0)
#define KSU_IOCTL_UID_GRANTED_ROOT _IOC(_IOC_READ|_IOC_WRITE, 'K', 8, 0)
#define KSU_IOCTL_UID_SHOULD_UMOUNT _IOC(_IOC_READ|_IOC_WRITE, 'K', 9, 0)
#define KSU_IOCTL_GET_MANAGER_UID _IOC(_IOC_READ, 'K', 10, 0)
#define KSU_IOCTL_GET_APP_PROFILE _IOC(_IOC_READ|_IOC_WRITE, 'K', 11, 0)
#define KSU_IOCTL_SET_APP_PROFILE _IOC(_IOC_WRITE, 'K', 12, 0)
#define KSU_IOCTL_GET_FEATURE _IOC(_IOC_READ|_IOC_WRITE, 'K', 13, 0)
#define KSU_IOCTL_SET_FEATURE _IOC(_IOC_WRITE, 'K', 14, 0)
// Other IOCTL command definitions
#define KSU_IOCTL_GET_FULL_VERSION _IOC(_IOC_READ, 'K', 100, 0)
#define KSU_IOCTL_HOOK_TYPE _IOC(_IOC_READ, 'K', 101, 0)
#define KSU_IOCTL_ENABLE_KPM _IOC(_IOC_READ, 'K', 102, 0)
#define KSU_IOCTL_DYNAMIC_MANAGER _IOC(_IOC_READ|_IOC_WRITE, 'K', 103, 0)
#define KSU_IOCTL_GET_MANAGERS _IOC(_IOC_READ|_IOC_WRITE, 'K', 104, 0)
#define KSU_IOCTL_ENABLE_UID_SCANNER _IOC(_IOC_READ|_IOC_WRITE, 'K', 105, 0)
bool get_allow_list(struct ksu_get_allow_list_cmd *);
// Legacy Compatible
struct ksu_version_info legacy_get_info();
struct ksu_version_info {
int32_t version;
int32_t flags;
};
bool legacy_get_allow_list(int *uids, int *size);
bool legacy_is_safe_mode();
bool legacy_uid_should_umount(int uid);
bool legacy_set_app_profile(const struct app_profile* profile);
bool legacy_get_app_profile(char* key, struct app_profile* profile);
bool legacy_set_su_enabled(bool enabled);
bool legacy_is_su_enabled();
bool legacy_is_KPM_enable();
bool legacy_get_hook_type(char* hook_type, size_t size);
void legacy_get_full_version(char* buff);
bool legacy_set_dynamic_manager(unsigned int size, const char* hash);
bool legacy_get_dynamic_manager(struct dynamic_manager_user_config* config);
bool legacy_clear_dynamic_manager();
bool legacy_get_managers_list(struct manager_list_info* info);
bool legacy_is_uid_scanner_enabled();
bool legacy_set_uid_scanner_enabled(bool enabled);
bool legacy_clear_uid_scanner_environment();
#endif //KERNELSU_KSU_H

View file

@ -0,0 +1,163 @@
//
// Created by shirkneko on 2025/11/3.
//
// Legacy Compatible
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <android/log.h>
#include <dirent.h>
#include <stdlib.h>
#include <limits.h>
#include "prelude.h"
#include "ksu.h"
#define KERNEL_SU_OPTION 0xDEADBEEF
#define CMD_GRANT_ROOT 0
#define CMD_BECOME_MANAGER 1
#define CMD_GET_VERSION 2
#define CMD_ALLOW_SU 3
#define CMD_DENY_SU 4
#define CMD_GET_SU_LIST 5
#define CMD_GET_DENY_LIST 6
#define CMD_CHECK_SAFEMODE 9
#define CMD_GET_APP_PROFILE 10
#define CMD_SET_APP_PROFILE 11
#define CMD_IS_UID_GRANTED_ROOT 12
#define CMD_IS_UID_SHOULD_UMOUNT 13
#define CMD_IS_SU_ENABLED 14
#define CMD_ENABLE_SU 15
#define CMD_GET_VERSION_FULL 0xC0FFEE1A
#define CMD_ENABLE_KPM 100
#define CMD_HOOK_TYPE 101
#define CMD_DYNAMIC_MANAGER 103
#define CMD_GET_MANAGERS 104
#define CMD_ENABLE_UID_SCANNER 105
static bool ksuctl(int cmd, void* arg1, void* arg2) {
int32_t result = 0;
int32_t rtn = prctl(KERNEL_SU_OPTION, cmd, arg1, arg2, &result);
return result == KERNEL_SU_OPTION && rtn == -1;
}
struct ksu_version_info legacy_get_info()
{
int32_t version = -1;
int32_t flags = 0;
ksuctl(CMD_GET_VERSION, &version, &flags);
return (struct ksu_version_info){version, flags};
}
bool legacy_get_allow_list(int *uids, int *size) {
return ksuctl(CMD_GET_SU_LIST, uids, size);
}
bool legacy_is_safe_mode() {
return ksuctl(CMD_CHECK_SAFEMODE, NULL, NULL);
}
bool legacy_uid_should_umount(int uid) {
int should;
return ksuctl(CMD_IS_UID_SHOULD_UMOUNT, (void*) ((size_t) uid), &should) && should;
}
bool legacy_set_app_profile(const struct app_profile* profile) {
return ksuctl(CMD_SET_APP_PROFILE, (void*) profile, NULL);
}
bool legacy_get_app_profile(char* key, struct app_profile* profile) {
return ksuctl(CMD_GET_APP_PROFILE, profile, NULL);
}
bool legacy_set_su_enabled(bool enabled) {
return ksuctl(CMD_ENABLE_SU, (void*) enabled, NULL);
}
bool legacy_is_su_enabled() {
int enabled = true;
// if ksuctl failed, we assume su is enabled, and it cannot be disabled.
ksuctl(CMD_IS_SU_ENABLED, &enabled, NULL);
return enabled;
}
bool legacy_is_KPM_enable() {
int enabled = false;
ksuctl(CMD_ENABLE_KPM, &enabled, NULL);
return enabled;
}
bool legacy_get_hook_type(char* hook_type, size_t size) {
if (hook_type == NULL || size == 0) {
return false;
}
static char cached_hook_type[16] = {0};
if (cached_hook_type[0] == '\0') {
if (!ksuctl(CMD_HOOK_TYPE, cached_hook_type, NULL)) {
strcpy(cached_hook_type, "Unknown");
}
}
strncpy(hook_type, cached_hook_type, size - 1);
hook_type[size - 1] = '\0';
return true;
}
void legacy_get_full_version(char* buff) {
ksuctl(CMD_GET_VERSION_FULL, buff, NULL);
}
bool legacy_set_dynamic_manager(unsigned int size, const char* hash) {
if (hash == NULL) {
return false;
}
struct dynamic_manager_user_config config;
config.operation = DYNAMIC_MANAGER_OP_SET;
config.size = size;
strncpy(config.hash, hash, sizeof(config.hash) - 1);
config.hash[sizeof(config.hash) - 1] = '\0';
return ksuctl(CMD_DYNAMIC_MANAGER, &config, NULL);
}
bool legacy_get_dynamic_manager(struct dynamic_manager_user_config* config) {
if (config == NULL) {
return false;
}
config->operation = DYNAMIC_MANAGER_OP_GET;
return ksuctl(CMD_DYNAMIC_MANAGER, config, NULL);
}
bool legacy_clear_dynamic_manager() {
struct dynamic_manager_user_config config;
config.operation = DYNAMIC_MANAGER_OP_CLEAR;
return ksuctl(CMD_DYNAMIC_MANAGER, &config, NULL);
}
bool legacy_get_managers_list(struct manager_list_info* info) {
if (info == NULL) {
return false;
}
return ksuctl(CMD_GET_MANAGERS, info, NULL);
}
bool legacy_is_uid_scanner_enabled() {
bool status = false;
ksuctl(CMD_ENABLE_UID_SCANNER, (void*)0, &status);
return status;
}
bool legacy_set_uid_scanner_enabled(bool enabled) {
return ksuctl(CMD_ENABLE_UID_SCANNER, (void*)1, (void*)enabled);
}
bool legacy_clear_uid_scanner_environment() {
return ksuctl(CMD_ENABLE_UID_SCANNER, (void*)2, NULL);
}

View file

@ -0,0 +1,70 @@
#ifndef KERNELSU_PRELUDE_H
#define KERNELSU_PRELUDE_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#include <jni.h>
#include <android/log.h>
#define GetEnvironment() (*env)
#define NativeBridge(fn, rtn, ...) JNIEXPORT rtn JNICALL Java_com_sukisu_ultra_Natives_##fn(JNIEnv* env, jclass clazz, __VA_ARGS__)
#define NativeBridgeNP(fn, rtn) JNIEXPORT rtn JNICALL Java_com_sukisu_ultra_Natives_##fn(JNIEnv* env, jclass clazz)
// Macros to simplify field setup
#define SET_BOOLEAN_FIELD(obj, cls, fieldName, value) do { \
jfieldID field = GetEnvironment()->GetFieldID(env, cls, #fieldName, "Z"); \
GetEnvironment()->SetBooleanField(env, obj, field, value); \
} while(0)
#define SET_INT_FIELD(obj, cls, fieldName, value) do { \
jfieldID field = GetEnvironment()->GetFieldID(env, cls, #fieldName, "I"); \
GetEnvironment()->SetIntField(env, obj, field, value); \
} while(0)
#define SET_STRING_FIELD(obj, cls, fieldName, value) do { \
jfieldID field = GetEnvironment()->GetFieldID(env, cls, #fieldName, "Ljava/lang/String;"); \
GetEnvironment()->SetObjectField(env, obj, field, GetEnvironment()->NewStringUTF(env, value)); \
} while(0)
#define SET_OBJECT_FIELD(obj, cls, fieldName, value) do { \
jfieldID field = GetEnvironment()->GetFieldID(env, cls, #fieldName, "Ljava/util/List;"); \
GetEnvironment()->SetObjectField(env, obj, field, value); \
} while(0)
// Macros for creating Java objects
#define CREATE_JAVA_OBJECT(className) ({ \
jclass cls = GetEnvironment()->FindClass(env, className); \
jmethodID constructor = GetEnvironment()->GetMethodID(env, cls, "<init>", "()V"); \
GetEnvironment()->NewObject(env, cls, constructor); \
})
// Macros for creating ArrayList
#define CREATE_ARRAYLIST() ({ \
jclass arrayListCls = GetEnvironment()->FindClass(env, "java/util/ArrayList"); \
jmethodID constructor = GetEnvironment()->GetMethodID(env, arrayListCls, "<init>", "()V"); \
GetEnvironment()->NewObject(env, arrayListCls, constructor); \
})
// Macros for adding elements to an ArrayList
#define ADD_TO_LIST(list, item) do { \
jclass cls = GetEnvironment()->GetObjectClass(env, list); \
jmethodID addMethod = GetEnvironment()->GetMethodID(env, cls, "add", "(Ljava/lang/Object;)Z"); \
GetEnvironment()->CallBooleanMethod(env, list, addMethod, item); \
} while(0)
// Macros for creating Java objects with parameter constructors
#define CREATE_JAVA_OBJECT_WITH_PARAMS(className, signature, ...) ({ \
jclass cls = GetEnvironment()->FindClass(env, className); \
jmethodID constructor = GetEnvironment()->GetMethodID(env, cls, "<init>", signature); \
GetEnvironment()->NewObject(env, cls, constructor, __VA_ARGS__); \
})
#ifdef NDEBUG
#define LogDebug(...) (void)0
#else
#define LogDebug(...) __android_log_print(ANDROID_LOG_DEBUG, "KernelSU", __VA_ARGS__)
#endif
#endif

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View file

@ -0,0 +1,72 @@
package com.sukisu.ultra
import android.app.Application
import android.system.Os
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import coil.Coil
import coil.ImageLoader
import com.dergoogler.mmrl.platform.Platform
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
import java.util.Locale
lateinit var ksuApp: KernelSUApplication
class KernelSUApplication : Application(), ViewModelStoreOwner {
lateinit var okhttpClient: OkHttpClient
private val appViewModelStore by lazy { ViewModelStore() }
override fun onCreate() {
super.onCreate()
ksuApp = this
// For faster response when first entering superuser or webui activity
val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java]
CoroutineScope(Dispatchers.Main).launch {
superUserViewModel.fetchAppList()
}
Platform.setHiddenApiExemptions()
val context = this
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
Coil.setImageLoader(
ImageLoader.Builder(context)
.components {
add(AppIconKeyer())
add(AppIconFetcher.Factory(iconSize, false, context))
}
.build()
)
val webroot = File(dataDir, "webroot")
if (!webroot.exists()) {
webroot.mkdir()
}
// Provide working env for rust's temp_dir()
Os.setenv("TMPDIR", cacheDir.absolutePath, true)
okhttpClient =
OkHttpClient.Builder().cache(Cache(File(cacheDir, "okhttp"), 10 * 1024 * 1024))
.addInterceptor { block ->
block.proceed(
block.request().newBuilder()
.header("User-Agent", "SukiSU/${BuildConfig.VERSION_CODE}")
.header("Accept-Language", Locale.getDefault().toLanguageTag()).build()
)
}.build()
}
override val viewModelStore: ViewModelStore
get() = appViewModelStore
}

View file

@ -0,0 +1,32 @@
package com.sukisu.ultra
import android.system.Os
/**
* @author weishu
* @date 2022/12/10.
*/
data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) {
override fun toString(): String = "$major.$patchLevel.$subLevel"
fun isGKI(): Boolean = when {
major > 5 -> true
major == 5 && patchLevel >= 10 -> true
else -> false
}
}
fun parseKernelVersion(version: String): KernelVersion {
val find = "(\\d+)\\.(\\d+)\\.(\\d+)".toRegex().find(version)
return if (find != null) {
KernelVersion(find.groupValues[1].toInt(), find.groupValues[2].toInt(), find.groupValues[3].toInt())
} else {
KernelVersion(-1, -1, -1)
}
}
fun getKernelVersion(): KernelVersion {
Os.uname().release.let {
return parseKernelVersion(it)
}
}

View file

@ -0,0 +1,281 @@
package com.sukisu.ultra
import android.os.Parcelable
import androidx.annotation.Keep
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
/**
* @author weishu
* @date 2022/12/8.
*/
object Natives {
// minimal supported kernel version
// 10915: allowlist breaking change, add app profile
// 10931: app profile struct add 'version' field
// 10946: add capabilities
// 10977: change groups_count and groups to avoid overflow write
// 11071: Fix the issue of failing to set a custom SELinux type.
// 12143: breaking: new supercall impl
const val MINIMAL_SUPPORTED_KERNEL = 12143
// 12040: Support disable sucompat mode
const val KERNEL_SU_DOMAIN = "u:r:su:s0"
const val MINIMAL_SUPPORTED_KERNEL_FULL = "v3.1.8"
const val MINIMAL_SUPPORTED_KPM = 12800
const val MINIMAL_SUPPORTED_DYNAMIC_MANAGER = 13215
const val MINIMAL_SUPPORTED_UID_SCANNER = 13347
const val MINIMAL_NEW_IOCTL_KERNEL = 13490
const val ROOT_UID = 0
const val ROOT_GID = 0
// 获取完整版本号
external fun getFullVersion(): String
fun isVersionLessThan(v1Full: String, v2Full: String): Boolean {
fun extractVersionParts(version: String): List<Int> {
val match = Regex("""v\d+(\.\d+)*""").find(version)
val simpleVersion = match?.value ?: version
return simpleVersion.trimStart('v').split('.').map { it.toIntOrNull() ?: 0 }
}
val v1Parts = extractVersionParts(v1Full)
val v2Parts = extractVersionParts(v2Full)
val maxLength = maxOf(v1Parts.size, v2Parts.size)
for (i in 0 until maxLength) {
val num1 = v1Parts.getOrElse(i) { 0 }
val num2 = v2Parts.getOrElse(i) { 0 }
if (num1 != num2) return num1 < num2
}
return false
}
fun getSimpleVersionFull(): String = getFullVersion().let { version ->
Regex("""v\d+(\.\d+)*""").find(version)?.value ?: version
}
init {
System.loadLibrary("zakosign")
System.loadLibrary("kernelsu")
}
val version: Int
external get
// get the uid list of allowed su processes.
val allowList: IntArray
external get
val isSafeMode: Boolean
external get
val isLkmMode: Boolean
external get
val isManager: Boolean
external get
external fun uidShouldUmount(uid: Int): Boolean
/**
* Get the profile of the given package.
* @param key usually the package name
* @return return null if failed.
*/
external fun getAppProfile(key: String?, uid: Int): Profile
external fun setAppProfile(profile: Profile?): Boolean
/**
* `su` compat mode can be disabled temporarily.
* 0: disabled
* 1: enabled
* negative : error
*/
external fun isSuEnabled(): Boolean
external fun setSuEnabled(enabled: Boolean): Boolean
/**
* Kernel module umount can be disabled temporarily.
* 0: disabled
* 1: enabled
* negative : error
*/
external fun isKernelUmountEnabled(): Boolean
external fun setKernelUmountEnabled(enabled: Boolean): Boolean
/**
* Enhanced security can be enabled/disabled.
* 0: disabled
* 1: enabled
* negative : error
*/
external fun isEnhancedSecurityEnabled(): Boolean
external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean
/**
* Su Log can be enabled/disabled.
* 0: disabled
* 1: enabled
* negative : error
*/
external fun isSuLogEnabled(): Boolean
external fun setSuLogEnabled(enabled: Boolean): Boolean
external fun isKPMEnabled(): Boolean
external fun getHookType(): String
/**
* Get SUSFS feature status from kernel
* @return SusfsFeatureStatus object containing all feature states, or null if failed
*/
/**
* Set dynamic managerature configuration
* @param size APK signature size
* @param hash APK signature hash (64 character hex string)
* @return true if successful, false otherwise
*/
external fun setDynamicManager(size: Int, hash: String): Boolean
/**
* Get current dynamic managerature configuration
* @return DynamicManagerConfig object containing current configuration, or null if not set
*/
external fun getDynamicManager(): DynamicManagerConfig?
/**
* Clear dynamic managerature configuration
* @return true if successful, false otherwise
*/
external fun clearDynamicManager(): Boolean
/**
* Get active managers list when dynamic manager is enabled
* @return ManagersList object containing active managers, or null if failed or not enabled
*/
external fun getManagersList(): ManagersList?
// 模块签名验证
external fun verifyModuleSignature(modulePath: String): Boolean
/**
* Check if UID scanner is currently enabled
* @return true if UID scanner is enabled, false otherwise
*/
external fun isUidScannerEnabled(): Boolean
/**
* Enable or disable UID scanner
* @param enabled true to enable, false to disable
* @return true if operation was successful, false otherwise
*/
external fun setUidScannerEnabled(enabled: Boolean): Boolean
/**
* Clear UID scanner environment (force exit)
* This will forcefully stop all UID scanner operations and clear the environment
* @return true if operation was successful, false otherwise
*/
external fun clearUidScannerEnvironment(): Boolean
external fun getUserName(uid: Int): String?
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
private const val NOBODY_UID = 9999
fun setDefaultUmountModules(umountModules: Boolean): Boolean {
Profile(
NON_ROOT_DEFAULT_PROFILE_KEY,
NOBODY_UID,
false,
umountModules = umountModules
).let {
return setAppProfile(it)
}
}
fun isDefaultUmountModules(): Boolean {
getAppProfile(NON_ROOT_DEFAULT_PROFILE_KEY, NOBODY_UID).let {
return it.umountModules
}
}
fun requireNewKernel(): Boolean {
if (version != -1 && version < MINIMAL_SUPPORTED_KERNEL) return true
return isVersionLessThan(getFullVersion(), MINIMAL_SUPPORTED_KERNEL_FULL)
}
@Immutable
@Parcelize
@Keep
data class DynamicManagerConfig(
val size: Int = 0,
val hash: String = ""
) : Parcelable {
fun isValid(): Boolean {
return size > 0 && hash.length == 64 && hash.all {
it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F'
}
}
}
@Immutable
@Parcelize
@Keep
data class ManagersList(
val count: Int = 0,
val managers: List<ManagerInfo> = emptyList()
) : Parcelable
@Immutable
@Parcelize
@Keep
data class ManagerInfo(
val uid: Int = 0,
val signatureIndex: Int = 0
) : Parcelable
@Immutable
@Parcelize
@Keep
data class Profile(
// and there is a default profile for root and non-root
val name: String,
// current uid for the package, this is convivent for kernel to check
// if the package name doesn't match uid, then it should be invalidated.
val currentUid: Int = 0,
// if this is true, kernel will grant root permission to this package
val allowSu: Boolean = false,
// these are used for root profile
val rootUseDefault: Boolean = true,
val rootTemplate: String? = null,
val uid: Int = ROOT_UID,
val gid: Int = ROOT_GID,
val groups: List<Int> = mutableListOf(),
val capabilities: List<Int> = mutableListOf(),
val context: String = KERNEL_SU_DOMAIN,
val namespace: Int = Namespace.INHERITED.ordinal,
val nonRootUseDefault: Boolean = true,
val umountModules: Boolean = true,
var rules: String = "", // this field is save in ksud!!
) : Parcelable {
enum class Namespace {
INHERITED,
GLOBAL,
INDIVIDUAL,
}
constructor() : this("")
}
}

View file

@ -0,0 +1,364 @@
package com.sukisu.ultra.network
import android.content.Context
import android.util.Log
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import java.util.concurrent.TimeUnit
class RemoteToolsDownloader(
private val context: Context,
private val workDir: String
) {
companion object {
private const val TAG = "RemoteToolsDownloader"
// 远程下载URL配置
private const val KPTOOLS_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kptools"
private const val KPIMG_REMOTE_URL = "https://raw.githubusercontent.com/ShirkNeko/SukiSU_patch/refs/heads/main/kpm/kpimg"
// 网络超时配置(毫秒)
private const val CONNECTION_TIMEOUT = 15000 // 15秒连接超时
private const val READ_TIMEOUT = 30000 // 30秒读取超时
// 最大重试次数
private const val MAX_RETRY_COUNT = 3
// 文件校验相关
private const val MIN_FILE_SIZE = 1024
}
interface DownloadProgressListener {
fun onProgress(fileName: String, progress: Int, total: Int)
fun onLog(message: String)
fun onError(fileName: String, error: String)
fun onSuccess(fileName: String, isRemote: Boolean)
}
data class DownloadResult(
val success: Boolean,
val isRemoteSource: Boolean,
val errorMessage: String? = null
)
suspend fun downloadToolsAsync(listener: DownloadProgressListener?): Map<String, DownloadResult> = withContext(Dispatchers.IO) {
val results = mutableMapOf<String, DownloadResult>()
listener?.onLog("Starting to prepare KPM tool files...")
try {
// 确保工作目录存在
File(workDir).mkdirs()
// 并行下载两个工具文件
val kptoolsDeferred = async { downloadSingleTool("kptools", KPTOOLS_REMOTE_URL, listener) }
val kpimgDeferred = async { downloadSingleTool("kpimg", KPIMG_REMOTE_URL, listener) }
// 等待所有下载完成
results["kptools"] = kptoolsDeferred.await()
results["kpimg"] = kpimgDeferred.await()
// 检查kptools执行权限
val kptoolsFile = File(workDir, "kptools")
if (kptoolsFile.exists()) {
setExecutablePermission(kptoolsFile.absolutePath)
listener?.onLog("Set kptools execution permission")
}
val successCount = results.values.count { it.success }
val remoteCount = results.values.count { it.success && it.isRemoteSource }
listener?.onLog("KPM tools preparation completed: Success $successCount/2, Remote downloaded $remoteCount")
} catch (e: Exception) {
Log.e(TAG, "Exception occurred while downloading tools", e)
listener?.onLog("Exception occurred during tool download: ${e.message}")
if (!results.containsKey("kptools")) {
results["kptools"] = downloadSingleTool("kptools", null, listener)
}
if (!results.containsKey("kpimg")) {
results["kpimg"] = downloadSingleTool("kpimg", null, listener)
}
}
results.toMap()
}
private suspend fun downloadSingleTool(
fileName: String,
remoteUrl: String?,
listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) {
val targetFile = File(workDir, fileName)
if (remoteUrl == null) {
return@withContext useLocalVersion(fileName, targetFile, listener)
}
// 尝试从远程下载
listener?.onLog("Downloading $fileName from remote repository...")
var lastError = ""
// 重试机制
repeat(MAX_RETRY_COUNT) { attempt ->
try {
val result = downloadFromRemote(fileName, remoteUrl, targetFile, listener)
if (result.success) {
listener?.onSuccess(fileName, true)
return@withContext result
}
lastError = result.errorMessage ?: "Unknown error"
} catch (e: Exception) {
lastError = e.message ?: "Network exception"
Log.w(TAG, "$fileName download attempt ${attempt + 1} failed", e)
if (attempt < MAX_RETRY_COUNT - 1) {
listener?.onLog("$fileName download failed, retrying in ${(attempt + 1) * 2} seconds...")
delay(TimeUnit.SECONDS.toMillis((attempt + 1) * 2L))
}
}
}
// 所有重试都失败,回退到本地版本
listener?.onError(fileName, "Remote download failed: $lastError")
listener?.onLog("$fileName remote download failed, falling back to local version...")
useLocalVersion(fileName, targetFile, listener)
}
private suspend fun downloadFromRemote(
fileName: String,
remoteUrl: String,
targetFile: File,
listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
val url = URL(remoteUrl)
connection = url.openConnection() as HttpURLConnection
// 设置连接参数
connection.apply {
connectTimeout = CONNECTION_TIMEOUT
readTimeout = READ_TIMEOUT
requestMethod = "GET"
setRequestProperty("User-Agent", "SukiSU-KPM-Downloader/1.0")
setRequestProperty("Accept", "*/*")
setRequestProperty("Connection", "close")
}
// 建立连接
connection.connect()
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
return@withContext DownloadResult(
false,
isRemoteSource = false,
errorMessage = "HTTP error code: $responseCode"
)
}
val fileLength = connection.contentLength
Log.d(TAG, "$fileName remote file size: $fileLength bytes")
// 创建临时文件
val tempFile = File(targetFile.absolutePath + ".tmp")
// 下载文件
connection.inputStream.use { input ->
FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(8192)
var totalBytes = 0
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
// 检查协程是否被取消
ensureActive()
output.write(buffer, 0, bytesRead)
totalBytes += bytesRead
// 更新下载进度
if (fileLength > 0) {
listener?.onProgress(fileName, totalBytes, fileLength)
}
}
output.flush()
}
}
// 验证下载的文件
if (!validateDownloadedFile(tempFile, fileName)) {
tempFile.delete()
return@withContext DownloadResult(
success = false,
isRemoteSource = false,
errorMessage = "File verification failed"
)
}
// 移动临时文件到目标位置
if (targetFile.exists()) {
targetFile.delete()
}
if (!tempFile.renameTo(targetFile)) {
tempFile.delete()
return@withContext DownloadResult(
false,
isRemoteSource = false,
errorMessage = "Failed to move file"
)
}
Log.i(TAG, "$fileName remote download successful, file size: ${targetFile.length()} bytes")
listener?.onLog("$fileName remote download successful")
DownloadResult(true, isRemoteSource = true)
} catch (e: SocketTimeoutException) {
Log.w(TAG, "$fileName download timeout", e)
DownloadResult(false, isRemoteSource = false, errorMessage = "Connection timeout")
} catch (e: IOException) {
Log.w(TAG, "$fileName network IO exception", e)
DownloadResult(false,
isRemoteSource = false,
errorMessage = "Network connection exception: ${e.message}"
)
} catch (e: Exception) {
Log.e(TAG, "$fileName exception occurred during download", e)
DownloadResult(false,
isRemoteSource = false,
errorMessage = "Download exception: ${e.message}"
)
} finally {
connection?.disconnect()
}
}
private suspend fun useLocalVersion(
fileName: String,
targetFile: File,
listener: DownloadProgressListener?
): DownloadResult = withContext(Dispatchers.IO) {
try {
com.sukisu.ultra.utils.AssetsUtil.exportFiles(context, fileName, targetFile.absolutePath)
if (!targetFile.exists()) {
val errorMsg = "Local $fileName file extraction failed"
listener?.onError(fileName, errorMsg)
return@withContext DownloadResult(false,
isRemoteSource = false,
errorMessage = errorMsg
)
}
if (!validateDownloadedFile(targetFile, fileName)) {
val errorMsg = "Local $fileName file verification failed"
listener?.onError(fileName, errorMsg)
return@withContext DownloadResult(
success = false,
isRemoteSource = false,
errorMessage = errorMsg
)
}
Log.i(TAG, "$fileName local version loaded successfully, file size: ${targetFile.length()} bytes")
listener?.onLog("$fileName local version loaded successfully")
listener?.onSuccess(fileName, false)
DownloadResult(true, isRemoteSource = false)
} catch (e: Exception) {
Log.e(TAG, "$fileName local version loading failed", e)
val errorMsg = "Local version loading failed: ${e.message}"
listener?.onError(fileName, errorMsg)
DownloadResult(success = false, isRemoteSource = false, errorMessage = errorMsg)
}
}
private fun validateDownloadedFile(file: File, fileName: String): Boolean {
if (!file.exists()) {
Log.w(TAG, "$fileName file does not exist")
return false
}
val fileSize = file.length()
if (fileSize < MIN_FILE_SIZE) {
Log.w(TAG, "$fileName file is too small: $fileSize bytes")
return false
}
try {
file.inputStream().use { input ->
val header = ByteArray(4)
val bytesRead = input.read(header)
if (bytesRead < 4) {
Log.w(TAG, "$fileName file header read incomplete")
return false
}
val isELF = header[0] == 0x7F.toByte() &&
header[1] == 'E'.code.toByte() &&
header[2] == 'L'.code.toByte() &&
header[3] == 'F'.code.toByte()
if (fileName == "kptools" && !isELF) {
Log.w(TAG, "kptools file format is invalid, not ELF format")
return false
}
Log.d(TAG, "$fileName file verification passed, size: $fileSize bytes, ELF: $isELF")
return true
}
} catch (e: Exception) {
Log.w(TAG, "$fileName file verification exception", e)
return false
}
}
private fun setExecutablePermission(filePath: String) {
try {
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "chmod a+rx $filePath"))
process.waitFor()
Log.d(TAG, "Set execution permission for $filePath")
} catch (e: Exception) {
Log.w(TAG, "Failed to set execution permission: $filePath", e)
try {
File(filePath).setExecutable(true, false)
} catch (ex: Exception) {
Log.w(TAG, "Java method to set permissions also failed", ex)
}
}
}
fun cleanup() {
try {
File(workDir).listFiles()?.forEach { file ->
if (file.name.endsWith(".tmp")) {
file.delete()
Log.d(TAG, "Cleaned temporary file: ${file.name}")
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to clean temporary files", e)
}
}
}

View file

@ -0,0 +1,49 @@
package com.sukisu.ultra.profile
/**
* @author weishu
* @date 2023/6/3.
*/
enum class Capabilities(val cap: Int, val display: String, val desc: String) {
CAP_CHOWN(0, "CHOWN", "Make arbitrary changes to file UIDs and GIDs (see chown(2))"),
CAP_DAC_OVERRIDE(1, "DAC_OVERRIDE", "Bypass file read, write, and execute permission checks"),
CAP_DAC_READ_SEARCH(2, "DAC_READ_SEARCH", "Bypass file read permission checks and directory read and execute permission checks"),
CAP_FOWNER(3, "FOWNER", "Bypass permission checks on operations that normally require the filesystem UID of the process to match the UID of the file (e.g., chmod(2), utime(2)), excluding those operations covered by CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH"),
CAP_FSETID(4, "FSETID", "Dont clear set-user-ID and set-group-ID permission bits when a file is modified; set the set-group-ID bit for a file whose GID does not match the filesystem or any of the supplementary GIDs of the calling process"),
CAP_KILL(5, "KILL", "Bypass permission checks for sending signals (see kill(2))."),
CAP_SETGID(6, "SETGID", "Make arbitrary manipulations of process GIDs and supplementary GID list; allow setgid(2) manipulation of the callers effective and real group IDs"),
CAP_SETUID(7, "SETUID", "Make arbitrary manipulations of process UIDs (setuid(2), setreuid(2), setresuid(2), setfsuid(2)); allow changing the current process user IDs; allow changing of the current process group ID to any value in the systems range of legal group IDs"),
CAP_SETPCAP(8, "SETPCAP", "If file capabilities are supported: grant or remove any capability in the callers permitted capability set to or from any other process. (This property supersedes the obsolete notion of giving a process all capabilities by granting all capabilities in its permitted set, and of removing all capabilities from a process by granting no capabilities in its permitted set. It does not permit any actions that were not permitted before.)"),
CAP_LINUX_IMMUTABLE(9, "LINUX_IMMUTABLE", "Set the FS_APPEND_FL and FS_IMMUTABLE_FL inode flags (see chattr(1))."),
CAP_NET_BIND_SERVICE(10, "NET_BIND_SERVICE", "Bind a socket to Internet domain"),
CAP_NET_BROADCAST(11, "NET_BROADCAST", "Make socket broadcasts, and listen to multicasts"),
CAP_NET_ADMIN(12, "NET_ADMIN", "Perform various network-related operations: interface configuration, administration of IP firewall, masquerading, and accounting, modify routing tables, bind to any address for transparent proxying, set type-of-service (TOS), clear driver statistics, set promiscuous mode, enabling multicasting, use setsockopt(2) to set the following socket options: SO_DEBUG, SO_MARK, SO_PRIORITY (for a priority outside the range 0 to 6), SO_RCVBUFFORCE, and SO_SNDBUFFORCE"),
CAP_NET_RAW(13, "NET_RAW", "Use RAW and PACKET sockets"),
CAP_IPC_LOCK(14, "IPC_LOCK", "Lock memory (mlock(2), mlockall(2), mmap(2), shmctl(2))"),
CAP_IPC_OWNER(15, "IPC_OWNER", "Bypass permission checks for operations on System V IPC objects"),
CAP_SYS_MODULE(16, "SYS_MODULE", "Load and unload kernel modules (see init_module(2) and delete_module(2)); in kernels before 2.6.25, this also granted rights for various other operations related to kernel modules"),
CAP_SYS_RAWIO(17, "SYS_RAWIO", "Perform I/O port operations (iopl(2) and ioperm(2)); access /proc/kcore"),
CAP_SYS_CHROOT(18, "SYS_CHROOT", "Use chroot(2)"),
CAP_SYS_PTRACE(19, "SYS_PTRACE", "Trace arbitrary processes using ptrace(2)"),
CAP_SYS_PACCT(20, "SYS_PACCT", "Use acct(2)"),
CAP_SYS_ADMIN(21, "SYS_ADMIN", "Perform a range of system administration operations including: quotactl(2), mount(2), umount(2), swapon(2), swapoff(2), sethostname(2), and setdomainname(2); set and modify process resource limits (setrlimit(2)); perform various network-related operations (e.g., setting privileged socket options, enabling multicasting, interface configuration); perform various IPC operations (e.g., SysV semaphores, POSIX message queues, System V shared memory); allow reboot and kexec_load(2); override /proc/sys kernel tunables; perform ptrace(2) PTRACE_SECCOMP_GET_FILTER operation; perform some tracing and debugging operations (see ptrace(2)); administer the lifetime of kernel tracepoints (tracefs(5)); perform the KEYCTL_CHOWN and KEYCTL_SETPERM keyctl(2) operations; perform the following keyctl(2) operations: KEYCTL_CAPABILITIES, KEYCTL_CAPSQUASH, and KEYCTL_PKEY_ OPERATIONS; set state for the Extensible Authentication Protocol (EAP) kernel module; and override the RLIMIT_NPROC resource limit; allow ioperm/iopl access to I/O ports"),
CAP_SYS_BOOT(22, "SYS_BOOT", "Use reboot(2) and kexec_load(2), reboot and load a new kernel for later execution"),
CAP_SYS_NICE(23, "SYS_NICE", "Raise process nice value (nice(2), setpriority(2)) and change the nice value for arbitrary processes; set real-time scheduling policies for calling process, and set scheduling policies and priorities for arbitrary processes (sched_setscheduler(2), sched_setparam(2)"),
CAP_SYS_RESOURCE(24, "SYS_RESOURCE", "Override resource Limits. Set resource limits (setrlimit(2), prlimit(2)), override quota limits (quota(2), quotactl(2)), override reserved space on ext2 filesystem (ext2_ioctl(2)), override size restrictions on IPC message queues (msg(2)) and system V shared memory segments (shmget(2)), and override the /proc/sys/fs/pipe-size-max limit"),
CAP_SYS_TIME(25, "SYS_TIME", "Set system clock (settimeofday(2), stime(2), adjtimex(2)); set real-time (hardware) clock"),
CAP_SYS_TTY_CONFIG(26, "SYS_TTY_CONFIG", "Use vhangup(2); employ various privileged ioctl(2) operations on virtual terminals"),
CAP_MKNOD(27, "MKNOD", "Create special files using mknod(2)"),
CAP_LEASE(28, "LEASE", "Establish leases on arbitrary files (see fcntl(2))"),
CAP_AUDIT_WRITE(29, "AUDIT_WRITE", "Write records to kernel auditing log"),
CAP_AUDIT_CONTROL(30, "AUDIT_CONTROL", "Enable and disable kernel auditing; change auditing filter rules; retrieve auditing status and filtering rules"),
CAP_SETFCAP(31, "SETFCAP", "If file capabilities are supported: grant or remove any capability in any capability set to any file"),
CAP_MAC_OVERRIDE(32, "MAC_OVERRIDE", "Override Mandatory Access Control (MAC). Implemented for the Smack Linux Security Module (LSM)"),
CAP_MAC_ADMIN(33, "MAC_ADMIN", "Allow MAC configuration or state changes. Implemented for the Smack LSM"),
CAP_SYSLOG(34, "SYSLOG", "Perform privileged syslog(2) operations. See syslog(2) for information on which operations require privilege"),
CAP_WAKE_ALARM(35, "WAKE_ALARM", "Trigger something that will wake up the system"),
CAP_BLOCK_SUSPEND(36, "BLOCK_SUSPEND", "Employ features that can block system suspend"),
CAP_AUDIT_READ(37, "AUDIT_READ", "Allow reading the audit log via a multicast netlink socket"),
CAP_PERFMON(38, "PERFMON", "Allow performance monitoring via perf_event_open(2)"),
CAP_BPF(39, "BPF", "Allow BPF operations via bpf(2)"),
CAP_CHECKPOINT_RESTORE(40, "CHECKPOINT_RESTORE", "Allow processes to be checkpointed via checkpoint/restore in user namespace(2)"),
}

View file

@ -0,0 +1,130 @@
package com.sukisu.ultra.profile
/**
* https://cs.android.com/android/platform/superproject/main/+/main:system/core/libcutils/include/private/android_filesystem_config.h
* @author weishu
* @date 2023/6/3.
*/
enum class Groups(val gid: Int, val display: String, val desc: String) {
ROOT(0, "root", "traditional unix root user"),
DAEMON(1, "daemon", "Traditional unix daemon owner."),
BIN(2, "bin", "Traditional unix binaries owner."),
SYS(3, "sys", "A group with the same gid on Linux/macOS/Android."),
SYSTEM(1000, "system", "system server"),
RADIO(1001, "radio", "telephony subsystem, RIL"),
BLUETOOTH(1002, "bluetooth", "bluetooth subsystem"),
GRAPHICS(1003, "graphics", "graphics devices"),
INPUT(1004, "input", "input devices"),
AUDIO(1005, "audio", "audio devices"),
CAMERA(1006, "camera", "camera devices"),
LOG(1007, "log", "log devices"),
COMPASS(1008, "compass", "compass device"),
MOUNT(1009, "mount", "mountd socket"),
WIFI(1010, "wifi", "wifi subsystem"),
ADB(1011, "adb", "android debug bridge (adbd)"),
INSTALL(1012, "install", "group for installing packages"),
MEDIA(1013, "media", "mediaserver process"),
DHCP(1014, "dhcp", "dhcp client"),
SDCARD_RW(1015, "sdcard_rw", "external storage write access"),
VPN(1016, "vpn", "vpn system"),
KEYSTORE(1017, "keystore", "keystore subsystem"),
USB(1018, "usb", "USB devices"),
DRM(1019, "drm", "DRM server"),
MDNSR(1020, "mdnsr", "MulticastDNSResponder (service discovery)"),
GPS(1021, "gps", "GPS daemon"),
UNUSED1(1022, "unused1", "deprecated, DO NOT USE"),
MEDIA_RW(1023, "media_rw", "internal media storage write access"),
MTP(1024, "mtp", "MTP USB driver access"),
UNUSED2(1025, "unused2", "deprecated, DO NOT USE"),
DRMRPC(1026, "drmrpc", "group for drm rpc"),
NFC(1027, "nfc", "nfc subsystem"),
SDCARD_R(1028, "sdcard_r", "external storage read access"),
CLAT(1029, "clat", "clat part of nat464"),
LOOP_RADIO(1030, "loop_radio", "loop radio devices"),
MEDIA_DRM(1031, "media_drm", "MediaDrm plugins"),
PACKAGE_INFO(1032, "package_info", "access to installed package details"),
SDCARD_PICS(1033, "sdcard_pics", "external storage photos access"),
SDCARD_AV(1034, "sdcard_av", "external storage audio/video access"),
SDCARD_ALL(1035, "sdcard_all", "access all users external storage"),
LOGD(1036, "logd", "log daemon"),
SHARED_RELRO(1037, "shared_relro", "creator of shared GNU RELRO files"),
DBUS(1038, "dbus", "dbus-daemon IPC broker process"),
TLSDATE(1039, "tlsdate", "tlsdate unprivileged user"),
MEDIA_EX(1040, "media_ex", "mediaextractor process"),
AUDIOSERVER(1041, "audioserver", "audioserver process"),
METRICS_COLL(1042, "metrics_coll", "metrics_collector process"),
METRICSD(1043, "metricsd", "metricsd process"),
WEBSERV(1044, "webserv", "webservd process"),
DEBUGGERD(1045, "debuggerd", "debuggerd unprivileged user"),
MEDIA_CODEC(1046, "media_codec", "media_codec process"),
CAMERASERVER(1047, "cameraserver", "cameraserver process"),
FIREWALL(1048, "firewall", "firewall process"),
TRUNKS(1049, "trunks", "trunksd process"),
NVRAM(1050, "nvram", "nvram daemon"),
DNS(1051, "dns", "DNS resolution daemon (system: netd)"),
DNS_TETHER(1052, "dns_tether", "DNS resolution daemon (tether: dnsmasq)"),
WEBVIEW_ZYGOTE(1053, "webview_zygote", "WebView zygote process"),
VEHICLE_NETWORK(1054, "vehicle_network", "Vehicle network service"),
MEDIA_AUDIO(1055, "media_audio", "GID for audio files on internal media storage"),
MEDIA_VIDEO(1056, "media_video", "GID for video files on internal media storage"),
MEDIA_IMAGE(1057, "media_image", "GID for image files on internal media storage"),
TOMBSTONED(1058, "tombstoned", "tombstoned user"),
MEDIA_OBB(1059, "media_obb", "GID for OBB files on internal media storage"),
ESE(1060, "ese", "embedded secure element (eSE) subsystem"),
OTA_UPDATE(1061, "ota_update", "resource tracking UID for OTA updates"),
AUTOMOTIVE_EVS(1062, "automotive_evs", "Automotive rear and surround view system"),
LOWPAN(1063, "lowpan", "LoWPAN subsystem"),
HSM(1064, "lowpan", "hardware security module subsystem"),
RESERVED_DISK(1065, "reserved_disk", "GID that has access to reserved disk space"),
STATSD(1066, "statsd", "statsd daemon"),
INCIDENTD(1067, "incidentd", "incidentd daemon"),
SECURE_ELEMENT(1068, "secure_element", "secure element subsystem"),
LMKD(1069, "lmkd", "low memory killer daemon"),
LLKD(1070, "llkd", "live lock daemon"),
IORAPD(1071, "iorapd", "input/output readahead and pin daemon"),
GPU_SERVICE(1072, "gpu_service", "GPU service daemon"),
NETWORK_STACK(1073, "network_stack", "network stack service"),
GSID(1074, "GSID", "GSI service daemon"),
FSVERITY_CERT(1075, "fsverity_cert", "fs-verity key ownership in keystore"),
CREDSTORE(1076, "credstore", "identity credential manager service"),
EXTERNAL_STORAGE(1077, "external_storage", "Full external storage access including USB OTG volumes"),
EXT_DATA_RW(1078, "ext_data_rw", "GID for app-private data directories on external storage"),
EXT_OBB_RW(1079, "ext_obb_rw", "GID for OBB directories on external storage"),
CONTEXT_HUB(1080, "context_hub", "GID for access to the Context Hub"),
VIRTUALIZATIONSERVICE(1081, "virtualizationservice", "VirtualizationService daemon"),
ARTD(1082, "artd", "ART Service daemon"),
UWB(1083, "uwb", "UWB subsystem"),
THREAD_NETWORK(1084, "thread_network", "Thread Network subsystem"),
DICED(1085, "diced", "Android's DICE daemon"),
DMESGD(1086, "dmesgd", "dmesg parsing daemon for kernel report collection"),
JC_WEAVER(1087, "jc_weaver", "Javacard Weaver HAL - to manage omapi ARA rules"),
JC_STRONGBOX(1088, "jc_strongbox", "Javacard Strongbox HAL - to manage omapi ARA rules"),
JC_IDENTITYCRED(1089, "jc_identitycred", "Javacard Identity Cred HAL - to manage omapi ARA rules"),
SDK_SANDBOX(1090, "sdk_sandbox", "SDK sandbox virtual UID"),
SECURITY_LOG_WRITER(1091, "security_log_writer", "write to security log"),
PRNG_SEEDER(1092, "prng_seeder", "PRNG seeder daemon"),
SHELL(2000, "shell", "adb and debug shell user"),
CACHE(2001, "cache", "cache access"),
DIAG(2002, "diag", "access to diagnostic resources"),
/* The 3000 series are intended for use as supplemental group id's only.
* They indicate special Android capabilities that the kernel is aware of. */
NET_BT_ADMIN(3001, "net_bt_admin", "bluetooth: create any socket"),
NET_BT(3002, "net_bt", "bluetooth: create sco, rfcomm or l2cap sockets"),
INET(3003, "inet", "can create AF_INET and AF_INET6 sockets"),
NET_RAW(3004, "net_raw", "can create raw INET sockets"),
NET_ADMIN(3005, "net_admin", "can configure interfaces and routing tables."),
NET_BW_STATS(3006, "net_bw_stats", "read bandwidth statistics"),
NET_BW_ACCT(3007, "net_bw_acct", "change bandwidth statistics accounting"),
NET_BT_STACK(3008, "net_bt_stack", "access to various bluetooth management functions"),
READPROC(3009, "readproc", "Allow /proc read access"),
WAKELOCK(3010, "wakelock", "Allow system wakelock read/write access"),
UHID(3011, "uhid", "Allow read/write to /dev/uhid node"),
READTRACEFS(3012, "readtracefs", "Allow tracefs read"),
EVERYBODY(9997, "everybody", "Shared external storage read/write"),
MISC(9998, "misc", "Access to misc storage"),
NOBODY(9999, "nobody", "Reserved"),
APP(10000, "app", "Access to app data"),
}

View file

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

View file

@ -0,0 +1,307 @@
package com.sukisu.ultra.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.ramcosta.composedestinations.DestinationsNavHost
import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle
import com.ramcosta.composedestinations.generated.NavGraphs
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination
import com.ramcosta.composedestinations.spec.NavHostGraphSpec
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.screen.BottomBarDestination
import com.sukisu.ultra.ui.theme.KernelSUTheme
import com.sukisu.ultra.ui.util.LocalSnackbarHost
import com.sukisu.ultra.ui.util.install
import com.sukisu.ultra.ui.viewmodel.HomeViewModel
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import com.sukisu.ultra.ui.webui.initPlatform
import com.sukisu.ultra.ui.component.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import com.sukisu.ultra.ui.activity.component.BottomBar
import com.sukisu.ultra.ui.activity.util.*
class MainActivity : ComponentActivity() {
private lateinit var superUserViewModel: SuperUserViewModel
private lateinit var homeViewModel: HomeViewModel
internal val settingsStateFlow = MutableStateFlow(SettingsState())
data class SettingsState(
val isHideOtherInfo: Boolean = false,
val showKpmInfo: Boolean = false
)
private var showConfirmationDialog = mutableStateOf(false)
private var pendingZipFiles = mutableStateOf<List<ZipFileInfo>>(emptyList())
private lateinit var themeChangeObserver: ThemeChangeContentObserver
private var isInitialized = false
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(newBase?.let { LocaleHelper.applyLanguage(it) })
}
override fun onCreate(savedInstanceState: Bundle?) {
try {
// 应用自定义 DPI
DisplayUtils.applyCustomDpi(this)
// Enable edge to edge
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
super.onCreate(savedInstanceState)
val isManager = Natives.isManager
if (isManager && !Natives.requireNewKernel()) {
install()
}
// 使用标记控制初始化流程
if (!isInitialized) {
initializeViewModels()
initializeData()
isInitialized = true
}
// Check if launched with a ZIP file
val zipUri: ArrayList<Uri>? = when (intent?.action) {
Intent.ACTION_SEND -> {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let { arrayListOf(it) }
}
Intent.ACTION_SEND_MULTIPLE -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
}
else -> when {
intent?.data != null -> arrayListOf(intent.data!!)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
intent.getParcelableArrayListExtra("uris", Uri::class.java)
}
else -> {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra("uris")
}
}
}
setContent {
KernelSUTheme {
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()
InstallConfirmationDialog(
show = showConfirmationDialog.value,
zipFiles = pendingZipFiles.value,
onConfirm = { confirmedFiles ->
showConfirmationDialog.value = false
UltraActivityUtils.navigateToFlashScreen(this, confirmedFiles, navigator)
},
onDismiss = {
showConfirmationDialog.value = false
pendingZipFiles.value = emptyList()
finish()
}
)
LaunchedEffect(zipUri) {
if (!zipUri.isNullOrEmpty()) {
// 检测 ZIP 文件类型并显示确认对话框
lifecycleScope.launch {
UltraActivityUtils.detectZipTypeAndShowConfirmation(this@MainActivity, zipUri) { infos ->
if (infos.isNotEmpty()) {
pendingZipFiles.value = infos
showConfirmationDialog.value = true
} else {
finish()
}
}
}
}
}
val showBottomBar = when (currentDestination?.route) {
ExecuteModuleActionScreenDestination.route -> false
else -> true
}
LaunchedEffect(Unit) {
initPlatform()
}
CompositionLocalProvider(
LocalSnackbarHost provides snackBarHostState
) {
Scaffold(
bottomBar = {
AnimatedBottomBar.AnimatedBottomBarWrapper(
showBottomBar = showBottomBar,
content = { BottomBar(navController) }
)
},
contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { innerPadding ->
DestinationsNavHost(
modifier = Modifier.padding(innerPadding),
navGraph = NavGraphs.root as NavHostGraphSpec,
navController = navController,
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
// If the target is a detail page (not a bottom navigation page), slide in from the right
if (targetState.destination.route !in bottomBarRoutes) {
slideInHorizontally(initialOffsetX = { it })
} else {
// Otherwise (switching between bottom navigation pages), use fade in
fadeIn(animationSpec = tween(340))
}
}
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
// If navigating from the home page (bottom navigation page) to a detail page, slide out to the left
if (initialState.destination.route in bottomBarRoutes && targetState.destination.route !in bottomBarRoutes) {
slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut()
} else {
// Otherwise (switching between bottom navigation pages), use fade out
fadeOut(animationSpec = tween(340))
}
}
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
// If returning to the home page (bottom navigation page), slide in from the left
if (targetState.destination.route in bottomBarRoutes) {
slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn()
} else {
// Otherwise (e.g., returning between multiple detail pages), use default fade in
fadeIn(animationSpec = tween(340))
}
}
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
// If returning from a detail page (not a bottom navigation page), scale down and fade out
if (initialState.destination.route !in bottomBarRoutes) {
scaleOut(targetScale = 0.9f) + fadeOut()
} else {
// Otherwise, use default fade out
fadeOut(animationSpec = tween(340))
}
}
}
)
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun initializeViewModels() {
superUserViewModel = SuperUserViewModel()
homeViewModel = HomeViewModel()
// 设置主题变化监听器
themeChangeObserver = ThemeUtils.registerThemeChangeObserver(this)
}
private fun initializeData() {
lifecycleScope.launch {
try {
superUserViewModel.fetchAppList()
} catch (e: Exception) {
e.printStackTrace()
}
}
// 数据刷新协程
DataRefreshUtils.startDataRefreshCoroutine(lifecycleScope)
DataRefreshUtils.startSettingsMonitorCoroutine(lifecycleScope, this, settingsStateFlow)
// 初始化主题相关设置
ThemeUtils.initializeThemeSettings(this, settingsStateFlow)
}
override fun onResume() {
try {
super.onResume()
ThemeUtils.onActivityResume()
// 仅在需要时刷新数据
if (isInitialized) {
refreshData()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun refreshData() {
lifecycleScope.launch {
try {
superUserViewModel.fetchAppList()
DataRefreshUtils.refreshData(lifecycleScope)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
override fun onPause() {
try {
super.onPause()
ThemeUtils.onActivityPause(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onDestroy() {
try {
ThemeUtils.unregisterThemeChangeObserver(this, themeChangeObserver)
super.onDestroy()
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,468 @@
package com.sukisu.ultra.ui.component
import android.graphics.text.LineBreaker
import android.os.Build
import android.os.Parcelable
import android.text.Layout
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.ViewGroup
import android.widget.TextView
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import io.noties.markwon.Markwon
import io.noties.markwon.utils.NoCopySpannableFactory
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import kotlin.coroutines.resume
private const val TAG = "DialogComponent"
interface ConfirmDialogVisuals : Parcelable {
val title: String
val content: String
val isMarkdown: Boolean
val confirm: String?
val dismiss: String?
}
@Parcelize
private data class ConfirmDialogVisualsImpl(
override val title: String,
override val content: String,
override val isMarkdown: Boolean,
override val confirm: String?,
override val dismiss: String?,
) : ConfirmDialogVisuals {
companion object {
val Empty: ConfirmDialogVisuals = ConfirmDialogVisualsImpl("", "", false, null, null)
}
}
interface DialogHandle {
val isShown: Boolean
val dialogType: String
fun show()
fun hide()
}
interface LoadingDialogHandle : DialogHandle {
suspend fun <R> withLoading(block: suspend () -> R): R
fun showLoading()
}
sealed interface ConfirmResult {
object Confirmed : ConfirmResult
object Canceled : ConfirmResult
}
interface ConfirmDialogHandle : DialogHandle {
val visuals: ConfirmDialogVisuals
fun showConfirm(
title: String,
content: String,
markdown: Boolean = false,
confirm: String? = null,
dismiss: String? = null
)
suspend fun awaitConfirm(
title: String,
content: String,
markdown: Boolean = false,
confirm: String? = null,
dismiss: String? = null
): ConfirmResult
}
private abstract class DialogHandleBase(
val visible: MutableState<Boolean>,
val coroutineScope: CoroutineScope
) : DialogHandle {
override val isShown: Boolean
get() = visible.value
override fun show() {
coroutineScope.launch {
visible.value = true
}
}
final override fun hide() {
coroutineScope.launch {
visible.value = false
}
}
override fun toString(): String {
return dialogType
}
}
private class LoadingDialogHandleImpl(
visible: MutableState<Boolean>,
coroutineScope: CoroutineScope
) : LoadingDialogHandle, DialogHandleBase(visible, coroutineScope) {
override suspend fun <R> withLoading(block: suspend () -> R): R {
return coroutineScope.async {
try {
visible.value = true
block()
} finally {
visible.value = false
}
}.await()
}
override fun showLoading() {
show()
}
override val dialogType: String get() = "LoadingDialog"
}
typealias NullableCallback = (() -> Unit)?
interface ConfirmCallback {
val onConfirm: NullableCallback
val onDismiss: NullableCallback
val isEmpty: Boolean get() = onConfirm == null && onDismiss == null
companion object {
operator fun invoke(onConfirmProvider: () -> NullableCallback, onDismissProvider: () -> NullableCallback): ConfirmCallback {
return object : ConfirmCallback {
override val onConfirm: NullableCallback
get() = onConfirmProvider()
override val onDismiss: NullableCallback
get() = onDismissProvider()
}
}
}
}
private class ConfirmDialogHandleImpl(
visible: MutableState<Boolean>,
coroutineScope: CoroutineScope,
callback: ConfirmCallback,
override var visuals: ConfirmDialogVisuals = ConfirmDialogVisualsImpl.Empty,
private val resultFlow: ReceiveChannel<ConfirmResult>
) : ConfirmDialogHandle, DialogHandleBase(visible, coroutineScope) {
private class ResultCollector(
private val callback: ConfirmCallback
) : FlowCollector<ConfirmResult> {
fun handleResult(result: ConfirmResult) {
Log.d(TAG, "handleResult: ${result.javaClass.simpleName}")
when (result) {
ConfirmResult.Confirmed -> onConfirm()
ConfirmResult.Canceled -> onDismiss()
}
}
fun onConfirm() {
callback.onConfirm?.invoke()
}
fun onDismiss() {
callback.onDismiss?.invoke()
}
override suspend fun emit(value: ConfirmResult) {
handleResult(value)
}
}
private val resultCollector = ResultCollector(callback)
private var awaitContinuation: CancellableContinuation<ConfirmResult>? = null
private val isCallbackEmpty = callback.isEmpty
init {
coroutineScope.launch {
resultFlow
.consumeAsFlow()
.onEach { result ->
awaitContinuation?.let {
awaitContinuation = null
if (it.isActive) {
it.resume(result)
}
}
}
.onEach { hide() }
.collect(resultCollector)
}
}
private suspend fun awaitResult(): ConfirmResult {
return suspendCancellableCoroutine {
awaitContinuation = it.apply {
if (isCallbackEmpty) {
invokeOnCancellation {
visible.value = false
}
}
}
}
}
fun updateVisuals(visuals: ConfirmDialogVisuals) {
this.visuals = visuals
}
override fun show() {
if (visuals !== ConfirmDialogVisualsImpl.Empty) {
super.show()
} else {
throw UnsupportedOperationException("can't show confirm dialog with the Empty visuals")
}
}
override fun showConfirm(
title: String,
content: String,
markdown: Boolean,
confirm: String?,
dismiss: String?
) {
coroutineScope.launch {
updateVisuals(ConfirmDialogVisualsImpl(title, content, markdown, confirm, dismiss))
show()
}
}
override suspend fun awaitConfirm(
title: String,
content: String,
markdown: Boolean,
confirm: String?,
dismiss: String?
): ConfirmResult {
coroutineScope.launch {
updateVisuals(ConfirmDialogVisualsImpl(title, content, markdown, confirm, dismiss))
show()
}
return awaitResult()
}
override val dialogType: String get() = "ConfirmDialog"
override fun toString(): String {
return "${super.toString()}(visuals: $visuals)"
}
companion object {
fun Saver(
visible: MutableState<Boolean>,
coroutineScope: CoroutineScope,
callback: ConfirmCallback,
resultChannel: ReceiveChannel<ConfirmResult>
) = Saver<ConfirmDialogHandle, ConfirmDialogVisuals>(
save = {
it.visuals
},
restore = {
Log.d(TAG, "ConfirmDialog restore, visuals: $it")
ConfirmDialogHandleImpl(visible, coroutineScope, callback, it, resultChannel)
}
)
}
}
private class CustomDialogHandleImpl(
visible: MutableState<Boolean>,
coroutineScope: CoroutineScope
) : DialogHandleBase(visible, coroutineScope) {
override val dialogType: String get() = "CustomDialog"
}
@Composable
fun rememberLoadingDialog(): LoadingDialogHandle {
val visible = remember {
mutableStateOf(false)
}
val coroutineScope = rememberCoroutineScope()
if (visible.value) {
LoadingDialog()
}
return remember {
LoadingDialogHandleImpl(visible, coroutineScope)
}
}
@Composable
private fun rememberConfirmDialog(visuals: ConfirmDialogVisuals, callback: ConfirmCallback): ConfirmDialogHandle {
val visible = rememberSaveable {
mutableStateOf(false)
}
val coroutineScope = rememberCoroutineScope()
val resultChannel = remember {
Channel<ConfirmResult>()
}
val handle = rememberSaveable(
saver = ConfirmDialogHandleImpl.Saver(visible, coroutineScope, callback, resultChannel),
init = {
ConfirmDialogHandleImpl(visible, coroutineScope, callback, visuals, resultChannel)
}
)
if (visible.value) {
ConfirmDialog(
handle.visuals,
confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } }
)
}
return handle
}
@Composable
fun rememberConfirmCallback(onConfirm: NullableCallback, onDismiss: NullableCallback): ConfirmCallback {
val currentOnConfirm by rememberUpdatedState(newValue = onConfirm)
val currentOnDismiss by rememberUpdatedState(newValue = onDismiss)
return remember {
ConfirmCallback({ currentOnConfirm }, { currentOnDismiss })
}
}
@Composable
fun rememberConfirmDialog(onConfirm: NullableCallback = null, onDismiss: NullableCallback = null): ConfirmDialogHandle {
return rememberConfirmDialog(rememberConfirmCallback(onConfirm, onDismiss))
}
@Composable
fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle {
return rememberConfirmDialog(ConfirmDialogVisualsImpl.Empty, callback)
}
@Composable
fun rememberCustomDialog(composable: @Composable (dismiss: () -> Unit) -> Unit): DialogHandle {
val visible = rememberSaveable {
mutableStateOf(false)
}
val coroutineScope = rememberCoroutineScope()
if (visible.value) {
composable { visible.value = false }
}
return remember {
CustomDialogHandleImpl(visible, coroutineScope)
}
}
@Composable
private fun LoadingDialog() {
Dialog(
onDismissRequest = {},
properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false)
) {
Surface(
modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
) {
Box(
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
@Composable
private fun ConfirmDialog(visuals: ConfirmDialogVisuals, confirm: () -> Unit, dismiss: () -> Unit) {
AlertDialog(
onDismissRequest = {
dismiss()
},
title = {
Text(text = visuals.title)
},
text = {
if (visuals.isMarkdown) {
MarkdownContent(content = visuals.content)
} else {
Text(text = visuals.content)
}
},
confirmButton = {
TextButton(onClick = confirm) {
Text(text = visuals.confirm ?: stringResource(id = android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = dismiss) {
Text(text = visuals.dismiss ?: stringResource(id = android.R.string.cancel))
}
},
)
}
@Composable
private fun MarkdownContent(content: String) {
val contentColor = LocalContentColor.current
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(
state = scrollState,
flingBehavior = ScrollableDefaults.flingBehavior()
)
.padding(12.dp)
) {
AndroidView(
factory = { context ->
TextView(context).apply {
movementMethod = LinkMovementMethod.getInstance()
setSpannableFactory(NoCopySpannableFactory.getInstance())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
}
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
},
update = {
Markwon.create(it.context).setMarkdown(it, content)
it.setTextColor(contentColor.toArgb())
}
)
}
}

View file

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

View file

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

View file

@ -0,0 +1,28 @@
package com.sukisu.ultra.ui.component
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.onKeyEvent
@Composable
fun KeyEventBlocker(predicate: (KeyEvent) -> Boolean) {
val requester = remember { FocusRequester() }
Box(
Modifier
.onKeyEvent {
predicate(it)
}
.focusRequester(requester)
.focusable()
)
LaunchedEffect(Unit) {
requester.requestFocus()
}
}

View file

@ -0,0 +1,17 @@
package com.sukisu.ultra.ui.component
import androidx.compose.runtime.Composable
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ksuApp
@Composable
fun KsuIsValid(
content: @Composable () -> Unit
) {
val isManager = Natives.isManager
val ksuVersion = if (isManager) Natives.version else null
if (ksuVersion != null) {
content()
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,58 @@
package com.sukisu.ultra.ui.component.profile
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.SwitchItem
@Composable
fun AppProfileConfig(
modifier: Modifier = Modifier,
fixedName: Boolean,
enabled: Boolean,
profile: Natives.Profile,
onProfileChange: (Natives.Profile) -> Unit,
) {
Column(modifier = modifier) {
if (!fixedName) {
OutlinedTextField(
label = { Text(stringResource(R.string.profile_name)) },
value = profile.name,
onValueChange = { onProfileChange(profile.copy(name = it)) }
)
}
SwitchItem(
title = stringResource(R.string.profile_umount_modules),
summary = stringResource(R.string.profile_umount_modules_summary),
checked = if (enabled) {
profile.umountModules
} else {
Natives.isDefaultUmountModules()
},
enabled = enabled,
onCheckedChange = {
onProfileChange(
profile.copy(
umountModules = it,
nonRootUseDefault = false
)
)
}
)
}
}
@Preview
@Composable
private fun AppProfileConfigPreview() {
var profile by remember { mutableStateOf(Natives.Profile("")) }
AppProfileConfig(fixedName = true, enabled = false, profile = profile) {
profile = it
}
}

View file

@ -0,0 +1,481 @@
package com.sukisu.ultra.ui.component.profile
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.text.isDigitsOnly
import com.maxkeppeker.sheets.core.models.base.Header
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
import com.maxkeppeler.sheets.input.InputDialog
import com.maxkeppeler.sheets.input.models.*
import com.maxkeppeler.sheets.list.ListDialog
import com.maxkeppeler.sheets.list.models.ListOption
import com.maxkeppeler.sheets.list.models.ListSelection
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.profile.Capabilities
import com.sukisu.ultra.profile.Groups
import com.sukisu.ultra.ui.component.rememberCustomDialog
import com.sukisu.ultra.ui.util.isSepolicyValid
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootProfileConfig(
modifier: Modifier = Modifier,
fixedName: Boolean,
profile: Natives.Profile,
onProfileChange: (Natives.Profile) -> Unit,
) {
Column(modifier = modifier) {
if (!fixedName) {
OutlinedTextField(
label = { Text(stringResource(R.string.profile_name)) },
value = profile.name,
onValueChange = { onProfileChange(profile.copy(name = it)) }
)
}
/*
var expanded by remember { mutableStateOf(false) }
val currentNamespace = when (profile.namespace) {
Natives.Profile.Namespace.INHERITED.ordinal -> stringResource(R.string.profile_namespace_inherited)
Natives.Profile.Namespace.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global)
Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual)
else -> stringResource(R.string.profile_namespace_inherited)
}
ListItem(headlineContent = {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
readOnly = true,
label = { Text(stringResource(R.string.profile_namespace)) },
value = currentNamespace,
onValueChange = {},
trailingIcon = {
if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
else Icon(Icons.Filled.ArrowDropDown, null)
},
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_inherited)) },
onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal))
expanded = false
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_global)) },
onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal))
expanded = false
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.profile_namespace_individual)) },
onClick = {
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal))
expanded = false
},
)
}
}
})
*/
UidPanel(uid = profile.uid, label = "uid", onUidChange = {
onProfileChange(
profile.copy(
uid = it,
rootUseDefault = false
)
)
})
UidPanel(uid = profile.gid, label = "gid", onUidChange = {
onProfileChange(
profile.copy(
gid = it,
rootUseDefault = false
)
)
})
val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e ->
e.mapNotNull { g ->
Groups.entries.find { it.gid == g }
}
}
GroupsPanel(selectedGroups) {
onProfileChange(
profile.copy(
groups = it.map { group -> group.gid }.ifEmpty { listOf(0) },
rootUseDefault = false
)
)
}
val selectedCaps = profile.capabilities.mapNotNull { e ->
Capabilities.entries.find { it.cap == e }
}
CapsPanel(selectedCaps) {
onProfileChange(
profile.copy(
capabilities = it.map { cap -> cap.cap },
rootUseDefault = false
)
)
}
SELinuxPanel(profile = profile, onSELinuxChange = { domain, rules ->
onProfileChange(
profile.copy(
context = domain,
rules = rules,
rootUseDefault = false
)
)
})
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) {
val selectGroupsDialog = rememberCustomDialog { dismiss: () -> Unit ->
val groups = Groups.entries.toTypedArray().sortedWith(
compareBy<Groups> { if (selected.contains(it)) 0 else 1 }
.then(compareBy {
when (it) {
Groups.ROOT -> 0
Groups.SYSTEM -> 1
Groups.SHELL -> 2
else -> Int.MAX_VALUE
}
})
.then(compareBy { it.name })
)
val options = groups.map { value ->
ListOption(
titleText = value.display,
subtitleText = value.desc,
selected = selected.contains(value),
)
}
val selection = HashSet(selected)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
ListDialog(
state = rememberUseCaseState(visible = true, onFinishedRequest = {
closeSelection(selection)
}, onCloseRequest = {
dismiss()
}),
header = Header.Default(
title = stringResource(R.string.profile_groups),
),
selection = ListSelection.Multiple(
showCheckBoxes = true,
options = options,
maxChoices = 32, // Kernel only supports 32 groups at most
) { indecies, _ ->
// Handle selection
selection.clear()
indecies.forEach { index ->
val group = groups[index]
selection.add(group)
}
}
)
}
}
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.clickable {
selectGroupsDialog.show()
}
.padding(16.dp)
) {
Text(stringResource(R.string.profile_groups))
FlowRow {
selected.forEach { group ->
AssistChip(
modifier = Modifier.padding(3.dp),
onClick = { /*TODO*/ },
label = { Text(group.display) })
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun CapsPanel(
selected: Collection<Capabilities>,
closeSelection: (selection: Set<Capabilities>) -> Unit
) {
val selectCapabilitiesDialog = rememberCustomDialog { dismiss ->
val caps = Capabilities.entries.toTypedArray().sortedWith(
compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 }
.then(compareBy { it.name })
)
val options = caps.map { value ->
ListOption(
titleText = value.display,
subtitleText = value.desc,
selected = selected.contains(value),
)
}
val selection = HashSet(selected)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
ListDialog(
state = rememberUseCaseState(visible = true, onFinishedRequest = {
closeSelection(selection)
}, onCloseRequest = {
dismiss()
}),
header = Header.Default(
title = stringResource(R.string.profile_capabilities),
),
selection = ListSelection.Multiple(
showCheckBoxes = true,
options = options
) { indecies, _ ->
// Handle selection
selection.clear()
indecies.forEach { index ->
val group = caps[index]
selection.add(group)
}
}
)
}
}
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.clickable {
selectCapabilitiesDialog.show()
}
.padding(16.dp)
) {
Text(stringResource(R.string.profile_capabilities))
FlowRow {
selected.forEach { group ->
AssistChip(
modifier = Modifier.padding(3.dp),
onClick = { /*TODO*/ },
label = { Text(group.display) })
}
}
}
}
}
@Composable
private fun UidPanel(uid: Int, label: String, onUidChange: (Int) -> Unit) {
ListItem(headlineContent = {
var isError by remember {
mutableStateOf(false)
}
var lastValidUid by remember {
mutableIntStateOf(uid)
}
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
label = { Text(label) },
value = uid.toString(),
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
onValueChange = {
if (it.isEmpty()) {
onUidChange(0)
return@OutlinedTextField
}
val valid = isTextValidUid(it)
val targetUid = if (valid) it.toInt() else lastValidUid
if (valid) {
lastValidUid = it.toInt()
}
onUidChange(targetUid)
isError = !valid
}
)
})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SELinuxPanel(
profile: Natives.Profile,
onSELinuxChange: (domain: String, rules: String) -> Unit
) {
val editSELinuxDialog = rememberCustomDialog { dismiss ->
var domain by remember { mutableStateOf(profile.context) }
var rules by remember { mutableStateOf(profile.rules) }
val inputOptions = listOf(
InputTextField(
text = domain,
header = InputHeader(
title = stringResource(id = R.string.profile_selinux_domain),
),
type = InputTextFieldType.OUTLINED,
required = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next
),
resultListener = {
domain = it ?: ""
},
validationListener = { value ->
// value can be a-zA-Z0-9_
val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$")
if (value?.matches(regex) == true) ValidationResult.Valid
else ValidationResult.Invalid("Domain must be in the format of \"user:role:type:level\"")
}
),
InputTextField(
text = rules,
header = InputHeader(
title = stringResource(id = R.string.profile_selinux_rules),
),
type = InputTextFieldType.OUTLINED,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
),
singleLine = false,
resultListener = {
rules = it ?: ""
},
validationListener = { value ->
if (isSepolicyValid(value)) ValidationResult.Valid
else ValidationResult.Invalid("SELinux rules is invalid!")
}
)
)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
InputDialog(
state = rememberUseCaseState(
visible = true,
onFinishedRequest = {
onSELinuxChange(domain, rules)
},
onCloseRequest = {
dismiss()
}),
header = Header.Default(
title = stringResource(R.string.profile_selinux_context),
),
selection = InputSelection(
input = inputOptions,
onPositiveClick = { result ->
// Handle selection
},
)
)
}
}
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
}

View file

@ -0,0 +1,105 @@
package com.sukisu.ultra.ui.component.profile
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ReadMore
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.Create
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.listAppProfileTemplates
import com.sukisu.ultra.ui.util.setSepolicy
import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById
/**
* @author weishu
* @date 2023/10/21.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TemplateConfig(
profile: Natives.Profile,
onViewTemplate: (id: String) -> Unit = {},
onManageTemplate: () -> Unit = {},
onProfileChange: (Natives.Profile) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var template by rememberSaveable {
mutableStateOf(profile.rootTemplate ?: "")
}
val profileTemplates = listAppProfileTemplates()
val noTemplates = profileTemplates.isEmpty()
ListItem(headlineContent = {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
OutlinedTextField(
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
readOnly = true,
label = { Text(stringResource(R.string.profile_template)) },
value = template.ifEmpty { "None" },
onValueChange = {},
trailingIcon = {
if (noTemplates) {
IconButton(
onClick = onManageTemplate
) {
Icon(Icons.Filled.Create, null)
}
} else if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
else Icon(Icons.Filled.ArrowDropDown, null)
},
)
if (profileTemplates.isEmpty()) {
return@ExposedDropdownMenuBox
}
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
profileTemplates.forEach { tid ->
val templateInfo =
getTemplateInfoById(tid) ?: return@forEach
DropdownMenuItem(
text = { Text(tid) },
onClick = {
template = tid
if (setSepolicy(tid, templateInfo.rules.joinToString("\n"))) {
onProfileChange(
profile.copy(
rootTemplate = tid,
rootUseDefault = false,
uid = templateInfo.uid,
gid = templateInfo.gid,
groups = templateInfo.groups,
capabilities = templateInfo.capabilities,
context = templateInfo.context,
namespace = templateInfo.namespace,
)
)
}
expanded = false
},
trailingIcon = {
IconButton(onClick = {
onViewTemplate(tid)
}) {
Icon(Icons.AutoMirrored.Filled.ReadMore, null)
}
}
)
}
}
}
})
}

View file

@ -0,0 +1,586 @@
package com.sukisu.ultra.ui.screen
import android.annotation.SuppressLint
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.dropUnlessResumed
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination
import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.SwitchItem
import com.sukisu.ultra.ui.component.profile.AppProfileConfig
import com.sukisu.ultra.ui.component.profile.RootProfileConfig
import com.sukisu.ultra.ui.component.profile.TemplateConfig
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.viewmodel.SuperUserViewModel
import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById
import kotlinx.coroutines.launch
/**
* @author weishu
* @date 2023/5/16.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun AppProfileScreen(
navigator: DestinationsNavigator,
appInfo: SuperUserViewModel.AppInfo,
) {
val context = LocalContext.current
val snackBarHost = LocalSnackbarHost.current
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val scope = rememberCoroutineScope()
val failToUpdateAppProfile = stringResource(R.string.failed_to_update_app_profile).format(appInfo.label)
val failToUpdateSepolicy = stringResource(R.string.failed_to_update_sepolicy).format(appInfo.label)
val suNotAllowed = stringResource(R.string.su_not_allowed).format(appInfo.label)
val packageName = appInfo.packageName
val initialProfile = Natives.getAppProfile(packageName, appInfo.uid)
if (initialProfile.allowSu) {
initialProfile.rules = getSepolicy(packageName)
}
var profile by rememberSaveable {
mutableStateOf(initialProfile)
}
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
val cardAlpha = CardConfig.cardAlpha
Scaffold(
topBar = {
TopBar(
title = appInfo.label,
packageName = packageName,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
),
onBack = dropUnlessResumed { navigator.popBackStack() },
scrollBehavior = scrollBehavior
)
},
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
) { paddingValues ->
AppProfileInner(
modifier = Modifier
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection)
.verticalScroll(rememberScrollState()),
packageName = appInfo.packageName,
appLabel = appInfo.label,
appIcon = {
AsyncImage(
model = ImageRequest.Builder(context).data(appInfo.packageInfo).crossfade(true).build(),
contentDescription = appInfo.label,
modifier = Modifier
.padding(4.dp)
.width(48.dp)
.height(48.dp)
)
},
profile = profile,
onViewTemplate = {
getTemplateInfoById(it)?.let { info ->
navigator.navigate(TemplateEditorScreenDestination(info))
}
},
onManageTemplate = {
navigator.navigate(AppProfileTemplateScreenDestination())
},
onProfileChange = {
scope.launch {
if (it.allowSu) {
// sync with allowlist.c - forbid_system_uid
if (appInfo.uid < 2000 && appInfo.uid != 1000) {
snackBarHost.showSnackbar(suNotAllowed)
return@launch
}
if (!it.rootUseDefault && it.rules.isNotEmpty() && !setSepolicy(profile.name, it.rules)) {
snackBarHost.showSnackbar(failToUpdateSepolicy)
return@launch
}
}
if (!Natives.setAppProfile(it)) {
snackBarHost.showSnackbar(failToUpdateAppProfile.format(appInfo.uid))
} else {
profile = it
}
}
},
)
}
}
@Composable
private fun AppProfileInner(
modifier: Modifier = Modifier,
packageName: String,
appLabel: String,
appIcon: @Composable () -> Unit,
profile: Natives.Profile,
onViewTemplate: (id: String) -> Unit = {},
onManageTemplate: () -> Unit = {},
onProfileChange: (Natives.Profile) -> Unit,
) {
val isRootGranted = profile.allowSu
val cardColors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = modifier) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.medium,
colors = cardColors,
elevation = getCardElevation(),
) {
AppMenuBox(packageName) {
ListItem(
headlineContent = {
Text(
text = appLabel,
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
text = packageName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = appIcon,
)
}
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.medium,
colors = cardColors,
elevation = getCardElevation(),
) {
SwitchItem(
icon = Icons.Filled.Security,
title = stringResource(id = R.string.superuser),
checked = isRootGranted,
onCheckedChange = { onProfileChange(profile.copy(allowSu = it)) },
)
}
Crossfade(
targetState = isRootGranted,
label = "RootAccess"
) { current ->
Column(
modifier = Modifier.padding(bottom = 6.dp + 48.dp + 6.dp /* SnackBar height */)
) {
if (current) {
val initialMode = if (profile.rootUseDefault) {
Mode.Default
} else if (profile.rootTemplate != null) {
Mode.Template
} else {
Mode.Custom
}
var mode by rememberSaveable {
mutableStateOf(initialMode)
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.medium,
colors = cardColors,
elevation = getCardElevation(),
) {
ProfileBox(mode, true) {
// template mode shouldn't change profile here!
if (it == Mode.Default || it == Mode.Custom) {
onProfileChange(
profile.copy(
rootUseDefault = it == Mode.Default,
rootTemplate = null
)
)
}
mode = it
}
}
AnimatedVisibility(
visible = mode != Mode.Default,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.medium,
colors = cardColors,
elevation = getCardElevation(),
) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
Crossfade(
targetState = mode,
label = "ProfileMode"
) { currentMode ->
when (currentMode) {
Mode.Template -> {
TemplateConfig(
profile = profile,
onViewTemplate = onViewTemplate,
onManageTemplate = onManageTemplate,
onProfileChange = onProfileChange
)
}
Mode.Custom -> {
RootProfileConfig(
fixedName = true,
profile = profile,
onProfileChange = onProfileChange
)
}
else -> {}
}
}
}
}
}
} else {
val mode = if (profile.nonRootUseDefault) Mode.Default else Mode.Custom
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.medium,
colors = cardColors,
elevation = getCardElevation(),
) {
ProfileBox(mode, false) {
onProfileChange(profile.copy(nonRootUseDefault = (it == Mode.Default)))
}
}
AnimatedVisibility(
visible = mode == Mode.Custom,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.medium,
colors = cardColors,
elevation = getCardElevation(),
) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
AppProfileConfig(
fixedName = true,
profile = profile,
enabled = mode == Mode.Custom,
onProfileChange = onProfileChange
)
}
}
}
}
}
}
}
}
}
private enum class Mode(@param:StringRes private val res: Int) {
Default(R.string.profile_default), Template(R.string.profile_template), Custom(R.string.profile_custom);
val text: String
@Composable get() = stringResource(res)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
title: String,
packageName: String,
onBack: () -> Unit,
colors: TopAppBarColors,
scrollBehavior: TopAppBarScrollBehavior? = null
) {
TopAppBar(
title = {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = packageName,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(0.8f)
)
}
},
colors = colors,
navigationIcon = {
IconButton(
onClick = onBack,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
windowInsets = WindowInsets.safeDrawing.only(
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
),
scrollBehavior = scrollBehavior,
modifier = Modifier.shadow(
elevation = if ((scrollBehavior?.state?.overlappedFraction ?: 0f) > 0.01f)
4.dp else 0.dp,
)
)
}
@Composable
private fun ProfileBox(
mode: Mode,
hasTemplate: Boolean,
onModeChange: (Mode) -> Unit,
) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
ListItem(
headlineContent = {
Text(
text = stringResource(R.string.profile),
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
text = mode.text,
style = MaterialTheme.typography.bodyMedium,
)
},
leadingContent = {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
)
},
)
HorizontalDivider(
thickness = Dp.Hairline,
)
ListItem(
headlineContent = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) {
FilterChip(
selected = mode == Mode.Default,
onClick = { onModeChange(Mode.Default) },
label = {
Text(
text = stringResource(R.string.profile_default),
style = MaterialTheme.typography.bodyMedium
)
},
shape = MaterialTheme.shapes.small
)
if (hasTemplate) {
FilterChip(
selected = mode == Mode.Template,
onClick = { onModeChange(Mode.Template) },
label = {
Text(
text = stringResource(R.string.profile_template),
style = MaterialTheme.typography.bodyMedium
)
},
shape = MaterialTheme.shapes.small
)
}
FilterChip(
selected = mode == Mode.Custom,
onClick = { onModeChange(Mode.Custom) },
label = {
Text(
text = stringResource(R.string.profile_custom),
style = MaterialTheme.typography.bodyMedium
)
},
shape = MaterialTheme.shapes.small
)
}
}
)
}
}
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
private fun AppMenuBox(
packageName: String,
content: @Composable () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var touchPoint: Offset by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
BoxWithConstraints(
Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
touchPoint = it
expanded = true
}
)
}
) {
content()
val (offsetX, offsetY) = with(density) {
(touchPoint.x.toDp()) to (-touchPoint.y.toDp())
}
DropdownMenu(
expanded = expanded,
offset = DpOffset(offsetX, offsetY),
onDismissRequest = {
expanded = false
}
) {
AppMenuOption(
text = stringResource(id = R.string.launch_app),
onClick = {
expanded = false
launchApp(packageName)
}
)
AppMenuOption(
text = stringResource(id = R.string.force_stop_app),
onClick = {
expanded = false
forceStopApp(packageName)
}
)
AppMenuOption(
text = stringResource(id = R.string.restart_app),
onClick = {
expanded = false
restartApp(packageName)
}
)
}
}
}
@Composable
private fun AppMenuOption(text: String, onClick: () -> Unit) {
DropdownMenuItem(
text = {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
},
onClick = onClick
)
}
@Preview
@Composable
private fun AppProfilePreview() {
var profile by remember { mutableStateOf(Natives.Profile("")) }
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Surface {
AppProfileInner(
packageName = "icu.nullptr.test",
appLabel = "Test",
appIcon = {
Icon(
imageVector = Icons.Filled.Android,
contentDescription = null,
)
},
profile = profile,
onProfileChange = {
profile = it
},
)
}
}
}

View file

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

View file

@ -0,0 +1,147 @@
package com.sukisu.ultra.ui.screen
import android.os.Environment
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.KeyEventBlocker
import com.sukisu.ultra.ui.util.LocalSnackbarHost
import com.sukisu.ultra.ui.util.runModuleAction
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@Composable
@Destination<RootGraph>
fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) {
var text by rememberSaveable { mutableStateOf("") }
var tempText : String
val logContent = rememberSaveable { StringBuilder() }
val snackBarHost = LocalSnackbarHost.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var isActionRunning by rememberSaveable { mutableStateOf(true) }
BackHandler(enabled = isActionRunning) {
// Disable back button if action is running
}
LaunchedEffect(Unit) {
if (text.isNotEmpty()) {
return@LaunchedEffect
}
withContext(Dispatchers.IO) {
runModuleAction(
moduleId = moduleId,
onStdout = {
tempText = "$it\n"
if (tempText.startsWith("")) { // clear command
text = tempText.substring(6)
} else {
text += tempText
}
logContent.append(it).append("\n")
},
onStderr = {
logContent.append(it).append("\n")
}
)
}
isActionRunning = false
}
Scaffold(
topBar = {
TopBar(
isActionRunning = isActionRunning,
onSave = {
if (!isActionRunning) {
scope.launch {
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
val date = format.format(Date())
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"KernelSU_module_action_log_${date}.log"
)
file.writeText(logContent.toString())
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
}
}
}
)
},
floatingActionButton = {
if (!isActionRunning) {
ExtendedFloatingActionButton(
text = { Text(text = stringResource(R.string.close)) },
icon = { Icon(Icons.Filled.Close, contentDescription = null) },
onClick = {
navigator.popBackStack()
}
)
}
},
contentWindowInsets = WindowInsets.safeDrawing,
snackbarHost = { SnackbarHost(snackBarHost) }
) { innerPadding ->
KeyEventBlocker {
it.key == Key.VolumeDown || it.key == Key.VolumeUp
}
Column(
modifier = Modifier
.fillMaxSize(1f)
.padding(innerPadding)
.verticalScroll(scrollState),
) {
LaunchedEffect(text) {
scrollState.animateScrollTo(scrollState.maxValue)
}
Text(
modifier = Modifier.padding(8.dp),
text = text,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
fontFamily = FontFamily.Monospace,
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(isActionRunning: Boolean, onSave: () -> Unit = {}) {
TopAppBar(
title = { Text(stringResource(R.string.action)) },
actions = {
IconButton(
onClick = onSave,
enabled = !isActionRunning
) {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = stringResource(id = R.string.save_log),
)
}
}
)
}

View file

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

View file

@ -0,0 +1,925 @@
package com.sukisu.ultra.ui.screen
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.os.PowerManager
import android.system.Os
import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.TaskAlt
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.pm.PackageInfoCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.InstallScreenDestination
import com.ramcosta.composedestinations.generated.destinations.SuSFSConfigScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.KernelVersion
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.KsuIsValid
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
import com.sukisu.ultra.ui.theme.getCardColors
import com.sukisu.ultra.ui.theme.getCardElevation
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
import com.sukisu.ultra.ui.util.checkNewVersion
import com.sukisu.ultra.ui.util.getSuSFSVersion
import com.sukisu.ultra.ui.util.module.LatestVersionInfo
import com.sukisu.ultra.ui.util.reboot
import com.sukisu.ultra.ui.viewmodel.HomeViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.random.Random
/**
* @author ShirkNeko
* @date 2025/9/29.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Destination<RootGraph>(start = true)
@Composable
fun HomeScreen(navigator: DestinationsNavigator) {
val context = LocalContext.current
val viewModel = viewModel<HomeViewModel>()
val coroutineScope = rememberCoroutineScope()
val pullRefreshState = rememberPullRefreshState(
refreshing = viewModel.isRefreshing,
onRefresh = {
viewModel.onPullRefresh(context)
}
)
LaunchedEffect(key1 = navigator) {
viewModel.loadUserSettings(context)
coroutineScope.launch {
viewModel.loadCoreData()
delay(100)
viewModel.loadExtendedData(context)
}
// 启动数据变化监听
coroutineScope.launch {
while (true) {
delay(5000) // 每5秒检查一次
viewModel.autoRefreshIfNeeded(context)
}
}
}
// 监听数据刷新状态流
LaunchedEffect(viewModel.dataRefreshTrigger) {
viewModel.dataRefreshTrigger.collect { _ ->
// 数据刷新时的额外处理可以在这里添加
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val scrollState = rememberScrollState()
Scaffold(
topBar = {
TopBar(
scrollBehavior = scrollBehavior,
navigator = navigator,
isDataLoaded = viewModel.isCoreDataLoaded
)
},
contentWindowInsets = WindowInsets.safeDrawing.only(
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
)
) { innerPadding ->
Box(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = 12.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 状态卡片
if (viewModel.isCoreDataLoaded) {
StatusCard(
systemStatus = viewModel.systemStatus,
onClickInstall = {
navigator.navigate(InstallScreenDestination(preselectedKernelUri = null))
}
)
// 警告信息
if (viewModel.systemStatus.requireNewKernel) {
WarningCard(
stringResource(id = R.string.require_kernel_version).format(
Natives.getSimpleVersionFull(),
Natives.MINIMAL_SUPPORTED_KERNEL_FULL
)
)
}
if (viewModel.systemStatus.ksuVersion != null && !viewModel.systemStatus.isRootAvailable) {
WarningCard(
stringResource(id = R.string.grant_root_failed)
)
}
// 只有在没有其他警告信息时才显示不兼容内核警告
val shouldShowWarnings = viewModel.systemStatus.requireNewKernel ||
(viewModel.systemStatus.ksuVersion != null && !viewModel.systemStatus.isRootAvailable)
if (Natives.version <= Natives.MINIMAL_NEW_IOCTL_KERNEL && !shouldShowWarnings && viewModel.systemStatus.ksuVersion != null) {
IncompatibleKernelCard()
Spacer(Modifier.height(12.dp))
}
}
// 更新检查
if (viewModel.isExtendedDataLoaded) {
val checkUpdate = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
.getBoolean("check_update", true)
if (checkUpdate) {
UpdateCard()
}
// 信息卡片
InfoCard(
systemInfo = viewModel.systemInfo,
isSimpleMode = viewModel.isSimpleMode,
isHideSusfsStatus = viewModel.isHideSusfsStatus,
isHideZygiskImplement = viewModel.isHideZygiskImplement,
showKpmInfo = viewModel.showKpmInfo,
lkmMode = viewModel.systemStatus.lkmMode,
)
// 链接卡片
if (!viewModel.isSimpleMode && !viewModel.isHideLinkCard) {
ContributionCard()
DonateCard()
LearnMoreCard()
}
}
if (!viewModel.isExtendedDataLoaded) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
Spacer(Modifier.height(16.dp))
}
}
}
}
@Composable
fun UpdateCard() {
val context = LocalContext.current
val latestVersionInfo = LatestVersionInfo()
val newVersion by produceState(initialValue = latestVersionInfo) {
value = withContext(Dispatchers.IO) {
checkNewVersion()
}
}
val currentVersionCode = getManagerVersion(context).second
val newVersionCode = newVersion.versionCode
val newVersionUrl = newVersion.downloadUrl
val changelog = newVersion.changelog
val uriHandler = LocalUriHandler.current
val title = stringResource(id = R.string.module_changelog)
val updateText = stringResource(id = R.string.module_update)
AnimatedVisibility(
visible = newVersionCode > currentVersionCode,
enter = fadeIn() + expandVertically(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
exit = shrinkVertically() + fadeOut()
) {
val updateDialog = rememberConfirmDialog(onConfirm = { uriHandler.openUri(newVersionUrl) })
WarningCard(
message = stringResource(id = R.string.new_version_available).format(newVersionCode),
color = MaterialTheme.colorScheme.outlineVariant,
onClick = {
if (changelog.isEmpty()) {
uriHandler.openUri(newVersionUrl)
} else {
updateDialog.showConfirm(
title = title,
content = changelog,
markdown = true,
confirm = updateText
)
}
}
)
}
}
@Composable
fun RebootDropdownItem(@StringRes id: Int, reason: String = "") {
DropdownMenuItem(
text = { Text(stringResource(id)) },
onClick = { reboot(reason) })
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
scrollBehavior: TopAppBarScrollBehavior? = null,
navigator: DestinationsNavigator,
isDataLoaded: Boolean = false
) {
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
TopAppBar(
title = {
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
),
actions = {
if (isDataLoaded) {
// SuSFS 配置按钮
val susfsVersion = getSuSFSVersion()
if (susfsVersion.isNotEmpty() && !susfsVersion.startsWith("[-]") && SuSFSManager.isBinaryAvailable(context)) {
IconButton(onClick = {
navigator.navigate(SuSFSConfigScreenDestination)
}) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = stringResource(R.string.susfs_config_setting_title)
)
}
}
// 重启按钮
var showDropdown by remember { mutableStateOf(false) }
KsuIsValid {
IconButton(onClick = {
showDropdown = true
}) {
Icon(
imageVector = Icons.Filled.PowerSettingsNew,
contentDescription = stringResource(id = R.string.reboot)
)
DropdownMenu(expanded = showDropdown, onDismissRequest = {
showDropdown = false
}) {
RebootDropdownItem(id = R.string.reboot)
val pm =
LocalContext.current.getSystemService(Context.POWER_SERVICE) as PowerManager?
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && pm?.isRebootingUserspaceSupported == true) {
RebootDropdownItem(id = R.string.reboot_userspace, reason = "userspace")
}
RebootDropdownItem(id = R.string.reboot_recovery, reason = "recovery")
RebootDropdownItem(id = R.string.reboot_bootloader, reason = "bootloader")
RebootDropdownItem(id = R.string.reboot_download, reason = "download")
RebootDropdownItem(id = R.string.reboot_edl, reason = "edl")
}
}
}
}
},
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior
)
}
@Composable
private fun StatusCard(
systemStatus: HomeViewModel.SystemStatus,
onClickInstall: () -> Unit = {}
) {
ElevatedCard(
colors = getCardColors(
if (systemStatus.ksuVersion != null) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.errorContainer
),
elevation = getCardElevation(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (systemStatus.isRootAvailable || systemStatus.kernelVersion.isGKI()) {
onClickInstall()
}
}
.padding(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
when {
systemStatus.ksuVersion != null -> {
val workingModeText = when {
Natives.isSafeMode -> stringResource(id = R.string.safe_mode)
else -> stringResource(id = R.string.home_working)
}
val workingModeSurfaceText = when {
systemStatus.lkmMode == true -> "LKM"
else -> "Built-in"
}
Icon(
Icons.Outlined.TaskAlt,
contentDescription = stringResource(R.string.home_working),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(28.dp)
.padding(
horizontal = 4.dp
),
)
Column(Modifier.padding(start = 20.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = workingModeText,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
// 工作模式标签
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
) {
Text(
text = workingModeSurfaceText,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = MaterialTheme.colorScheme.onPrimary
)
}
Spacer(Modifier.width(6.dp))
// 架构标签
if (Os.uname().machine != "aarch64") {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
) {
Text(
text = Os.uname().machine,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(
horizontal = 6.dp,
vertical = 2.dp
),
color = MaterialTheme.colorScheme.onPrimary
)
}
}
}
val isHideVersion = LocalContext.current.getSharedPreferences(
"settings",
Context.MODE_PRIVATE
)
.getBoolean("is_hide_version", false)
if (!isHideVersion) {
Spacer(Modifier.height(4.dp))
systemStatus.ksuFullVersion?.let {
Text(
text = stringResource(R.string.home_working_version, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
systemStatus.kernelVersion.isGKI() -> {
Icon(
Icons.Outlined.Warning,
contentDescription = stringResource(R.string.home_not_installed),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.size(28.dp)
.padding(
horizontal = 4.dp
),
)
Column(Modifier.padding(start = 20.dp)) {
Text(
text = stringResource(R.string.home_not_installed),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.home_click_to_install),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
else -> {
Icon(
Icons.Outlined.Block,
contentDescription = stringResource(R.string.home_unsupported),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.size(28.dp)
.padding(
horizontal = 4.dp
),
)
Column(Modifier.padding(start = 20.dp)) {
Text(
text = stringResource(R.string.home_unsupported),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.home_unsupported_reason),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
@Composable
fun WarningCard(
message: String,
color: Color = MaterialTheme.colorScheme.error,
onClick: (() -> Unit)? = null
) {
ElevatedCard(
colors = getCardColors(color),
elevation = getCardElevation(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(onClick?.let { Modifier.clickable { it() } } ?: Modifier)
.padding(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
@Composable
fun ContributionCard() {
val uriHandler = LocalUriHandler.current
val links = listOf("https://github.com/ShirkNeko", "https://github.com/udochina")
ElevatedCard(
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainer),
elevation = getCardElevation(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val randomIndex = Random.nextInt(links.size)
uriHandler.openUri(links[randomIndex])
}
.padding(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = stringResource(R.string.home_ContributionCard_kernelsu),
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.home_click_to_ContributionCard_kernelsu),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
fun LearnMoreCard() {
val uriHandler = LocalUriHandler.current
val url = stringResource(R.string.home_learn_kernelsu_url)
ElevatedCard(
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainer),
elevation = CardDefaults.cardElevation(defaultElevation = cardElevation)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
uriHandler.openUri(url)
}
.padding(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = stringResource(R.string.home_learn_kernelsu),
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.home_click_to_learn_kernelsu),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
fun DonateCard() {
val uriHandler = LocalUriHandler.current
ElevatedCard(
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainer),
elevation = getCardElevation(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
uriHandler.openUri("https://patreon.com/weishu")
}
.padding(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = stringResource(R.string.home_support_title),
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.home_support_content),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
@Composable
private fun InfoCard(
systemInfo: HomeViewModel.SystemInfo,
isSimpleMode: Boolean,
isHideSusfsStatus: Boolean,
isHideZygiskImplement: Boolean,
showKpmInfo: Boolean,
lkmMode: Boolean?
) {
ElevatedCard(
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainer),
elevation = getCardElevation(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 16.dp),
) {
@Composable
fun InfoCardItem(
label: String,
content: String,
icon: ImageVector? = null,
) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier
.size(28.dp)
.padding(vertical = 4.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
)
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
softWrap = true
)
}
}
}
InfoCardItem(
stringResource(R.string.home_kernel),
systemInfo.kernelRelease,
icon = Icons.Default.Memory,
)
if (!isSimpleMode) {
InfoCardItem(
stringResource(R.string.home_android_version),
systemInfo.androidVersion,
icon = Icons.Default.Android,
)
}
InfoCardItem(
stringResource(R.string.home_device_model),
systemInfo.deviceModel,
icon = Icons.Default.PhoneAndroid,
)
InfoCardItem(
stringResource(R.string.home_manager_version),
"${systemInfo.managerVersion.first} (${systemInfo.managerVersion.second.toInt()})",
icon = Icons.Default.SettingsSuggest,
)
if (!isSimpleMode &&
(systemInfo.suSFSStatus != "Supported")) {
InfoCardItem(
stringResource(R.string.home_hook_type),
Natives.getHookType(),
icon = Icons.Default.Link
)
}
// 活跃管理器
if (!isSimpleMode && systemInfo.isDynamicSignEnabled && systemInfo.managersList != null) {
val signatureMap = systemInfo.managersList.managers.groupBy { it.signatureIndex }
val managersText = buildString {
signatureMap.toSortedMap().forEach { (signatureIndex, managers) ->
append(managers.joinToString(", ") { "UID: ${it.uid}" })
append(" ")
append(
when (signatureIndex) {
0 -> "(${stringResource(R.string.default_signature)})"
100 -> "(${stringResource(R.string.dynamic_managerature)})"
else -> if (signatureIndex >= 1) "(${
stringResource(
R.string.signature_index,
signatureIndex
)
})" else "(${stringResource(R.string.unknown_signature)})"
}
)
append(" | ")
}
}.trimEnd(' ', '|')
InfoCardItem(
stringResource(R.string.multi_manager_list),
managersText.ifEmpty { stringResource(R.string.no_active_manager) },
icon = Icons.Default.Group,
)
}
InfoCardItem(
stringResource(R.string.home_selinux_status),
systemInfo.seLinuxStatus,
icon = Icons.Default.Security,
)
if (!isHideZygiskImplement && !isSimpleMode && systemInfo.zygiskImplement != "None") {
InfoCardItem(
stringResource(R.string.home_zygisk_implement),
systemInfo.zygiskImplement,
icon = Icons.Default.Adb,
)
}
if (!isSimpleMode) {
if (lkmMode != true && !showKpmInfo) {
val displayVersion =
if (systemInfo.kpmVersion.isEmpty() || systemInfo.kpmVersion.startsWith("Error")) {
val statusText = if (Natives.isKPMEnabled()) {
stringResource(R.string.kernel_patched)
} else {
stringResource(R.string.kernel_not_enabled)
}
"${stringResource(R.string.not_supported)} ($statusText)"
} else {
"${stringResource(R.string.supported)} (${systemInfo.kpmVersion})"
}
InfoCardItem(
stringResource(R.string.home_kpm_version),
displayVersion,
icon = Icons.Default.Archive
)
}
}
if (!isSimpleMode && !isHideSusfsStatus &&
systemInfo.suSFSStatus == "Supported" &&
systemInfo.suSFSVersion.isNotEmpty()
) {
val infoText = SuSFSInfoText(systemInfo)
InfoCardItem(
stringResource(R.string.home_susfs_version),
infoText,
icon = Icons.Default.Storage
)
}
}
}
}
@SuppressLint("ComposableNaming")
@Composable
private fun SuSFSInfoText(systemInfo: HomeViewModel.SystemInfo): String = buildString {
append(systemInfo.suSFSVersion)
when {
Natives.getHookType() == "Manual" -> {
append(" (${stringResource(R.string.manual_hook)})")
}
Natives.getHookType() == "Inline" -> {
append(" (${stringResource(R.string.inline_hook)})")
}
else -> {
append(" (${Natives.getHookType()})")
}
}
}
fun getManagerVersion(context: Context): Pair<String, Long> {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)!!
val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
return Pair(packageInfo.versionName!!, versionCode)
}
@Preview
@Composable
private fun StatusCardPreview() {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
StatusCard(
HomeViewModel.SystemStatus(
isManager = true,
ksuVersion = 1,
lkmMode = null,
kernelVersion = KernelVersion(5, 10, 101),
isRootAvailable = true
)
)
StatusCard(
HomeViewModel.SystemStatus(
isManager = true,
ksuVersion = 40000,
lkmMode = true,
kernelVersion = KernelVersion(5, 10, 101),
isRootAvailable = true
)
)
StatusCard(
HomeViewModel.SystemStatus(
isManager = false,
ksuVersion = null,
lkmMode = true,
kernelVersion = KernelVersion(5, 10, 101),
isRootAvailable = false
)
)
StatusCard(
HomeViewModel.SystemStatus(
isManager = false,
ksuVersion = null,
lkmMode = false,
kernelVersion = KernelVersion(4, 10, 101),
isRootAvailable = false
)
)
}
}
@Composable
private fun IncompatibleKernelCard() {
val currentKver = remember { Natives.version }
val threshold = Natives.MINIMAL_NEW_IOCTL_KERNEL
val msg = stringResource(
id = R.string.incompatible_kernel_msg,
currentKver,
threshold
)
WarningCard(
message = msg,
color = MaterialTheme.colorScheme.error
)
}
@Preview
@Composable
private fun WarningCardPreview() {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
WarningCard(message = "Warning message")
WarningCard(
message = "Warning message ",
MaterialTheme.colorScheme.outlineVariant,
onClick = {})
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,742 @@
package com.sukisu.ultra.ui.screen
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.sukisu.ultra.ui.component.*
import com.sukisu.ultra.ui.theme.*
import com.sukisu.ultra.ui.viewmodel.KpmViewModel
import com.sukisu.ultra.ui.util.*
import java.io.File
import androidx.core.content.edit
import com.sukisu.ultra.R
import java.io.FileInputStream
import java.net.*
import android.app.Activity
import androidx.compose.ui.res.painterResource
/**
* KPM 管理界面
* 以下内核模块功能由KernelPatch开发经过修改后加入SukiSU Ultra的内核模块功能
* 开发者ShirkNeko, Liaokong
*/
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun KpmScreen(
viewModel: KpmViewModel = viewModel()
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackBarHost = remember { SnackbarHostState() }
val confirmDialog = rememberConfirmDialog()
val listState = rememberLazyListState()
val fabVisible by rememberFabVisibilityState(listState)
val moduleConfirmContentMap = viewModel.moduleList.associate { module ->
val moduleFileName = module.id
module.id to stringResource(R.string.confirm_uninstall_content, moduleFileName)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val kpmInstallSuccess = stringResource(R.string.kpm_install_success)
val kpmInstallFailed = stringResource(R.string.kpm_install_failed)
val cancel = stringResource(R.string.cancel)
val uninstall = stringResource(R.string.uninstall)
val failedToCheckModuleFile = stringResource(R.string.snackbar_failed_to_check_module_file)
val kpmUninstallSuccess = stringResource(R.string.kpm_uninstall_success)
val kpmUninstallFailed = stringResource(R.string.kpm_uninstall_failed)
val kpmInstallMode = stringResource(R.string.kpm_install_mode)
val kpmInstallModeLoad = stringResource(R.string.kpm_install_mode_load)
val kpmInstallModeEmbed = stringResource(R.string.kpm_install_mode_embed)
val invalidFileTypeMessage = stringResource(R.string.invalid_file_type)
val confirmTitle = stringResource(R.string.confirm_uninstall_title_with_filename)
var tempFileForInstall by remember { mutableStateOf<File?>(null) }
val installModeDialog = rememberCustomDialog { dismiss ->
var moduleName by remember { mutableStateOf<String?>(null) }
LaunchedEffect(tempFileForInstall) {
tempFileForInstall?.let { tempFile ->
try {
val shell = getRootShell()
val command = "strings ${tempFile.absolutePath} | grep 'name='"
val result = shell.newJob().add(command).to(ArrayList(), null).exec()
if (result.isSuccess) {
for (line in result.out) {
if (line.startsWith("name=")) {
moduleName = line.substringAfter("name=").trim()
break
}
}
}
} catch (e: Exception) {
Log.e("KsuCli", "Failed to get module name: ${e.message}", e)
}
}
}
AlertDialog(
onDismissRequest = {
dismiss()
tempFileForInstall?.delete()
tempFileForInstall = null
},
title = {
Text(
text = kpmInstallMode,
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
Column {
moduleName?.let {
Text(
text = stringResource(R.string.kpm_install_mode_description, it),
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(16.dp))
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = {
scope.launch {
dismiss()
tempFileForInstall?.let { tempFile ->
handleModuleInstall(
tempFile = tempFile,
isEmbed = false,
viewModel = viewModel,
snackBarHost = snackBarHost,
kpmInstallSuccess = kpmInstallSuccess,
kpmInstallFailed = kpmInstallFailed
)
}
tempFileForInstall = null
}
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp)
)
Text(kpmInstallModeLoad)
}
Button(
onClick = {
scope.launch {
dismiss()
tempFileForInstall?.let { tempFile ->
handleModuleInstall(
tempFile = tempFile,
isEmbed = true,
viewModel = viewModel,
snackBarHost = snackBarHost,
kpmInstallSuccess = kpmInstallSuccess,
kpmInstallFailed = kpmInstallFailed
)
}
tempFileForInstall = null
}
},
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Inventory,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 4.dp)
)
Text(kpmInstallModeEmbed)
}
}
}
},
confirmButton = {
},
dismissButton = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = {
dismiss()
tempFileForInstall?.delete()
tempFileForInstall = null
}
) {
Text(cancel)
}
}
},
shape = MaterialTheme.shapes.extraLarge
)
}
val selectPatchLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
scope.launch {
val fileName = uri.lastPathSegment ?: "unknown.kpm"
val encodedFileName = URLEncoder.encode(fileName, "UTF-8")
val tempFile = File(context.cacheDir, encodedFileName)
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
val mimeType = context.contentResolver.getType(uri)
val isCorrectMimeType = mimeType == null || mimeType.contains("application/octet-stream")
if (!isCorrectMimeType) {
var shouldShowSnackbar = true
try {
val matchCount = checkStringsCommand(tempFile)
val isElf = isElfFile(tempFile)
if (matchCount >= 1 || isElf) {
shouldShowSnackbar = false
}
} catch (e: Exception) {
Log.e("KsuCli", "Failed to execute checks: ${e.message}", e)
}
if (shouldShowSnackbar) {
snackBarHost.showSnackbar(
message = invalidFileTypeMessage,
duration = SnackbarDuration.Short
)
}
tempFile.delete()
return@launch
}
tempFileForInstall = tempFile
installModeDialog.show()
}
}
LaunchedEffect(Unit) {
while(true) {
viewModel.fetchModuleList()
delay(5000)
}
}
val sharedPreferences = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE)
var isNoticeClosed by remember { mutableStateOf(sharedPreferences.getBoolean("is_notice_closed", false)) }
Scaffold(
topBar = {
SearchAppBar(
title = { Text(stringResource(R.string.kpm_title)) },
searchText = viewModel.search,
onSearchTextChange = { viewModel.search = it },
onClearClick = { viewModel.search = "" },
scrollBehavior = scrollBehavior,
dropdownContent = {
IconButton(
onClick = { viewModel.fetchModuleList() }
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.refresh),
)
}
}
)
},
floatingActionButton = {
AnimatedFab(visible = fabVisible) {
FloatingActionButton(
contentColor = MaterialTheme.colorScheme.onPrimary,
containerColor = MaterialTheme.colorScheme.primary,
onClick = {
selectPatchLauncher.launch(
Intent(Intent.ACTION_GET_CONTENT).apply {
type = "application/octet-stream"
}
)
},
content = {
Icon(
painter = painterResource(id = R.drawable.package_import),
contentDescription = null
)
}
)
}
},
contentWindowInsets = WindowInsets.safeDrawing.only(
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
),
snackbarHost = { SnackbarHost(snackBarHost) }
) { padding ->
Column(modifier = Modifier.padding(padding)) {
if (!isNoticeClosed) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clip(MaterialTheme.shapes.medium)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp)
)
Text(
text = stringResource(R.string.kernel_module_notice),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
IconButton(
onClick = {
isNoticeClosed = true
sharedPreferences.edit { putBoolean("is_notice_closed", true) }
},
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.close_notice)
)
}
}
}
}
if (viewModel.moduleList.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
modifier = Modifier
.size(96.dp)
.padding(bottom = 16.dp)
)
Text(
stringResource(R.string.kpm_empty),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(viewModel.moduleList) { module ->
KpmModuleItem(
module = module,
onUninstall = {
scope.launch {
val confirmContent = moduleConfirmContentMap[module.id] ?: ""
handleModuleUninstall(
module = module,
viewModel = viewModel,
snackBarHost = snackBarHost,
kpmUninstallSuccess = kpmUninstallSuccess,
kpmUninstallFailed = kpmUninstallFailed,
failedToCheckModuleFile = failedToCheckModuleFile,
uninstall = uninstall,
cancel = cancel,
confirmDialog = confirmDialog,
confirmTitle = confirmTitle,
confirmContent = confirmContent
)
}
},
onControl = {
viewModel.loadModuleDetail(module.id)
}
)
}
}
}
}
}
}
private suspend fun handleModuleInstall(
tempFile: File,
isEmbed: Boolean,
viewModel: KpmViewModel,
snackBarHost: SnackbarHostState,
kpmInstallSuccess: String,
kpmInstallFailed: String
) {
var moduleId: String? = null
try {
val shell = getRootShell()
val command = "strings ${tempFile.absolutePath} | grep 'name='"
val result = shell.newJob().add(command).to(ArrayList(), null).exec()
if (result.isSuccess) {
for (line in result.out) {
if (line.startsWith("name=")) {
moduleId = line.substringAfter("name=").trim()
break
}
}
}
} catch (e: Exception) {
Log.e("KsuCli", "Failed to get module ID from strings command: ${e.message}", e)
}
if (moduleId == null || moduleId.isEmpty()) {
Log.e("KsuCli", "Failed to extract module ID from file: ${tempFile.name}")
snackBarHost.showSnackbar(
message = kpmInstallFailed,
duration = SnackbarDuration.Short
)
tempFile.delete()
return
}
val targetPath = "/data/adb/kpm/$moduleId.kpm"
try {
if (isEmbed) {
val shell = getRootShell()
shell.newJob().add("mkdir -p /data/adb/kpm").exec()
shell.newJob().add("cp ${tempFile.absolutePath} $targetPath").exec()
}
val loadResult = loadKpmModule(tempFile.absolutePath)
if (loadResult.startsWith("Error")) {
Log.e("KsuCli", "Failed to load KPM module: $loadResult")
snackBarHost.showSnackbar(
message = kpmInstallFailed,
duration = SnackbarDuration.Short
)
} else {
viewModel.fetchModuleList()
snackBarHost.showSnackbar(
message = kpmInstallSuccess,
duration = SnackbarDuration.Short
)
}
} catch (e: Exception) {
Log.e("KsuCli", "Failed to load KPM module: ${e.message}", e)
snackBarHost.showSnackbar(
message = kpmInstallFailed,
duration = SnackbarDuration.Short
)
}
tempFile.delete()
}
private suspend fun handleModuleUninstall(
module: KpmViewModel.ModuleInfo,
viewModel: KpmViewModel,
snackBarHost: SnackbarHostState,
kpmUninstallSuccess: String,
kpmUninstallFailed: String,
failedToCheckModuleFile: String,
uninstall: String,
cancel: String,
confirmTitle : String,
confirmContent : String,
confirmDialog: ConfirmDialogHandle
) {
val moduleFileName = "${module.id}.kpm"
val moduleFilePath = "/data/adb/kpm/$moduleFileName"
val fileExists = try {
val shell = getRootShell()
val result = shell.newJob().add("ls /data/adb/kpm/$moduleFileName").exec()
result.isSuccess
} catch (e: Exception) {
Log.e("KsuCli", "Failed to check module file existence: ${e.message}", e)
snackBarHost.showSnackbar(
message = failedToCheckModuleFile,
duration = SnackbarDuration.Short
)
false
}
val confirmResult = confirmDialog.awaitConfirm(
title = confirmTitle,
content = confirmContent,
confirm = uninstall,
dismiss = cancel
)
if (confirmResult == ConfirmResult.Confirmed) {
try {
val unloadResult = unloadKpmModule(module.id)
if (unloadResult.startsWith("Error")) {
Log.e("KsuCli", "Failed to unload KPM module: $unloadResult")
snackBarHost.showSnackbar(
message = kpmUninstallFailed,
duration = SnackbarDuration.Short
)
return
}
if (fileExists) {
val shell = getRootShell()
shell.newJob().add("rm $moduleFilePath").exec()
}
viewModel.fetchModuleList()
snackBarHost.showSnackbar(
message = kpmUninstallSuccess,
duration = SnackbarDuration.Short
)
} catch (e: Exception) {
Log.e("KsuCli", "Failed to unload KPM module: ${e.message}", e)
snackBarHost.showSnackbar(
message = kpmUninstallFailed,
duration = SnackbarDuration.Short
)
}
}
}
@Composable
private fun KpmModuleItem(
module: KpmViewModel.ModuleInfo,
onUninstall: () -> Unit,
onControl: () -> Unit
) {
val viewModel: KpmViewModel = viewModel()
val scope = rememberCoroutineScope()
val snackBarHost = remember { SnackbarHostState() }
val successMessage = stringResource(R.string.kpm_control_success)
val failureMessage = stringResource(R.string.kpm_control_failed)
if (viewModel.showInputDialog && viewModel.selectedModuleId == module.id) {
AlertDialog(
onDismissRequest = { viewModel.hideInputDialog() },
title = {
Text(
text = stringResource(R.string.kpm_control),
style = MaterialTheme.typography.headlineSmall,
)
},
text = {
OutlinedTextField(
value = viewModel.inputArgs,
onValueChange = { viewModel.updateInputArgs(it) },
label = {
Text(
text = stringResource(R.string.kpm_args),
)
},
placeholder = {
Text(
text = module.args,
)
},
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
scope.launch {
val result = viewModel.executeControl()
val message = when (result) {
0 -> successMessage
else -> failureMessage
}
snackBarHost.showSnackbar(message)
onControl()
}
}
) {
Text(
text = stringResource(R.string.confirm),
)
}
},
dismissButton = {
TextButton(onClick = { viewModel.hideInputDialog() }) {
Text(
text = stringResource(R.string.cancel),
)
}
},
shape = MaterialTheme.shapes.extraLarge
)
}
Card(
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh),
elevation = getCardElevation()
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = module.name,
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${stringResource(R.string.kpm_version)}: ${module.version}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "${stringResource(R.string.kpm_author)}: ${module.author}",
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "${stringResource(R.string.kpm_args)}: ${module.args}",
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = module.description,
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { viewModel.showInputDialog(module.id) },
enabled = module.hasAction,
modifier = Modifier.weight(1f),
) {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.kpm_control))
}
Button(
onClick = onUninstall,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.kpm_uninstall))
}
}
}
}
}
private fun checkStringsCommand(tempFile: File): Int {
val shell = getRootShell()
val command = "strings ${tempFile.absolutePath} | grep -E 'name=|version=|license=|author='"
val result = shell.newJob().add(command).to(ArrayList(), null).exec()
if (!result.isSuccess) {
return 0
}
var matchCount = 0
val keywords = listOf("name=", "version=", "license=", "author=")
var nameExists = false
for (line in result.out) {
if (!nameExists && line.startsWith("name=")) {
nameExists = true
matchCount++
} else if (nameExists) {
for (keyword in keywords) {
if (line.startsWith(keyword)) {
matchCount++
break
}
}
}
}
return if (nameExists) matchCount else 0
}
private fun isElfFile(tempFile: File): Boolean {
val elfMagic = byteArrayOf(0x7F, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())
val fileBytes = ByteArray(4)
FileInputStream(tempFile).use { input ->
input.read(fileBytes)
}
return fileBytes.contentEquals(elfMagic)
}

View file

@ -0,0 +1,941 @@
package com.sukisu.ultra.ui.screen
import android.content.Context
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.*
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
import com.sukisu.ultra.ui.theme.getCardColors
import com.sukisu.ultra.ui.theme.getCardElevation
import com.sukisu.ultra.ui.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.*
import java.time.format.DateTimeFormatter
import android.os.Process.myUid
import androidx.core.content.edit
private val SPACING_SMALL = 4.dp
private val SPACING_MEDIUM = 8.dp
private val SPACING_LARGE = 16.dp
private const val PAGE_SIZE = 10000
private const val MAX_TOTAL_LOGS = 100000
private const val LOGS_PATCH = "/data/adb/ksu/log/sulog.log"
data class LogEntry(
val timestamp: String,
val type: LogType,
val uid: String,
val comm: String,
val details: String,
val pid: String,
val rawLine: String
)
data class LogPageInfo(
val currentPage: Int = 0,
val totalPages: Int = 0,
val totalLogs: Int = 0,
val hasMore: Boolean = false
)
enum class LogType(val displayName: String, val color: Color) {
SU_GRANT("SU_GRANT", Color(0xFF4CAF50)),
SU_EXEC("SU_EXEC", Color(0xFF2196F3)),
PERM_CHECK("PERM_CHECK", Color(0xFFFF9800)),
SYSCALL("SYSCALL", Color(0xFF00BCD4)),
MANAGER_OP("MANAGER_OP", Color(0xFF9C27B0)),
UNKNOWN("UNKNOWN", Color(0xFF757575))
}
enum class LogExclType(val displayName: String, val color: Color) {
CURRENT_APP("Current app", Color(0xFF9E9E9E)),
PRCTL_STAR("prctl_*", Color(0xFF00BCD4)),
PRCTL_UNKNOWN("prctl_unknown", Color(0xFF00BCD4)),
SETUID("setuid", Color(0xFF00BCD4))
}
private val utcFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val localFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private fun saveExcludedSubTypes(context: Context, types: Set<LogExclType>) {
val prefs = context.getSharedPreferences("sulog", Context.MODE_PRIVATE)
val nameSet = types.map { it.name }.toSet()
prefs.edit { putStringSet("excluded_subtypes", nameSet) }
}
private fun loadExcludedSubTypes(context: Context): Set<LogExclType> {
val prefs = context.getSharedPreferences("sulog", Context.MODE_PRIVATE)
val nameSet = prefs.getStringSet("excluded_subtypes", emptySet()) ?: emptySet()
return nameSet.mapNotNull { name ->
LogExclType.entries.firstOrNull { it.name == name }
}.toSet()
}
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun LogViewerScreen(navigator: DestinationsNavigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val snackBarHost = LocalSnackbarHost.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
var logEntries by remember { mutableStateOf<List<LogEntry>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
var filterType by rememberSaveable { mutableStateOf<LogType?>(null) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var showSearchBar by rememberSaveable { mutableStateOf(false) }
var pageInfo by remember { mutableStateOf(LogPageInfo()) }
var lastLogFileHash by remember { mutableStateOf("") }
val currentUid = remember { myUid().toString() }
val initialExcluded = remember {
loadExcludedSubTypes(context)
}
var excludedSubTypes by rememberSaveable { mutableStateOf(initialExcluded) }
LaunchedEffect(excludedSubTypes) {
saveExcludedSubTypes(context, excludedSubTypes)
}
val filteredEntries = remember(
logEntries, filterType, searchQuery, excludedSubTypes
) {
logEntries.filter { entry ->
val matchesSearch = searchQuery.isEmpty() ||
entry.comm.contains(searchQuery, ignoreCase = true) ||
entry.details.contains(searchQuery, ignoreCase = true) ||
entry.uid.contains(searchQuery, ignoreCase = true)
// 排除本应用
if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false
// 排除 SYSCALL 子类型
if (entry.type == LogType.SYSCALL) {
val detail = entry.details
if (LogExclType.PRCTL_STAR in excludedSubTypes && detail.startsWith("Syscall: prctl") && !detail.startsWith("Syscall: prctl_unknown")) return@filter false
if (LogExclType.PRCTL_UNKNOWN in excludedSubTypes && detail.startsWith("Syscall: prctl_unknown")) return@filter false
if (LogExclType.SETUID in excludedSubTypes && detail.startsWith("Syscall: setuid")) return@filter false
}
// 普通类型筛选
val matchesFilter = filterType == null || entry.type == filterType
matchesFilter && matchesSearch
}
}
val loadingDialog = rememberLoadingDialog()
val confirmDialog = rememberConfirmDialog()
val loadPage: (Int, Boolean) -> Unit = { page, forceRefresh ->
scope.launch {
if (isLoading) return@launch
isLoading = true
try {
loadLogsWithPagination(
page,
forceRefresh,
lastLogFileHash
) { entries, newPageInfo, newHash ->
logEntries = if (page == 0 || forceRefresh) {
entries
} else {
logEntries + entries
}
pageInfo = newPageInfo
lastLogFileHash = newHash
}
} finally {
isLoading = false
}
}
}
val onManualRefresh: () -> Unit = {
loadPage(0, true)
}
val loadNextPage: () -> Unit = {
if (pageInfo.hasMore && !isLoading) {
loadPage(pageInfo.currentPage + 1, false)
}
}
LaunchedEffect(Unit) {
while (true) {
delay(5_000)
if (!isLoading) {
scope.launch {
val hasNewLogs = checkForNewLogs(lastLogFileHash)
if (hasNewLogs) {
loadPage(0, true)
}
}
}
}
}
LaunchedEffect(Unit) {
loadPage(0, true)
}
Scaffold(
topBar = {
LogViewerTopBar(
scrollBehavior = scrollBehavior,
onBackClick = { navigator.navigateUp() },
showSearchBar = showSearchBar,
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
onSearchToggle = { showSearchBar = !showSearchBar },
onRefresh = onManualRefresh,
onClearLogs = {
scope.launch {
val result = confirmDialog.awaitConfirm(
title = context.getString(R.string.log_viewer_clear_logs),
content = context.getString(R.string.log_viewer_clear_logs_confirm)
)
if (result == ConfirmResult.Confirmed) {
loadingDialog.withLoading {
clearLogs()
loadPage(0, true)
}
snackBarHost.showSnackbar(context.getString(R.string.log_viewer_logs_cleared))
}
}
}
)
},
snackbarHost = { SnackbarHost(snackBarHost) },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
LogControlPanel(
filterType = filterType,
onFilterTypeSelected = { filterType = it },
logCount = filteredEntries.size,
totalCount = logEntries.size,
pageInfo = pageInfo,
excludedSubTypes = excludedSubTypes,
onExcludeToggle = { excl ->
excludedSubTypes = if (excl in excludedSubTypes)
excludedSubTypes - excl
else
excludedSubTypes + excl
}
)
// 日志列表
if (isLoading && logEntries.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (filteredEntries.isEmpty()) {
EmptyLogState(
hasLogs = logEntries.isNotEmpty(),
onRefresh = onManualRefresh
)
} else {
LogList(
entries = filteredEntries,
pageInfo = pageInfo,
isLoading = isLoading,
onLoadMore = loadNextPage,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
@Composable
private fun LogControlPanel(
filterType: LogType?,
onFilterTypeSelected: (LogType?) -> Unit,
logCount: Int,
totalCount: Int,
pageInfo: LogPageInfo,
excludedSubTypes: Set<LogExclType>,
onExcludeToggle: (LogExclType) -> Unit
) {
var isExpanded by rememberSaveable { mutableStateOf(true) }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
elevation = getCardElevation()
) {
Column {
// 标题栏(点击展开/收起)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded }
.padding(SPACING_LARGE),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier.padding(horizontal = SPACING_LARGE)
) {
// 类型过滤
Text(
text = stringResource(R.string.log_viewer_filter_type),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) {
item {
FilterChip(
onClick = { onFilterTypeSelected(null) },
label = { Text(stringResource(R.string.log_viewer_all_types)) },
selected = filterType == null
)
}
items(LogType.entries.toTypedArray()) { type ->
FilterChip(
onClick = { onFilterTypeSelected(if (filterType == type) null else type) },
label = { Text(type.displayName) },
selected = filterType == type,
leadingIcon = {
Box(
modifier = Modifier
.size(8.dp)
.background(type.color, RoundedCornerShape(4.dp))
)
}
)
}
}
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
// 排除子类型
Text(
text = stringResource(R.string.log_viewer_exclude_subtypes),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) {
items(LogExclType.entries.toTypedArray()) { excl ->
val label = if (excl == LogExclType.CURRENT_APP)
stringResource(R.string.log_viewer_exclude_current_app)
else excl.displayName
FilterChip(
onClick = { onExcludeToggle(excl) },
label = { Text(label) },
selected = excl in excludedSubTypes,
leadingIcon = {
Box(
modifier = Modifier
.size(8.dp)
.background(excl.color, RoundedCornerShape(4.dp))
)
}
)
}
}
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
// 统计信息
Column(verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)) {
Text(
text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (pageInfo.totalPages > 0) {
Text(
text = stringResource(
R.string.log_viewer_page_info,
pageInfo.currentPage + 1,
pageInfo.totalPages,
pageInfo.totalLogs
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) {
Text(
text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(SPACING_LARGE))
}
}
}
}
}
@Composable
private fun LogList(
entries: List<LogEntry>,
pageInfo: LogPageInfo,
isLoading: Boolean,
onLoadMore: () -> Unit,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = modifier,
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)
) {
items(entries) { entry ->
LogEntryCard(entry = entry)
}
// 加载更多按钮或加载指示器
if (pageInfo.hasMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(SPACING_LARGE),
contentAlignment = Alignment.Center
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp)
)
} else {
Button(
onClick = onLoadMore,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Filled.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text(stringResource(R.string.log_viewer_load_more))
}
}
}
}
} else if (entries.isNotEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(SPACING_LARGE),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.log_viewer_all_logs_loaded),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun LogEntryCard(entry: LogEntry) {
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier.padding(SPACING_MEDIUM)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
) {
Box(
modifier = Modifier
.size(12.dp)
.background(entry.type.color, RoundedCornerShape(6.dp))
)
Text(
text = entry.type.displayName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold
)
}
Text(
text = entry.timestamp,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(SPACING_SMALL))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "UID: ${entry.uid}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "PID: ${entry.pid}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = entry.comm,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = if (expanded) Int.MAX_VALUE else 1,
overflow = TextOverflow.Ellipsis
)
if (entry.details.isNotEmpty()) {
Spacer(modifier = Modifier.height(SPACING_SMALL))
Text(
text = entry.details,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (expanded) Int.MAX_VALUE else 2,
overflow = TextOverflow.Ellipsis
)
}
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
Text(
text = stringResource(R.string.log_viewer_raw_log),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(SPACING_SMALL))
Text(
text = entry.rawLine,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun EmptyLogState(
hasLogs: Boolean,
onRefresh: () -> Unit
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(SPACING_LARGE)
) {
Icon(
imageVector = if (hasLogs) Icons.Filled.FilterList else Icons.Filled.Description,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(
if (hasLogs) R.string.log_viewer_no_matching_logs
else R.string.log_viewer_no_logs
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = onRefresh) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text(stringResource(R.string.log_viewer_refresh))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LogViewerTopBar(
scrollBehavior: TopAppBarScrollBehavior? = null,
onBackClick: () -> Unit,
showSearchBar: Boolean,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
onSearchToggle: () -> Unit,
onRefresh: () -> Unit,
onClearLogs: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
Column {
TopAppBar(
title = {
Text(
text = stringResource(R.string.log_viewer_title),
style = MaterialTheme.typography.titleLarge
)
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.log_viewer_back)
)
}
},
actions = {
IconButton(onClick = onSearchToggle) {
Icon(
imageVector = if (showSearchBar) Icons.Filled.SearchOff else Icons.Filled.Search,
contentDescription = stringResource(R.string.log_viewer_search)
)
}
IconButton(onClick = onRefresh) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringResource(R.string.log_viewer_refresh)
)
}
IconButton(onClick = onClearLogs) {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = stringResource(R.string.log_viewer_clear_logs)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
),
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior
)
AnimatedVisibility(
visible = showSearchBar,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
placeholder = { Text(stringResource(R.string.log_viewer_search_placeholder)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null
)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchQueryChange("") }) {
Icon(
imageVector = Icons.Filled.Clear,
contentDescription = stringResource(R.string.log_viewer_clear_search)
)
}
}
},
singleLine = true
)
}
}
}
private suspend fun checkForNewLogs(
lastHash: String
): Boolean {
return withContext(Dispatchers.IO) {
try {
val shell = getRootShell()
val logPath = "/data/adb/ksu/log/sulog.log"
val result = runCmd(shell, "stat -c '%Y %s' $logPath 2>/dev/null || echo '0 0'")
val currentHash = result.trim()
currentHash != lastHash && currentHash != "0 0"
} catch (_: Exception) {
false
}
}
}
private suspend fun loadLogsWithPagination(
page: Int,
forceRefresh: Boolean,
lastHash: String,
onLoaded: (List<LogEntry>, LogPageInfo, String) -> Unit
) {
withContext(Dispatchers.IO) {
try {
val shell = getRootShell()
// 获取文件信息
val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'")
val currentHash = statResult.trim()
if (!forceRefresh && currentHash == lastHash && currentHash != "0 0") {
withContext(Dispatchers.Main) {
onLoaded(emptyList(), LogPageInfo(), currentHash)
}
return@withContext
}
// 获取总行数
val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'")
val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0
if (totalLines == 0) {
withContext(Dispatchers.Main) {
onLoaded(emptyList(), LogPageInfo(), currentHash)
}
return@withContext
}
// 限制最大日志数量
val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS)
val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE
// 计算要读取的行数范围
val startLine = if (page == 0) {
maxOf(1, totalLines - effectiveTotal + 1)
} else {
val skipLines = page * PAGE_SIZE
maxOf(1, totalLines - effectiveTotal + 1 + skipLines)
}
val endLine = minOf(startLine + PAGE_SIZE - 1, totalLines)
if (startLine > totalLines) {
withContext(Dispatchers.Main) {
onLoaded(emptyList(), LogPageInfo(page, totalPages, effectiveTotal, false), currentHash)
}
return@withContext
}
val result = runCmd(shell, "sed -n '${startLine},${endLine}p' $LOGS_PATCH 2>/dev/null || echo ''")
val entries = parseLogEntries(result)
val hasMore = endLine < totalLines
val pageInfo = LogPageInfo(page, totalPages, effectiveTotal, hasMore)
withContext(Dispatchers.Main) {
onLoaded(entries, pageInfo, currentHash)
}
} catch (_: Exception) {
withContext(Dispatchers.Main) {
onLoaded(emptyList(), LogPageInfo(), lastHash)
}
}
}
}
private suspend fun clearLogs() {
withContext(Dispatchers.IO) {
try {
val shell = getRootShell()
runCmd(shell, "echo '' > $LOGS_PATCH")
} catch (_: Exception) {
}
}
}
private fun parseLogEntries(logContent: String): List<LogEntry> {
if (logContent.isBlank()) return emptyList()
val entries = logContent.lines()
.filter { it.isNotBlank() && it.startsWith("[") }
.mapNotNull { line ->
try {
parseLogLine(line)
} catch (_: Exception) {
null
}
}
return entries.reversed()
}
private fun utcToLocal(utc: String): String {
return try {
val instant = LocalDateTime.parse(utc, utcFormatter).atOffset(ZoneOffset.UTC).toInstant()
val local = instant.atZone(ZoneId.systemDefault())
local.format(localFormatter)
} catch (_: Exception) {
utc
}
}
private fun parseLogLine(line: String): LogEntry? {
// 解析格式: [timestamp] TYPE: UID=xxx COMM=xxx ...
val timestampRegex = """\[(.*?)]""".toRegex()
val timestampMatch = timestampRegex.find(line) ?: return null
val timestamp = utcToLocal(timestampMatch.groupValues[1])
val afterTimestamp = line.substring(timestampMatch.range.last + 1).trim()
val parts = afterTimestamp.split(":")
if (parts.size < 2) return null
val typeStr = parts[0].trim()
val type = when (typeStr) {
"SU_GRANT" -> LogType.SU_GRANT
"SU_EXEC" -> LogType.SU_EXEC
"PERM_CHECK" -> LogType.PERM_CHECK
"SYSCALL" -> LogType.SYSCALL
"MANAGER_OP" -> LogType.MANAGER_OP
else -> LogType.UNKNOWN
}
val details = parts[1].trim()
val uid: String = extractValue(details, "UID") ?: ""
val comm: String = extractValue(details, "COMM") ?: ""
val pid: String = extractValue(details, "PID") ?: ""
// 构建详细信息字符串
val detailsStr = when (type) {
LogType.SU_GRANT -> {
val method: String = extractValue(details, "METHOD") ?: ""
"Method: $method"
}
LogType.SU_EXEC -> {
val target: String = extractValue(details, "TARGET") ?: ""
val result: String = extractValue(details, "RESULT") ?: ""
"Target: $target, Result: $result"
}
LogType.PERM_CHECK -> {
val result: String = extractValue(details, "RESULT") ?: ""
"Result: $result"
}
LogType.SYSCALL -> {
val syscall = extractValue(details, "SYSCALL") ?: ""
val args = extractValue(details, "ARGS") ?: ""
"Syscall: $syscall, Args: $args"
}
LogType.MANAGER_OP -> {
val op: String = extractValue(details, "OP") ?: ""
val managerUid: String = extractValue(details, "MANAGER_UID") ?: ""
val targetUid: String = extractValue(details, "TARGET_UID") ?: ""
"Operation: $op, Manager UID: $managerUid, Target UID: $targetUid"
}
else -> details
}
return LogEntry(
timestamp = timestamp,
type = type,
uid = uid,
comm = comm,
details = detailsStr,
pid = pid,
rawLine = line
)
}
private fun extractValue(text: String, key: String): String? {
val regex = """$key=(\S+)""".toRegex()
return regex.find(text)?.groupValues?.get(1)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,961 @@
package com.sukisu.ultra.ui.screen
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.*
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.expandVertically
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
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.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import com.dergoogler.mmrl.ui.component.LabelItem
import com.dergoogler.mmrl.ui.component.LabelItemDefaults
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.AppProfileScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.FabMenuPresets
import com.sukisu.ultra.ui.component.SearchAppBar
import com.sukisu.ultra.ui.component.VerticalExpandableFab
import com.sukisu.ultra.ui.util.module.ModuleModify
import com.sukisu.ultra.ui.viewmodel.AppCategory
import com.sukisu.ultra.ui.viewmodel.SortType
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
enum class AppPriority(val value: Int) {
ROOT(1), CUSTOM(2), DEFAULT(3)
}
data class BottomSheetMenuItem(
val icon: ImageVector,
val titleRes: Int,
val onClick: () -> Unit
)
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun SuperUserScreen(navigator: DestinationsNavigator) {
val viewModel = viewModel<SuperUserViewModel>()
val scope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val listState = rememberLazyListState()
val context = LocalContext.current
val snackBarHostState = remember { SnackbarHostState() }
val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showBottomSheet by remember { mutableStateOf(false) }
val backupLauncher = ModuleModify.rememberAllowlistBackupLauncher(context, snackBarHostState)
val restoreLauncher = ModuleModify.rememberAllowlistRestoreLauncher(context, snackBarHostState)
LaunchedEffect(navigator) {
viewModel.search = ""
}
LaunchedEffect(viewModel.selectedApps, viewModel.showBatchActions) {
if (viewModel.showBatchActions && viewModel.selectedApps.isEmpty()) {
viewModel.showBatchActions = false
}
}
val filteredAndSortedAppGroups = remember(
viewModel.appGroupList,
viewModel.selectedCategory,
viewModel.currentSortType,
viewModel.search,
viewModel.showSystemApps
) {
var groups = viewModel.appGroupList
// 按分类筛选
groups = when (viewModel.selectedCategory) {
AppCategory.ALL -> groups
AppCategory.ROOT -> groups.filter { it.allowSu }
AppCategory.CUSTOM -> groups.filter { !it.allowSu && it.hasCustomProfile }
AppCategory.DEFAULT -> groups.filter { !it.allowSu && !it.hasCustomProfile }
}
// 排序
groups.sortedWith { group1, group2 ->
val priority1 = when {
group1.allowSu -> AppPriority.ROOT
group1.hasCustomProfile -> AppPriority.CUSTOM
else -> AppPriority.DEFAULT
}
val priority2 = when {
group2.allowSu -> AppPriority.ROOT
group2.hasCustomProfile -> AppPriority.CUSTOM
else -> AppPriority.DEFAULT
}
val priorityComparison = priority1.value.compareTo(priority2.value)
if (priorityComparison != 0) {
priorityComparison
} else {
when (viewModel.currentSortType) {
SortType.NAME_ASC -> group1.mainApp.label.lowercase()
.compareTo(group2.mainApp.label.lowercase())
SortType.NAME_DESC -> group2.mainApp.label.lowercase()
.compareTo(group1.mainApp.label.lowercase())
SortType.INSTALL_TIME_NEW -> group2.mainApp.packageInfo.firstInstallTime
.compareTo(group1.mainApp.packageInfo.firstInstallTime)
SortType.INSTALL_TIME_OLD -> group1.mainApp.packageInfo.firstInstallTime
.compareTo(group2.mainApp.packageInfo.firstInstallTime)
else -> group1.mainApp.label.lowercase()
.compareTo(group2.mainApp.label.lowercase())
}
}
}
}
val appCounts = remember(viewModel.appGroupList, viewModel.showSystemApps) {
mapOf(
AppCategory.ALL to viewModel.appGroupList.size,
AppCategory.ROOT to viewModel.appGroupList.count { it.allowSu },
AppCategory.CUSTOM to viewModel.appGroupList.count { !it.allowSu && it.hasCustomProfile },
AppCategory.DEFAULT to viewModel.appGroupList.count { !it.allowSu && !it.hasCustomProfile }
)
}
Scaffold(
topBar = {
SearchAppBar(
title = { TopBarTitle(viewModel.selectedCategory, appCounts) },
searchText = viewModel.search,
onSearchTextChange = { viewModel.search = it },
onClearClick = { viewModel.search = "" },
dropdownContent = {
IconButton(onClick = { showBottomSheet = true }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(id = R.string.settings),
)
}
},
scrollBehavior = scrollBehavior
)
},
snackbarHost = { SnackbarHost(snackBarHostState) },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
floatingActionButton = {
SuperUserFab(viewModel, filteredAndSortedAppGroups, listState, scope)
}
) { innerPadding ->
SuperUserContent(
innerPadding = innerPadding,
viewModel = viewModel,
filteredAndSortedAppGroups = filteredAndSortedAppGroups,
listState = listState,
scrollBehavior = scrollBehavior,
navigator = navigator,
scope = scope
)
if (showBottomSheet) {
SuperUserBottomSheet(
bottomSheetState = bottomSheetState,
onDismiss = { showBottomSheet = false },
viewModel = viewModel,
appCounts = appCounts,
backupLauncher = backupLauncher,
restoreLauncher = restoreLauncher,
scope = scope,
listState = listState
)
}
}
}
@Composable
private fun TopBarTitle(
selectedCategory: AppCategory,
appCounts: Map<AppCategory, Int>
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(stringResource(R.string.superuser))
if (selectedCategory != AppCategory.ALL) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(start = 4.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(selectedCategory.displayNameRes),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "(${appCounts[selectedCategory] ?: 0})",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
}
@Composable
private fun SuperUserFab(
viewModel: SuperUserViewModel,
filteredAndSortedAppGroups: List<SuperUserViewModel.AppGroup>,
listState: androidx.compose.foundation.lazy.LazyListState,
scope: CoroutineScope
) {
VerticalExpandableFab(
menuItems = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) {
FabMenuPresets.getBatchActionMenuItems(
onCancel = {
viewModel.selectedApps = emptySet()
viewModel.showBatchActions = false
},
onDeny = { scope.launch { viewModel.updateBatchPermissions(false) } },
onAllow = { scope.launch { viewModel.updateBatchPermissions(true) } },
onUnmountModules = {
scope.launch { viewModel.updateBatchPermissions(
allowSu = false,
umountModules = true
) }
},
onDisableUnmount = {
scope.launch { viewModel.updateBatchPermissions(
allowSu = false,
umountModules = false
) }
}
)
} else {
FabMenuPresets.getScrollMenuItems(
onScrollToTop = { scope.launch { listState.animateScrollToItem(0) } },
onScrollToBottom = {
scope.launch {
val lastIndex = filteredAndSortedAppGroups.size - 1
if (lastIndex >= 0) listState.animateScrollToItem(lastIndex)
}
}
)
},
mainButtonIcon = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) {
Icons.Filled.GridView
} else {
Icons.Filled.Add
},
mainButtonExpandedIcon = Icons.Filled.Close
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SuperUserContent(
innerPadding: PaddingValues,
viewModel: SuperUserViewModel,
filteredAndSortedAppGroups: List<SuperUserViewModel.AppGroup>,
listState: androidx.compose.foundation.lazy.LazyListState,
scrollBehavior: TopAppBarScrollBehavior,
navigator: DestinationsNavigator,
scope: CoroutineScope
) {
val expandedGroups = remember { mutableStateOf(setOf<Int>()) }
val density = LocalDensity.current
val targetSizePx = remember(density) { with(density) { 36.dp.roundToPx() } }
val context = LocalContext.current
PullToRefreshBox(
modifier = Modifier.padding(innerPadding),
onRefresh = { scope.launch { viewModel.fetchAppList() } },
isRefreshing = viewModel.isRefreshing
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
filteredAndSortedAppGroups.forEachIndexed { _, appGroup ->
item(key = "${appGroup.uid}-${appGroup.mainApp.packageName}") {
AppGroupItem(
expandedGroups = expandedGroups,
appGroup = appGroup,
isSelected = appGroup.packageNames.any { viewModel.selectedApps.contains(it) },
onToggleSelection = {
appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) }
},
onClick = {
if (viewModel.showBatchActions) {
appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) }
} else if (appGroup.apps.size > 1) {
expandedGroups.value = if (expandedGroups.value.contains(appGroup.uid)) {
expandedGroups.value - appGroup.uid
} else {
expandedGroups.value + appGroup.uid
}
} else {
navigator.navigate(AppProfileScreenDestination(appGroup.mainApp))
}
},
onLongClick = {
if (!viewModel.showBatchActions) {
viewModel.toggleBatchMode()
appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) }
}
},
viewModel = viewModel
)
}
if (appGroup.apps.size <= 1) return@forEachIndexed
items(appGroup.apps, key = { "${it.packageName}-${it.uid}" }) { app ->
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context)
.data(app.packageInfo)
.size(targetSizePx)
.crossfade(true)
.build()
)
val listItemContent = remember(app.packageName, appGroup.uid) {
@Composable {
ListItem(
modifier = Modifier
.clickable { navigator.navigate(AppProfileScreenDestination(app)) }
.fillMaxWidth()
.padding(start = 10.dp),
headlineContent = { Text(app.label, style = MaterialTheme.typography.bodyMedium) },
supportingContent = { Text(app.packageName, style = MaterialTheme.typography.bodySmall) },
leadingContent = {
Image(
painter = painter,
contentDescription = app.label,
modifier = Modifier
.padding(4.dp)
.size(36.dp),
contentScale = ContentScale.Crop
)
}
)
}
}
AnimatedVisibility(
visible = expandedGroups.value.contains(appGroup.uid),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
listItemContent()
}
}
}
if (filteredAndSortedAppGroups.isEmpty()) {
item {
Box(
modifier = Modifier.fillMaxWidth().height(400.dp),
contentAlignment = Alignment.Center
) {
if ((viewModel.isRefreshing || viewModel.appGroupList.isEmpty()) && viewModel.search.isEmpty()) {
LoadingAnimation(isLoading = true)
} else {
EmptyState(
selectedCategory = viewModel.selectedCategory,
isSearchEmpty = viewModel.search.isNotEmpty()
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SuperUserBottomSheet(
bottomSheetState: SheetState,
onDismiss: () -> Unit,
viewModel: SuperUserViewModel,
appCounts: Map<AppCategory, Int>,
backupLauncher: androidx.activity.result.ActivityResultLauncher<android.content.Intent>,
restoreLauncher: androidx.activity.result.ActivityResultLauncher<android.content.Intent>,
scope: CoroutineScope,
listState: androidx.compose.foundation.lazy.LazyListState
) {
val bottomSheetMenuItems = remember(viewModel.showSystemApps) {
listOf(
BottomSheetMenuItem(
icon = Icons.Filled.Refresh,
titleRes = R.string.refresh,
onClick = {
scope.launch {
viewModel.fetchAppList()
bottomSheetState.hide()
onDismiss()
}
}
),
BottomSheetMenuItem(
icon = if (viewModel.showSystemApps) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
titleRes = if (viewModel.showSystemApps) R.string.hide_system_apps else R.string.show_system_apps,
onClick = {
viewModel.updateShowSystemApps(!viewModel.showSystemApps)
scope.launch {
kotlinx.coroutines.delay(100)
bottomSheetState.hide()
onDismiss()
}
}
),
BottomSheetMenuItem(
icon = Icons.Filled.Save,
titleRes = R.string.backup_allowlist,
onClick = {
backupLauncher.launch(ModuleModify.createAllowlistBackupIntent())
scope.launch {
bottomSheetState.hide()
onDismiss()
}
}
),
BottomSheetMenuItem(
icon = Icons.Filled.RestoreFromTrash,
titleRes = R.string.restore_allowlist,
onClick = {
restoreLauncher.launch(ModuleModify.createAllowlistRestoreIntent())
scope.launch {
bottomSheetState.hide()
onDismiss()
}
}
)
)
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = bottomSheetState,
dragHandle = {
Surface(
modifier = Modifier.padding(vertical = 11.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
shape = RoundedCornerShape(16.dp)
) {
Box(Modifier.size(width = 32.dp, height = 4.dp))
}
}
) {
BottomSheetContent(
menuItems = bottomSheetMenuItems,
currentSortType = viewModel.currentSortType,
onSortTypeChanged = { newSortType ->
viewModel.updateCurrentSortType(newSortType)
scope.launch {
bottomSheetState.hide()
onDismiss()
}
},
selectedCategory = viewModel.selectedCategory,
onCategorySelected = { newCategory ->
viewModel.updateSelectedCategory(newCategory)
scope.launch {
listState.animateScrollToItem(0)
bottomSheetState.hide()
onDismiss()
}
},
appCounts = appCounts
)
}
}
@Composable
private fun BottomSheetContent(
menuItems: List<BottomSheetMenuItem>,
currentSortType: SortType,
onSortTypeChanged: (SortType) -> Unit,
selectedCategory: AppCategory,
onCategorySelected: (AppCategory) -> Unit,
appCounts: Map<AppCategory, Int>
) {
Column(
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)
) {
Text(
text = stringResource(R.string.menu_options),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
LazyVerticalGrid(
columns = GridCells.Fixed(4),
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(menuItems) { menuItem ->
BottomSheetMenuItemView(menuItem = menuItem)
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp))
Text(
text = stringResource(R.string.sort_options),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
LazyRow(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(SortType.entries.toTypedArray()) { sortType ->
FilterChip(
onClick = { onSortTypeChanged(sortType) },
label = { Text(stringResource(sortType.displayNameRes)) },
selected = currentSortType == sortType
)
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp))
Text(
text = stringResource(R.string.app_categories),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(AppCategory.entries.toTypedArray()) { category ->
CategoryChip(
category = category,
isSelected = selectedCategory == category,
onClick = { onCategorySelected(category) },
appCount = appCounts[category] ?: 0
)
}
}
}
}
@Composable
private fun CategoryChip(
category: AppCategory,
isSelected: Boolean,
onClick: () -> Unit,
appCount: Int,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1.0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "categoryChipScale"
)
Surface(
modifier = modifier
.fillMaxWidth()
.scale(scale)
.clickable(interactionSource = interactionSource, indication = null) { onClick() },
shape = RoundedCornerShape(12.dp),
color = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
tonalElevation = if (isSelected) 4.dp else 0.dp
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = stringResource(category.displayNameRes),
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
),
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AnimatedVisibility(
visible = isSelected,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut()
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = stringResource(R.string.selected),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(16.dp)
)
}
}
Text(
text = "$appCount apps",
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
@Composable
private fun BottomSheetMenuItemView(menuItem: BottomSheetMenuItem) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1.0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "menuItemScale"
)
Column(
modifier = Modifier
.fillMaxWidth()
.scale(scale)
.clickable(interactionSource = interactionSource, indication = null) { menuItem.onClick() }
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.size(48.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = menuItem.icon,
contentDescription = stringResource(menuItem.titleRes),
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(menuItem.titleRes),
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
maxLines = 2
)
}
}
@Composable
private fun LoadingAnimation(
modifier: Modifier = Modifier,
isLoading: Boolean = true
) {
val infiniteTransition = rememberInfiniteTransition(label = "loading")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
AnimatedVisibility(
visible = isLoading,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = modifier
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LinearProgressIndicator(
modifier = Modifier.width(200.dp).height(4.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = alpha),
trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
)
}
}
}
@Composable
@SuppressLint("ModifierParameter")
private fun EmptyState(
selectedCategory: AppCategory,
modifier: Modifier = Modifier,
isSearchEmpty: Boolean = false
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier
) {
Icon(
imageVector = if (isSearchEmpty) Icons.Filled.SearchOff else Icons.Filled.Archive,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
modifier = Modifier.size(96.dp).padding(bottom = 16.dp)
)
Text(
text = if (isSearchEmpty || selectedCategory == AppCategory.ALL) {
stringResource(R.string.no_apps_found)
} else {
stringResource(R.string.no_apps_in_category)
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
)
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun AppGroupItem(
appGroup: SuperUserViewModel.AppGroup,
isSelected: Boolean,
onToggleSelection: () -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit,
viewModel: SuperUserViewModel,
expandedGroups: MutableState<Set<Int>>
) {
val mainApp = appGroup.mainApp
ListItem(
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onLongPress = { onLongClick() },
onTap = { onClick() }
)
},
headlineContent = {
Text(mainApp.label)
},
supportingContent = {
Column {
val summaryText = if (appGroup.apps.size > 1) {
stringResource(R.string.group_contains_apps, appGroup.apps.size)
} else {
mainApp.packageName
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(summaryText)
if (appGroup.apps.size > 1) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.rotate(
animateFloatAsState(
targetValue = if (expandedGroups.value.contains(appGroup.uid)) 180f else 0f,
animationSpec = tween(200, easing = LinearOutSlowInEasing),
label = ""
).value
)
)
}
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (appGroup.allowSu) {
LabelItem(text = "ROOT")
} else {
if (Natives.uidShouldUmount(appGroup.uid)) {
LabelItem(
text = "UMOUNT",
style = LabelItemDefaults.style.copy(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
)
}
}
if (appGroup.hasCustomProfile) {
LabelItem(
text = "CUSTOM",
style = LabelItemDefaults.style.copy(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
)
} else if (!appGroup.allowSu) {
LabelItem(
text = "DEFAULT",
style = LabelItemDefaults.style.copy(
containerColor = Color.Gray
)
)
}
if (appGroup.apps.size > 1) {
appGroup.userName?.let {
LabelItem(
text = it,
style = LabelItemDefaults.style.copy(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
)
}
}
}
}
},
leadingContent = {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(mainApp.packageInfo)
.crossfade(true)
.build(),
contentDescription = mainApp.label,
modifier = Modifier.padding(4.dp).width(48.dp).height(48.dp)
)
},
trailingContent = {
AnimatedVisibility(
visible = viewModel.showBatchActions,
enter = fadeIn(animationSpec = tween(200)) + scaleIn(
animationSpec = tween(200),
initialScale = 0.6f
),
exit = fadeOut(animationSpec = tween(200)) + scaleOut(
animationSpec = tween(200),
targetScale = 0.6f
)
) {
val checkboxInteractionSource = remember { MutableInteractionSource() }
val isCheckboxPressed by checkboxInteractionSource.collectIsPressedAsState()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
AnimatedVisibility(
visible = isCheckboxPressed,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut()
) {
Text(
text = if (isSelected) stringResource(R.string.selected) else stringResource(R.string.select),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(end = 4.dp)
)
}
Checkbox(
checked = isSelected,
onCheckedChange = { onToggleSelection() },
interactionSource = checkboxInteractionSource,
)
}
}
}
)
}

View file

@ -0,0 +1,282 @@
package com.sukisu.ultra.ui.screen
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ImportExport
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.getSystemService
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.ResultRecipient
import com.ramcosta.composedestinations.result.getOr
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* @author weishu
* @date 2023/10/20.
*/
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun AppProfileTemplateScreen(
navigator: DestinationsNavigator,
resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean>
) {
val viewModel = viewModel<TemplateViewModel>()
val scope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
LaunchedEffect(Unit) {
if (viewModel.templateList.isEmpty()) {
viewModel.fetchTemplates()
}
}
// handle result from TemplateEditorScreen, refresh if needed
resultRecipient.onNavResult { result ->
if (result.getOr { false }) {
scope.launch { viewModel.fetchTemplates() }
}
}
Scaffold(
topBar = {
val context = LocalContext.current
val clipboardManager = context.getSystemService<ClipboardManager>()
val showToast = fun(msg: String) {
scope.launch(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
TopBar(
onBack = dropUnlessResumed { navigator.popBackStack() },
onSync = {
scope.launch { viewModel.fetchTemplates(true) }
},
onImport = {
scope.launch {
val clipboardText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString()
if (clipboardText.isNullOrEmpty()) {
showToast(context.getString(R.string.app_profile_template_import_empty))
return@launch
}
viewModel.importTemplates(
clipboardText,
{
showToast(context.getString(R.string.app_profile_template_import_success))
viewModel.fetchTemplates(false)
},
showToast
)
}
},
onExport = {
scope.launch {
viewModel.exportTemplates(
{
showToast(context.getString(R.string.app_profile_template_export_empty))
}
) { text ->
clipboardManager?.setPrimaryClip(ClipData.newPlainText("", text))
}
}
},
scrollBehavior = scrollBehavior
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = {
navigator.navigate(
TemplateEditorScreenDestination(
TemplateViewModel.TemplateInfo(),
false
)
)
},
icon = { Icon(Icons.Filled.Add, null) },
text = { Text(stringResource(id = R.string.app_profile_template_create)) },
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
},
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
) { innerPadding ->
PullToRefreshBox(
modifier = Modifier.padding(innerPadding),
isRefreshing = viewModel.isRefreshing,
onRefresh = {
scope.launch { viewModel.fetchTemplates() }
}
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
contentPadding = remember {
PaddingValues(bottom = 16.dp + 56.dp + 16.dp /* Scaffold Fab Spacing + Fab container height */)
}
) {
items(viewModel.templateList, key = { it.id }) { app ->
TemplateItem(navigator, app)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TemplateItem(
navigator: DestinationsNavigator,
template: TemplateViewModel.TemplateInfo
) {
ListItem(
modifier = Modifier
.clickable {
navigator.navigate(TemplateEditorScreenDestination(template, !template.local))
},
headlineContent = { Text(template.name) },
supportingContent = {
Column {
Text(
text = "${template.id}${if (template.author.isEmpty()) "" else "@${template.author}"}",
style = MaterialTheme.typography.bodySmall,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
)
Text(template.description)
FlowRow {
LabelText(label = "UID: ${template.uid}")
LabelText(label = "GID: ${template.gid}")
LabelText(label = template.context)
if (template.local) {
LabelText(label = "local")
} else {
LabelText(label = "remote")
}
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
onBack: () -> Unit,
onSync: () -> Unit = {},
onImport: () -> Unit = {},
onExport: () -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null
) {
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
val cardAlpha = CardConfig.cardAlpha
TopAppBar(
title = {
Text(stringResource(R.string.settings_profile_template))
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = cardColor.copy(alpha = cardAlpha),
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
),
navigationIcon = {
IconButton(
onClick = onBack
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
},
actions = {
IconButton(onClick = onSync) {
Icon(
Icons.Filled.Sync,
contentDescription = stringResource(id = R.string.app_profile_template_sync)
)
}
var showDropdown by remember { mutableStateOf(false) }
IconButton(onClick = {
showDropdown = true
}) {
Icon(
imageVector = Icons.Filled.ImportExport,
contentDescription = stringResource(id = R.string.app_profile_import_export)
)
DropdownMenu(expanded = showDropdown, onDismissRequest = {
showDropdown = false
}) {
DropdownMenuItem(text = {
Text(stringResource(id = R.string.app_profile_import_from_clipboard))
}, onClick = {
onImport()
showDropdown = false
})
DropdownMenuItem(text = {
Text(stringResource(id = R.string.app_profile_export_to_clipboard))
}, onClick = {
onExport()
showDropdown = false
})
}
}
},
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior
)
}
@Composable
fun LabelText(label: String) {
Box(
modifier = Modifier
.padding(top = 4.dp, end = 4.dp)
.background(
Color.Black,
shape = RoundedCornerShape(4.dp)
)
) {
Text(
text = label,
modifier = Modifier.padding(vertical = 2.dp, horizontal = 5.dp),
style = TextStyle(
fontSize = 8.sp,
color = Color.White,
)
)
}
}

View file

@ -0,0 +1,319 @@
package com.sukisu.ultra.ui.screen
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.compose.dropUnlessResumed
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.result.ResultBackNavigator
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.profile.RootProfileConfig
import com.sukisu.ultra.ui.util.deleteAppProfileTemplate
import com.sukisu.ultra.ui.util.getAppProfileTemplate
import com.sukisu.ultra.ui.util.setAppProfileTemplate
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
import com.sukisu.ultra.ui.viewmodel.toJSON
/**
* @author weishu
* @date 2023/10/20.
*/
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun TemplateEditorScreen(
navigator: ResultBackNavigator<Boolean>,
initialTemplate: TemplateViewModel.TemplateInfo,
readOnly: Boolean = true,
) {
val isCreation = initialTemplate.id.isBlank()
val autoSave = !isCreation
var template by rememberSaveable {
mutableStateOf(initialTemplate)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BackHandler {
navigator.navigateBack(result = !readOnly)
}
Scaffold(
topBar = {
val author =
if (initialTemplate.author.isNotEmpty()) "@${initialTemplate.author}" else ""
val readOnlyHint = if (readOnly) {
" - ${stringResource(id = R.string.app_profile_template_readonly)}"
} else {
""
}
val titleSummary = "${initialTemplate.id}$author$readOnlyHint"
val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
val context = LocalContext.current
TopBar(
title = if (isCreation) {
stringResource(R.string.app_profile_template_create)
} else if (readOnly) {
stringResource(R.string.app_profile_template_view)
} else {
stringResource(R.string.app_profile_template_edit)
},
readOnly = readOnly,
summary = titleSummary,
onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) },
onDelete = {
if (deleteAppProfileTemplate(template.id)) {
navigator.navigateBack(result = true)
}
},
onSave = {
if (saveTemplate(template, isCreation)) {
navigator.navigateBack(result = true)
} else {
Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show()
}
},
scrollBehavior = scrollBehavior
)
},
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.nestedScroll(scrollBehavior.nestedScrollConnection)
.verticalScroll(rememberScrollState())
.pointerInteropFilter {
// disable click and ripple if readOnly
readOnly
}
) {
if (isCreation) {
var errorHint by remember {
mutableStateOf("")
}
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
TextEdit(
label = stringResource(id = R.string.app_profile_template_id),
text = template.id,
errorHint = errorHint,
isError = errorHint.isNotEmpty()
) { value ->
errorHint = if (isTemplateExist(value)) {
idConflictError
} else if (!isValidTemplateId(value)) {
idInvalidError
} else {
""
}
template = template.copy(id = value)
}
}
TextEdit(
label = stringResource(id = R.string.app_profile_template_name),
text = template.name
) { value ->
template.copy(name = value).run {
if (autoSave) {
if (!saveTemplate(this)) {
// failed
return@run
}
}
template = this
}
}
TextEdit(
label = stringResource(id = R.string.app_profile_template_description),
text = template.description
) { value ->
template.copy(description = value).run {
if (autoSave) {
if (!saveTemplate(this)) {
// failed
return@run
}
}
template = this
}
}
RootProfileConfig(fixedName = true,
profile = toNativeProfile(template),
onProfileChange = {
template.copy(
uid = it.uid,
gid = it.gid,
groups = it.groups,
capabilities = it.capabilities,
context = it.context,
namespace = it.namespace,
rules = it.rules.split("\n")
).run {
if (autoSave) {
if (!saveTemplate(this)) {
// failed
return@run
}
}
template = this
}
})
}
}
}
fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile {
return Natives.Profile().copy(rootTemplate = templateInfo.id,
uid = templateInfo.uid,
gid = templateInfo.gid,
groups = templateInfo.groups,
capabilities = templateInfo.capabilities,
context = templateInfo.context,
namespace = templateInfo.namespace,
rules = templateInfo.rules.joinToString("\n").ifBlank { "" })
}
fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean {
if (template.id.isBlank()) {
return false
}
if (!isValidTemplateId(template.id)) {
return false
}
return true
}
fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean {
if (!isTemplateValid(template)) {
return false
}
if (isCreation && isTemplateExist(template.id)) {
return false
}
val json = template.toJSON()
json.put("local", true)
return setAppProfileTemplate(template.id, json.toString())
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
title: String,
readOnly: Boolean,
summary: String = "",
onBack: () -> Unit,
onDelete: () -> Unit = {},
onSave: () -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null
) {
TopAppBar(
title = {
Column {
Text(title)
if (summary.isNotBlank()) {
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}, navigationIcon = {
IconButton(
onClick = onBack
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
}, actions = {
if (readOnly) {
return@TopAppBar
}
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.DeleteForever,
contentDescription = stringResource(id = R.string.app_profile_template_delete)
)
}
IconButton(onClick = onSave) {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = stringResource(id = R.string.app_profile_template_save)
)
}
},
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior
)
}
@Composable
private fun TextEdit(
label: String,
text: String,
errorHint: String = "",
isError: Boolean = false,
onValueChange: (String) -> Unit = {}
) {
ListItem(headlineContent = {
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
value = text,
modifier = Modifier.fillMaxWidth(),
label = { Text(label) },
suffix = {
if (errorHint.isNotBlank()) {
Text(
text = if (isError) errorHint else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
},
isError = isError,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
onValueChange = onValueChange
)
})
}
private fun isValidTemplateId(id: String): Boolean {
return Regex("""^([A-Za-z][A-Za-z\d_]*\.)*[A-Za-z][A-Za-z\d_]*$""").matches(id)
}
private fun isTemplateExist(id: String): Boolean {
return getAppProfileTemplate(id).isNotBlank()
}

View file

@ -0,0 +1,410 @@
package com.sukisu.ultra.ui.screen
import android.content.Context
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.component.ConfirmResult
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.theme.getCardColors
import com.sukisu.ultra.ui.theme.getCardElevation
import com.sukisu.ultra.ui.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val SPACING_SMALL = 3.dp
private val SPACING_MEDIUM = 8.dp
private val SPACING_LARGE = 16.dp
data class UmountPathEntry(
val path: String,
val flags: Int,
)
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun UmountManagerScreen(navigator: DestinationsNavigator) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val snackBarHost = LocalSnackbarHost.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val confirmDialog = rememberConfirmDialog()
var pathList by remember { mutableStateOf<List<UmountPathEntry>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
var showAddDialog by remember { mutableStateOf(false) }
fun loadPaths() {
scope.launch(Dispatchers.IO) {
isLoading = true
val result = listUmountPaths()
val entries = parseUmountPaths(result)
withContext(Dispatchers.Main) {
pathList = entries
isLoading = false
}
}
}
LaunchedEffect(Unit) {
loadPaths()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.umount_path_manager)) },
navigationIcon = {
IconButton(onClick = { navigator.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
actions = {
IconButton(onClick = { loadPaths() }) {
Icon(Icons.Filled.Refresh, contentDescription = null)
}
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(
alpha = CardConfig.cardAlpha
)
)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showAddDialog = true }
) {
Icon(Icons.Filled.Add, contentDescription = null)
}
},
snackbarHost = { SnackbarHost(snackBarHost) }
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(SPACING_LARGE),
colors = getCardColors(MaterialTheme.colorScheme.primaryContainer),
elevation = getCardElevation()
) {
Column(
modifier = Modifier.padding(SPACING_LARGE)
) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
Text(
text = stringResource(R.string.umount_path_restart_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
verticalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
) {
items(pathList, key = { it.path }) { entry ->
UmountPathCard(
entry = entry,
onDelete = {
scope.launch(Dispatchers.IO) {
val success = removeUmountPath(entry.path)
withContext(Dispatchers.Main) {
if (success) {
snackBarHost.showSnackbar(
context.getString(R.string.umount_path_removed)
)
loadPaths()
} else {
snackBarHost.showSnackbar(
context.getString(R.string.operation_failed)
)
}
}
}
}
)
}
item {
Spacer(modifier = Modifier.height(SPACING_LARGE))
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SPACING_LARGE),
horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
) {
Button(
onClick = {
scope.launch {
if (confirmDialog.awaitConfirm(
title = context.getString(R.string.confirm_action),
content = context.getString(R.string.confirm_clear_custom_paths)
) == ConfirmResult.Confirmed) {
withContext(Dispatchers.IO) {
val success = clearCustomUmountPaths()
withContext(Dispatchers.Main) {
if (success) {
snackBarHost.showSnackbar(
context.getString(R.string.custom_paths_cleared)
)
loadPaths()
} else {
snackBarHost.showSnackbar(
context.getString(R.string.operation_failed)
)
}
}
}
}
}
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.DeleteForever, contentDescription = null)
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text(stringResource(R.string.clear_custom_paths))
}
Button(
onClick = {
scope.launch(Dispatchers.IO) {
val success = applyUmountConfigToKernel()
withContext(Dispatchers.Main) {
if (success) {
snackBarHost.showSnackbar(
context.getString(R.string.config_applied)
)
} else {
snackBarHost.showSnackbar(
context.getString(R.string.operation_failed)
)
}
}
}
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Filled.Check, contentDescription = null)
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
Text(stringResource(R.string.apply_config))
}
}
}
}
}
}
if (showAddDialog) {
AddUmountPathDialog(
onDismiss = { showAddDialog = false },
onConfirm = { path, flags ->
showAddDialog = false
scope.launch(Dispatchers.IO) {
val success = addUmountPath(path, flags)
withContext(Dispatchers.Main) {
if (success) {
saveUmountConfig()
snackBarHost.showSnackbar(
context.getString(R.string.umount_path_added)
)
loadPaths()
} else {
snackBarHost.showSnackbar(
context.getString(R.string.operation_failed)
)
}
}
}
}
)
}
}
}
@Composable
fun UmountPathCard(
entry: UmountPathEntry,
onDelete: () -> Unit
) {
val confirmDialog = rememberConfirmDialog()
val scope = rememberCoroutineScope()
val context = LocalContext.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
elevation = getCardElevation()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(SPACING_LARGE),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(SPACING_LARGE))
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.path,
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(SPACING_SMALL))
Text(
text = buildString {
append(context.getString(R.string.flags))
append(": ")
append(entry.flags.toUmountFlagName(context))
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = {
scope.launch {
if (confirmDialog.awaitConfirm(
title = context.getString(R.string.confirm_delete),
content = context.getString(R.string.confirm_delete_umount_path, entry.path)
) == ConfirmResult.Confirmed) {
onDelete()
}
}
}
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
@Composable
fun AddUmountPathDialog(
onDismiss: () -> Unit,
onConfirm: (String, Int) -> Unit
) {
var path by rememberSaveable { mutableStateOf("") }
var flags by rememberSaveable { mutableStateOf("-1") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.add_umount_path)) },
text = {
Column {
OutlinedTextField(
value = path,
onValueChange = { path = it },
label = { Text(stringResource(R.string.mount_path)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
OutlinedTextField(
value = flags,
onValueChange = { flags = it },
label = { Text(stringResource(R.string.umount_flags)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text(stringResource(R.string.umount_flags_hint)) }
)
}
},
confirmButton = {
TextButton(
onClick = {
val flagsInt = flags.toIntOrNull() ?: -1
onConfirm(path, flagsInt)
},
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> {
val lines = output.lines().filter { it.isNotBlank() }
if (lines.size < 2) return emptyList()
return lines.drop(2).mapNotNull { line ->
val parts = line.trim().split(Regex("\\s+"))
if (parts.size >= 2) {
UmountPathEntry(
path = parts[0],
flags = parts[1].toIntOrNull() ?: -1
)
} else null
}
}
private fun Int.toUmountFlagName(context: Context): String {
return when (this) {
-1 -> context.getString(R.string.mnt_detach)
else -> this.toString()
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,928 @@
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
)
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,555 @@
package com.sukisu.ultra.ui.susfs.util
import android.annotation.SuppressLint
/**
* Magisk模块脚本生成器
* 用于生成各种启动脚本的内容
*/
object ScriptGenerator {
// 常量定义
private const val DEFAULT_UNAME = "default"
private const val DEFAULT_BUILD_TIME = "default"
private const val LOG_DIR = "/data/adb/ksu/log"
/**
* 生成所有脚本文件
*/
fun generateAllScripts(config: SuSFSManager.ModuleConfig): Map<String, String> {
return mapOf(
"service.sh" to generateServiceScript(config),
"post-fs-data.sh" to generatePostFsDataScript(config),
"post-mount.sh" to generatePostMountScript(config),
"boot-completed.sh" to generateBootCompletedScript(config)
)
}
// 日志相关的通用脚本片段
private fun generateLogSetup(logFileName: String): String = """
# 日志目录
LOG_DIR="$LOG_DIR"
LOG_FILE="${'$'}LOG_DIR/$logFileName"
# 创建日志目录
mkdir -p "${'$'}LOG_DIR"
# 获取当前时间
get_current_time() {
date '+%Y-%m-%d %H:%M:%S'
}
""".trimIndent()
// 二进制文件检查的通用脚本片段
private fun generateBinaryCheck(targetPath: String): String = """
# 检查SuSFS二进制文件
SUSFS_BIN="$targetPath"
if [ ! -f "${'$'}SUSFS_BIN" ]; then
echo "$(get_current_time): SuSFS二进制文件未找到: ${'$'}SUSFS_BIN" >> "${'$'}LOG_FILE"
exit 1
fi
""".trimIndent()
/**
* 生成service.sh脚本内容
*/
@SuppressLint("SdCardPath")
private fun generateServiceScript(config: SuSFSManager.ModuleConfig): String {
return buildString {
appendLine("#!/system/bin/sh")
appendLine("# SuSFS Service Script")
appendLine("# 在系统服务启动后执行")
appendLine()
appendLine(generateLogSetup("susfs_service.log"))
appendLine()
appendLine(generateBinaryCheck(config.targetPath))
appendLine()
if (shouldConfigureInService(config)) {
// 添加SUS路径 (仅在不支持隐藏挂载时)
if (!config.support158 && config.susPaths.isNotEmpty()) {
appendLine()
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
appendLine("sleep 45")
generateSusPathsSection(config.susPaths)
}
// 设置uname和构建时间
generateUnameSection(config)
// 添加Kstat配置
generateKstatSection(config.kstatConfigs, config.addKstatPaths)
}
// 添加日志设置
generateLogSettingSection(config.enableLog)
// 隐藏BL相关配置
if (config.enableHideBl) {
generateHideBlSection()
}
// 清理工具残留
if (config.enableCleanupResidue) {
generateCleanupResidueSection()
}
appendLine("echo \"$(get_current_time): Service脚本执行完成\" >> \"${'$'}LOG_FILE\"")
}
}
/**
* 判断是否需要在service中配置
*/
private fun shouldConfigureInService(config: SuSFSManager.ModuleConfig): Boolean {
return config.susPaths.isNotEmpty() ||
config.susLoopPaths.isNotEmpty() ||
config.kstatConfigs.isNotEmpty() ||
config.addKstatPaths.isNotEmpty() ||
(!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME))
}
private fun StringBuilder.generateLogSettingSection(enableLog: Boolean) {
appendLine("# 设置日志启用状态")
val logValue = if (enableLog) 1 else 0
appendLine("\"${'$'}SUSFS_BIN\" enable_log $logValue")
appendLine("echo \"$(get_current_time): 日志功能设置为: ${if (enableLog) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
appendLine()
}
private fun StringBuilder.generateAvcLogSpoofingSection(enableAvcLogSpoofing: Boolean) {
appendLine("# 设置AVC日志欺骗状态")
val avcLogValue = if (enableAvcLogSpoofing) 1 else 0
appendLine("\"${'$'}SUSFS_BIN\" enable_avc_log_spoofing $avcLogValue")
appendLine("echo \"$(get_current_time): AVC日志欺骗功能设置为: ${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
appendLine()
}
private fun StringBuilder.generateSusPathsSection(susPaths: Set<String>) {
if (susPaths.isNotEmpty()) {
appendLine("# 添加SUS路径")
susPaths.forEach { path ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path '$path'")
appendLine("echo \"$(get_current_time): 添加SUS路径: $path\" >> \"${'$'}LOG_FILE\"")
}
appendLine()
}
}
private fun StringBuilder.generateSusLoopPathsSection(susLoopPaths: Set<String>) {
if (susLoopPaths.isNotEmpty()) {
appendLine("# 添加SUS循环路径")
susLoopPaths.forEach { path ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path_loop '$path'")
appendLine("echo \"$(get_current_time): 添加SUS循环路径: $path\" >> \"${'$'}LOG_FILE\"")
}
appendLine()
}
}
@SuppressLint("SdCardPath")
private fun StringBuilder.generateKstatSection(
kstatConfigs: Set<String>,
addKstatPaths: Set<String>
) {
// 添加Kstat路径
if (addKstatPaths.isNotEmpty()) {
appendLine("# 添加Kstat路径")
addKstatPaths.forEach { path ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat '$path'")
appendLine("echo \"$(get_current_time): 添加Kstat路径: $path\" >> \"${'$'}LOG_FILE\"")
}
appendLine()
}
// 添加Kstat静态配置
if (kstatConfigs.isNotEmpty()) {
appendLine("# 添加Kstat静态配置")
kstatConfigs.forEach { config ->
val parts = config.split("|")
if (parts.size >= 13) {
val path = parts[0]
val params = parts.drop(1).joinToString("' '", "'", "'")
appendLine()
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat_statically '$path' $params")
appendLine("echo \"$(get_current_time): 添加Kstat静态配置: $path\" >> \"${'$'}LOG_FILE\"")
appendLine()
appendLine("\"${'$'}SUSFS_BIN\" update_sus_kstat '$path'")
appendLine("echo \"$(get_current_time): 更新Kstat配置: $path\" >> \"${'$'}LOG_FILE\"")
}
}
appendLine()
}
}
private fun StringBuilder.generateUnameSection(config: SuSFSManager.ModuleConfig) {
if (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
appendLine("# 设置uname和构建时间")
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'")
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"")
appendLine()
}
}
private fun StringBuilder.generateHideBlSection() {
appendLine("# 隐藏BL 来自 Shamiko 脚本")
appendLine(
"""
RESETPROP_BIN="/data/adb/ksu/bin/resetprop"
check_reset_prop() {
local NAME=$1
local EXPECTED=$2
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
}
check_missing_prop() {
local NAME=$1
local EXPECTED=$2
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
}
check_missing_match_prop() {
local NAME=$1
local EXPECTED=$2
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
}
contains_reset_prop() {
local NAME=$1
local CONTAINS=$2
local NEWVAL=$3
case "$("${'$'}RESETPROP_BIN" ${'$'}NAME)" in
*"${'$'}CONTAINS"*) "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}NEWVAL ;;
esac
}
""".trimIndent())
appendLine()
appendLine("sleep 30")
appendLine()
appendLine("\"${'$'}RESETPROP_BIN\" -w sys.boot_completed 0")
// 添加所有系统属性重置
val systemProps = listOf(
"ro.boot.vbmeta.invalidate_on_error" to "yes",
"ro.boot.vbmeta.avb_version" to "1.2",
"ro.boot.vbmeta.hash_alg" to "sha256",
"ro.boot.vbmeta.size" to "19968",
"ro.boot.vbmeta.device_state" to "locked",
"ro.boot.verifiedbootstate" to "green",
"ro.boot.flash.locked" to "1",
"ro.boot.veritymode" to "enforcing",
"ro.boot.warranty_bit" to "0",
"ro.warranty_bit" to "0",
"ro.debuggable" to "0",
"ro.force.debuggable" to "0",
"ro.secure" to "1",
"ro.adb.secure" to "1",
"ro.build.type" to "user",
"ro.build.tags" to "release-keys",
"ro.vendor.boot.warranty_bit" to "0",
"ro.vendor.warranty_bit" to "0",
"vendor.boot.vbmeta.device_state" to "locked",
"vendor.boot.verifiedbootstate" to "green",
"sys.oem_unlock_allowed" to "0",
"ro.secureboot.lockstate" to "locked",
"ro.boot.realmebootstate" to "green",
"ro.boot.realme.lockstate" to "1",
"ro.crypto.state" to "encrypted"
)
systemProps.forEach { (prop, value) ->
when {
prop.startsWith("ro.boot.vbmeta") && prop.endsWith("_on_error") ->
appendLine("check_missing_prop \"$prop\" \"$value\"")
prop.contains("device_state") || prop.contains("verifiedbootstate") ->
appendLine("check_missing_match_prop \"$prop\" \"$value\"")
else ->
appendLine("check_reset_prop \"$prop\" \"$value\"")
}
}
appendLine()
appendLine("# Hide adb debugging traces")
appendLine("resetprop \"sys.usb.adb.disabled\" \" \"")
appendLine()
appendLine("# Hide recovery boot mode")
appendLine("contains_reset_prop \"ro.bootmode\" \"recovery\" \"unknown\"")
appendLine("contains_reset_prop \"ro.boot.bootmode\" \"recovery\" \"unknown\"")
appendLine("contains_reset_prop \"vendor.boot.bootmode\" \"recovery\" \"unknown\"")
appendLine()
appendLine("# Hide cloudphone detection")
appendLine("[ -n \"$(resetprop ro.kernel.qemu)\" ] && resetprop ro.kernel.qemu \"\"")
appendLine()
}
// 清理残留脚本生成
private fun StringBuilder.generateCleanupResidueSection() {
appendLine("# 清理工具残留文件")
appendLine("echo \"$(get_current_time): 开始清理工具残留\" >> \"${'$'}LOG_FILE\"")
appendLine()
// 定义清理函数
appendLine("""
cleanup_path() {
local path="$1"
local desc="$2"
local current="$3"
local total="$4"
if [ -n "${'$'}desc" ]; then
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path (${'$'}desc)" >> "${'$'}LOG_FILE"
else
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path" >> "${'$'}LOG_FILE"
fi
if rm -rf "${'$'}path" 2>/dev/null; then
echo "$(get_current_time): ✓ 成功清理: ${'$'}path" >> "${'$'}LOG_FILE"
else
echo "$(get_current_time): ✗ 清理失败或不存在: ${'$'}path" >> "${'$'}LOG_FILE"
fi
}
""".trimIndent())
appendLine()
appendLine("# 开始清理各种工具残留")
appendLine("TOTAL=33")
appendLine()
val cleanupPaths = listOf(
"/data/local/stryker/" to "Stryker残留",
"/data/system/AppRetention" to "AppRetention残留",
"/data/local/tmp/luckys" to "Luck Tool残留",
"/data/local/tmp/HyperCeiler" to "西米露残留",
"/data/local/tmp/simpleHook" to "simple Hook残留",
"/data/local/tmp/DisabledAllGoogleServices" to "谷歌省电模块残留",
"/data/local/MIO" to "解包软件",
"/data/DNA" to "解包软件",
"/data/local/tmp/cleaner_starter" to "质感清理残留",
"/data/local/tmp/byyang" to "",
"/data/local/tmp/mount_mask" to "",
"/data/local/tmp/mount_mark" to "",
"/data/local/tmp/scriptTMP" to "",
"/data/local/luckys" to "",
"/data/local/tmp/horae_control.log" to "",
"/data/gpu_freq_table.conf" to "",
"/storage/emulated/0/Download/advanced/" to "",
"/storage/emulated/0/Documents/advanced/" to "爱玩机",
"/storage/emulated/0/Android/naki/" to "旧版asoulopt",
"/data/swap_config.conf" to "scene附加模块2",
"/data/local/tmp/resetprop" to "",
"/dev/cpuset/AppOpt/" to "AppOpt模块",
"/storage/emulated/0/Android/Clash/" to "Clash for Magisk模块",
"/storage/emulated/0/Android/Yume-Yunyun/" to "网易云后台优化模块",
"/data/local/tmp/Surfing_update" to "Surfing模块缓存",
"/data/encore/custom_default_cpu_gov" to "encore模块",
"/data/encore/default_cpu_gov" to "encore模块",
"/data/local/tmp/yshell" to "",
"/data/local/tmp/encore_logo.png" to "",
"/storage/emulated/legacy/" to "",
"/storage/emulated/elgg/" to "",
"/data/system/junge/" to "",
"/data/local/tmp/mount_namespace" to "挂载命名空间残留"
)
cleanupPaths.forEachIndexed { index, (path, desc) ->
val current = index + 1
appendLine("cleanup_path '$path' '$desc' $current \$TOTAL")
}
appendLine()
appendLine("echo \"$(get_current_time): 工具残留清理完成\" >> \"${'$'}LOG_FILE\"")
appendLine()
}
/**
* 生成post-fs-data.sh脚本内容
*/
private fun generatePostFsDataScript(config: SuSFSManager.ModuleConfig): String {
return buildString {
appendLine("#!/system/bin/sh")
appendLine("# SuSFS Post-FS-Data Script")
appendLine("# 在文件系统挂载后但在系统完全启动前执行")
appendLine()
appendLine(generateLogSetup("susfs_post_fs_data.log"))
appendLine()
appendLine(generateBinaryCheck(config.targetPath))
appendLine()
appendLine("echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"${'$'}LOG_FILE\"")
appendLine()
// 设置uname和构建时间 - 只有在选择在post-fs-data中执行时才执行
if (config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
appendLine("# 设置uname和构建时间")
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'")
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"")
appendLine()
}
generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService, config.support158)
// 添加AVC日志欺骗设置
generateAvcLogSpoofingSection(config.enableAvcLogSpoofing)
appendLine("echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"${'$'}LOG_FILE\"")
}
}
// 添加新的生成方法
private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean, support158: Boolean) {
if (support158) {
appendLine("# 设置Zygote隔离服务卸载状态")
val umountValue = if (umountForZygoteIsoService) 1 else 0
appendLine("\"${'$'}SUSFS_BIN\" umount_for_zygote_iso_service $umountValue")
appendLine("echo \"$(get_current_time): Zygote隔离服务卸载设置为: ${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
appendLine()
}
}
/**
* 生成post-mount.sh脚本内容
*/
private fun generatePostMountScript(config: SuSFSManager.ModuleConfig): String {
return buildString {
appendLine("#!/system/bin/sh")
appendLine("# SuSFS Post-Mount Script")
appendLine("# 在所有分区挂载完成后执行")
appendLine()
appendLine(generateLogSetup("susfs_post_mount.log"))
appendLine()
appendLine("echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"${'$'}LOG_FILE\"")
appendLine()
appendLine(generateBinaryCheck(config.targetPath))
appendLine()
// 添加SUS挂载
if (config.susMounts.isNotEmpty()) {
appendLine("# 添加SUS挂载")
config.susMounts.forEach { mount ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_mount '$mount'")
appendLine("echo \"$(get_current_time): 添加SUS挂载: $mount\" >> \"${'$'}LOG_FILE\"")
}
appendLine()
}
// 添加尝试卸载
if (config.tryUmounts.isNotEmpty()) {
appendLine("# 添加尝试卸载")
config.tryUmounts.forEach { umount ->
val parts = umount.split("|")
if (parts.size == 2) {
val path = parts[0]
val mode = parts[1]
appendLine("\"${'$'}SUSFS_BIN\" add_try_umount '$path' $mode")
appendLine("echo \"$(get_current_time): 添加尝试卸载: $path (模式: $mode)\" >> \"${'$'}LOG_FILE\"")
}
}
appendLine()
}
appendLine("echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"${'$'}LOG_FILE\"")
}
}
/**
* 生成boot-completed.sh脚本内容
*/
@SuppressLint("SdCardPath")
private fun generateBootCompletedScript(config: SuSFSManager.ModuleConfig): String {
return buildString {
appendLine("#!/system/bin/sh")
appendLine("# SuSFS Boot-Completed Script")
appendLine("# 在系统完全启动后执行")
appendLine()
appendLine(generateLogSetup("susfs_boot_completed.log"))
appendLine()
appendLine("echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"${'$'}LOG_FILE\"")
appendLine()
appendLine(generateBinaryCheck(config.targetPath))
appendLine()
// 仅在支持隐藏挂载功能时执行相关配置
if (config.support158) {
// SUS挂载隐藏控制
val hideValue = if (config.hideSusMountsForAllProcs) 1 else 0
appendLine("# 设置SUS挂载隐藏控制")
appendLine("\"${'$'}SUSFS_BIN\" hide_sus_mnts_for_all_procs $hideValue")
appendLine("echo \"$(get_current_time): SUS挂载隐藏控制设置为: ${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"${'$'}LOG_FILE\"")
appendLine()
// 路径设置和SUS路径设置
if (config.susPaths.isNotEmpty() || config.susLoopPaths.isNotEmpty()) {
generatePathSettingSection(config.androidDataPath, config.sdcardPath)
appendLine()
// 添加普通SUS路径
if (config.susPaths.isNotEmpty()) {
generateSusPathsSection(config.susPaths)
}
// 添加循环SUS路径
if (config.susLoopPaths.isNotEmpty()) {
generateSusLoopPathsSection(config.susLoopPaths)
}
if (config.susMaps.isNotEmpty()) {
generateSusMapsSection(config.susMaps)
}
}
}
appendLine("echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"${'$'}LOG_FILE\"")
}
}
private fun StringBuilder.generateSusMapsSection(susMaps: Set<String>) {
if (susMaps.isNotEmpty()) {
appendLine("# 添加SUS映射")
susMaps.forEach { map ->
appendLine("\"${'$'}SUSFS_BIN\" add_sus_map '$map'")
appendLine("echo \"$(get_current_time): 添加SUS映射: $map\" >> \"${'$'}LOG_FILE\"")
}
appendLine()
}
}
@SuppressLint("SdCardPath")
private fun StringBuilder.generatePathSettingSection(androidDataPath: String, sdcardPath: String) {
appendLine("# 路径配置")
appendLine("# 设置Android Data路径")
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
appendLine("sleep 60")
appendLine()
appendLine("\"${'$'}SUSFS_BIN\" set_android_data_root_path '$androidDataPath'")
appendLine("echo \"$(get_current_time): Android Data路径设置为: $androidDataPath\" >> \"${'$'}LOG_FILE\"")
appendLine()
appendLine("# 设置SD卡路径")
appendLine("\"${'$'}SUSFS_BIN\" set_sdcard_root_path '$sdcardPath'")
appendLine("echo \"$(get_current_time): SD卡路径设置为: $sdcardPath\" >> \"${'$'}LOG_FILE\"")
appendLine()
}
/**
* 生成module.prop文件内容
*/
fun generateModuleProp(moduleId: String): String {
val moduleVersion = "v1.0.2"
val moduleVersionCode = "1002"
return """
id=$moduleId
name=SuSFS Manager
version=$moduleVersion
versionCode=$moduleVersionCode
author=ShirkNeko
description=SuSFS Manager Auto Configuration Module (自动生成请不要手动卸载或删除该模块! / Automatically generated Please do not manually uninstall or delete the module!)
updateJson=
""".trimIndent()
}
}

View file

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

View file

@ -0,0 +1,615 @@
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
}
}
}

View file

@ -0,0 +1,593 @@
package com.sukisu.ultra.ui.theme
import android.content.Context
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.paint
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.zIndex
import androidx.core.content.edit
import androidx.core.net.toUri
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import com.sukisu.ultra.ui.theme.util.BackgroundTransformation
import com.sukisu.ultra.ui.theme.util.saveTransformedBackground
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
@Stable
object ThemeConfig {
// 主题状态
var customBackgroundUri by mutableStateOf<Uri?>(null)
var forceDarkMode by mutableStateOf<Boolean?>(null)
var currentTheme by mutableStateOf<ThemeColors>(ThemeColors.Default)
var useDynamicColor by mutableStateOf(false)
// 背景状态
var backgroundImageLoaded by mutableStateOf(false)
var isThemeChanging by mutableStateOf(false)
var preventBackgroundRefresh by mutableStateOf(false)
// 主题变化检测
private var lastDarkModeState: Boolean? = null
fun detectThemeChange(currentDarkMode: Boolean): Boolean {
val hasChanged = lastDarkModeState != null && lastDarkModeState != currentDarkMode
lastDarkModeState = currentDarkMode
return hasChanged
}
fun resetBackgroundState() {
if (!preventBackgroundRefresh) {
backgroundImageLoaded = false
}
isThemeChanging = true
}
fun updateTheme(
theme: ThemeColors? = null,
dynamicColor: Boolean? = null,
darkMode: Boolean? = null
) {
theme?.let { currentTheme = it }
dynamicColor?.let { useDynamicColor = it }
darkMode?.let { forceDarkMode = it }
}
fun reset() {
customBackgroundUri = null
forceDarkMode = null
currentTheme = ThemeColors.Default
useDynamicColor = false
backgroundImageLoaded = false
isThemeChanging = false
preventBackgroundRefresh = false
lastDarkModeState = null
}
}
object ThemeManager {
private const val PREFS_NAME = "theme_prefs"
fun saveThemeMode(context: Context, forceDark: Boolean?) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
putString("theme_mode", when (forceDark) {
true -> "dark"
false -> "light"
null -> "system"
})
}
ThemeConfig.forceDarkMode = forceDark
}
fun loadThemeMode(context: Context) {
val mode = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString("theme_mode", "system")
ThemeConfig.forceDarkMode = when (mode) {
"dark" -> true
"light" -> false
else -> null
}
}
fun saveThemeColors(context: Context, themeName: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
putString("theme_colors", themeName)
}
ThemeConfig.currentTheme = ThemeColors.fromName(themeName)
}
fun loadThemeColors(context: Context) {
val themeName = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString("theme_colors", "default") ?: "default"
ThemeConfig.currentTheme = ThemeColors.fromName(themeName)
}
fun saveDynamicColorState(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
putBoolean("use_dynamic_color", enabled)
}
ThemeConfig.useDynamicColor = enabled
}
fun loadDynamicColorState(context: Context) {
val enabled = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean("use_dynamic_color", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
ThemeConfig.useDynamicColor = enabled
}
}
object BackgroundManager {
private const val TAG = "BackgroundManager"
fun saveAndApplyCustomBackground(
context: Context,
uri: Uri,
transformation: BackgroundTransformation? = null
) {
try {
val finalUri = if (transformation != null) {
context.saveTransformedBackground(uri, transformation)
} else {
copyImageToInternalStorage(context, uri)
}
saveBackgroundUri(context, finalUri)
ThemeConfig.customBackgroundUri = finalUri
CardConfig.updateBackground(true)
resetBackgroundState(context)
} catch (e: Exception) {
Log.e(TAG, "保存背景失败: ${e.message}", e)
}
}
fun clearCustomBackground(context: Context) {
saveBackgroundUri(context, null)
ThemeConfig.customBackgroundUri = null
CardConfig.updateBackground(false)
resetBackgroundState(context)
}
fun loadCustomBackground(context: Context) {
val uriString = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
.getString("custom_background", null)
val newUri = uriString?.toUri()
val preventRefresh = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
.getBoolean("prevent_background_refresh", false)
ThemeConfig.preventBackgroundRefresh = preventRefresh
if (!preventRefresh || ThemeConfig.customBackgroundUri?.toString() != newUri?.toString()) {
Log.d(TAG, "加载自定义背景: $uriString")
ThemeConfig.customBackgroundUri = newUri
ThemeConfig.backgroundImageLoaded = false
CardConfig.updateBackground(newUri != null)
}
}
private fun saveBackgroundUri(context: Context, uri: Uri?) {
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
putString("custom_background", uri?.toString())
putBoolean("prevent_background_refresh", false)
}
}
private fun resetBackgroundState(context: Context) {
ThemeConfig.backgroundImageLoaded = false
ThemeConfig.preventBackgroundRefresh = false
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
putBoolean("prevent_background_refresh", false)
}
}
private fun copyImageToInternalStorage(context: Context, uri: Uri): Uri? {
return try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
val fileName = "custom_background_${System.currentTimeMillis()}.jpg"
val file = File(context.filesDir, fileName)
FileOutputStream(file).use { outputStream ->
val buffer = ByteArray(8 * 1024)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
}
outputStream.flush()
}
inputStream.close()
Uri.fromFile(file)
} catch (e: Exception) {
Log.e(TAG, "复制图片失败: ${e.message}", e)
null
}
}
}
@Composable
fun KernelSUTheme(
darkTheme: Boolean = when(ThemeConfig.forceDarkMode) {
true -> true
false -> false
null -> isSystemInDarkTheme()
},
dynamicColor: Boolean = ThemeConfig.useDynamicColor,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val systemIsDark = isSystemInDarkTheme()
// 初始化主题
ThemeInitializer(context = context, systemIsDark = systemIsDark)
// 创建颜色方案
val colorScheme = createColorScheme(context, darkTheme, dynamicColor)
// 系统栏样式
SystemBarController(darkTheme)
MaterialTheme(
colorScheme = colorScheme,
typography = Typography
) {
Box(modifier = Modifier.fillMaxSize()) {
// 背景层
BackgroundLayer(darkTheme)
// 内容层
Box(modifier = Modifier.fillMaxSize().zIndex(1f)) {
content()
}
}
}
}
@Composable
private fun ThemeInitializer(context: Context, systemIsDark: Boolean) {
val themeChanged = ThemeConfig.detectThemeChange(systemIsDark)
val scope = rememberCoroutineScope()
// 处理系统主题变化
LaunchedEffect(systemIsDark, themeChanged) {
if (ThemeConfig.forceDarkMode == null && themeChanged) {
Log.d("ThemeSystem", "系统主题变化: $systemIsDark")
ThemeConfig.resetBackgroundState()
if (!ThemeConfig.preventBackgroundRefresh) {
BackgroundManager.loadCustomBackground(context)
}
CardConfig.apply {
load(context)
setThemeDefaults(systemIsDark)
save(context)
}
}
}
// 初始加载配置
LaunchedEffect(Unit) {
scope.launch {
ThemeManager.loadThemeMode(context)
ThemeManager.loadThemeColors(context)
ThemeManager.loadDynamicColorState(context)
CardConfig.load(context)
if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) {
BackgroundManager.loadCustomBackground(context)
}
}
}
}
@Composable
private fun BackgroundLayer(darkTheme: Boolean) {
val backgroundUri = rememberSaveable { mutableStateOf(ThemeConfig.customBackgroundUri) }
LaunchedEffect(ThemeConfig.customBackgroundUri) {
backgroundUri.value = ThemeConfig.customBackgroundUri
}
// 默认背景
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(-2f)
.background(
if (CardConfig.isCustomBackgroundEnabled) {
MaterialTheme.colorScheme.surfaceContainerLow
} else {
MaterialTheme.colorScheme.background
}
)
)
// 自定义背景
backgroundUri.value?.let { uri ->
CustomBackgroundLayer(uri = uri, darkTheme = darkTheme)
}
}
@Composable
private fun CustomBackgroundLayer(uri: Uri, darkTheme: Boolean) {
val painter = rememberAsyncImagePainter(
model = uri,
onError = { error ->
Log.e("ThemeSystem", "背景加载失败: ${error.result.throwable.message}")
ThemeConfig.customBackgroundUri = null
},
onSuccess = {
Log.d("ThemeSystem", "背景加载成功")
ThemeConfig.backgroundImageLoaded = true
ThemeConfig.isThemeChanging = false
}
)
val transition = updateTransition(
targetState = ThemeConfig.backgroundImageLoaded,
label = "backgroundTransition"
)
val alpha by transition.animateFloat(
label = "backgroundAlpha",
transitionSpec = {
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
}
) { loaded -> if (loaded) 1f else 0f }
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(-1f)
.alpha(alpha)
) {
// 背景图片
Box(
modifier = Modifier
.fillMaxSize()
.paint(painter = painter, contentScale = ContentScale.Crop)
.graphicsLayer {
this.alpha = (painter.state as? AsyncImagePainter.State.Success)?.let { 1f } ?: 0f
}
)
// 遮罩层
BackgroundOverlay(darkTheme = darkTheme)
}
}
@Composable
private fun BackgroundOverlay(darkTheme: Boolean) {
val dimFactor = CardConfig.cardDim
// 主要遮罩层
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (darkTheme) {
Color.Black.copy(alpha = 0.3f + dimFactor * 0.4f)
} else {
Color.White.copy(alpha = 0.05f + dimFactor * 0.3f)
}
)
)
// 边缘渐变遮罩
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(
Color.Transparent,
if (darkTheme) {
Color.Black.copy(alpha = 0.2f + dimFactor * 0.2f)
} else {
Color.Black.copy(alpha = 0.05f + dimFactor * 0.1f)
}
),
radius = 1000f
)
)
)
}
@Composable
private fun createColorScheme(
context: Context,
darkTheme: Boolean,
dynamicColor: Boolean
): ColorScheme {
return when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) createDynamicDarkColorScheme(context)
else createDynamicLightColorScheme(context)
}
darkTheme -> createDarkColorScheme()
else -> createLightColorScheme()
}
}
@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)
}

View file

@ -0,0 +1,108 @@
package com.sukisu.ultra.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
// 大标题
displayLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
// 标题
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
// 标题栏
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
// 主体文字
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
// 标签
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View file

@ -0,0 +1,411 @@
package com.sukisu.ultra.ui.theme.component
import android.net.Uri
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Fullscreen
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.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.theme.util.BackgroundTransformation
import com.sukisu.ultra.ui.theme.util.saveTransformedBackground
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.max
@Composable
fun ImageEditorDialog(
imageUri: Uri,
onDismiss: () -> Unit,
onConfirm: (Uri) -> Unit
) {
// 图像变换状态
val transformState = remember { ImageTransformState() }
val context = LocalContext.current
val scope = rememberCoroutineScope()
// 尺寸状态
var imageSize by remember { mutableStateOf(Size.Zero) }
var screenSize by remember { mutableStateOf(Size.Zero) }
// 动画状态
val animationSpec = spring<Float>(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
val animatedScale by animateFloatAsState(
targetValue = transformState.scale,
animationSpec = animationSpec,
label = "ScaleAnimation"
)
val animatedOffsetX by animateFloatAsState(
targetValue = transformState.offsetX,
animationSpec = animationSpec,
label = "OffsetXAnimation"
)
val animatedOffsetY by animateFloatAsState(
targetValue = transformState.offsetY,
animationSpec = animationSpec,
label = "OffsetYAnimation"
)
// 工具函数
val scaleToFullScreen = remember {
{
if (imageSize.height > 0 && screenSize.height > 0) {
val newScale = screenSize.height / imageSize.height
transformState.updateTransform(newScale, 0f, 0f)
}
}
}
val saveImage: () -> Unit = remember {
{
scope.launch {
try {
val transformation = BackgroundTransformation(
transformState.scale,
transformState.offsetX,
transformState.offsetY
)
val savedUri = context.saveTransformedBackground(imageUri, transformation)
savedUri?.let { onConfirm(it) }
} catch (_: Exception) {
}
}
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(
Color.Black.copy(alpha = 0.9f),
Color.Black.copy(alpha = 0.95f)
),
radius = 800f
)
)
.onSizeChanged { size ->
screenSize = Size(size.width.toFloat(), size.height.toFloat())
}
) {
// 图像显示区域
ImageDisplayArea(
imageUri = imageUri,
animatedScale = animatedScale,
animatedOffsetX = animatedOffsetX,
animatedOffsetY = animatedOffsetY,
transformState = transformState,
onImageSizeChanged = { imageSize = it },
modifier = Modifier.fillMaxSize()
)
// 顶部工具栏
TopToolbar(
onDismiss = onDismiss,
onFullscreen = scaleToFullScreen,
onConfirm = saveImage,
modifier = Modifier.align(Alignment.TopCenter)
)
// 底部提示信息
BottomHintCard(
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
/**
* 图像变换状态管理类
*/
private class ImageTransformState {
var scale by mutableFloatStateOf(1f)
var offsetX by mutableFloatStateOf(0f)
var offsetY by mutableFloatStateOf(0f)
private var lastScale = 1f
private var lastOffsetX = 0f
private var lastOffsetY = 0f
fun updateTransform(newScale: Float, newOffsetX: Float, newOffsetY: Float) {
val scaleDiff = abs(newScale - lastScale)
val offsetXDiff = abs(newOffsetX - lastOffsetX)
val offsetYDiff = abs(newOffsetY - lastOffsetY)
if (scaleDiff > 0.01f || offsetXDiff > 1f || offsetYDiff > 1f) {
scale = newScale
offsetX = newOffsetX
offsetY = newOffsetY
lastScale = newScale
lastOffsetX = newOffsetX
lastOffsetY = newOffsetY
}
}
fun resetToLast() {
scale = lastScale
offsetX = lastOffsetX
offsetY = lastOffsetY
}
}
/**
* 图像显示区域组件
*/
@Composable
private fun ImageDisplayArea(
imageUri: Uri,
animatedScale: Float,
animatedOffsetX: Float,
animatedOffsetY: Float,
transformState: ImageTransformState,
onImageSizeChanged: (Size) -> Unit,
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUri)
.crossfade(true)
.build(),
contentDescription = stringResource(R.string.settings_custom_background),
contentScale = ContentScale.Fit,
modifier = modifier
.graphicsLayer(
scaleX = animatedScale,
scaleY = animatedScale,
translationX = animatedOffsetX,
translationY = animatedOffsetY
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scope.launch {
try {
val newScale = (transformState.scale * zoom).coerceIn(0.5f, 3f)
val maxOffsetX = max(0f, size.width * (newScale - 1) / 2)
val maxOffsetY = max(0f, size.height * (newScale - 1) / 2)
val newOffsetX = if (maxOffsetX > 0) {
(transformState.offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
} else 0f
val newOffsetY = if (maxOffsetY > 0) {
(transformState.offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
} else 0f
transformState.updateTransform(newScale, newOffsetX, newOffsetY)
} catch (_: Exception) {
transformState.resetToLast()
}
}
}
}
.onSizeChanged { size ->
onImageSizeChanged(Size(size.width.toFloat(), size.height.toFloat()))
}
)
}
/**
* 顶部工具栏组件
*/
@Composable
private fun TopToolbar(
onDismiss: () -> Unit,
onFullscreen: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(24.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
// 关闭按钮
ActionButton(
onClick = onDismiss,
icon = Icons.Default.Close,
contentDescription = stringResource(R.string.cancel),
backgroundColor = MaterialTheme.colorScheme.error.copy(alpha = 0.9f)
)
// 全屏按钮
ActionButton(
onClick = onFullscreen,
icon = Icons.Default.Fullscreen,
contentDescription = stringResource(R.string.reprovision),
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
)
// 确认按钮
ActionButton(
onClick = onConfirm,
icon = Icons.Default.Check,
contentDescription = stringResource(R.string.confirm),
backgroundColor = Color(0xFF4CAF50).copy(alpha = 0.9f)
)
}
}
/**
* 操作按钮组件
*/
@Composable
private fun ActionButton(
onClick: () -> Unit,
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
backgroundColor: Color,
modifier: Modifier = Modifier
) {
var isPressed by remember { mutableStateOf(false) }
val buttonScale by animateFloatAsState(
targetValue = if (isPressed) 0.85f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
),
label = "ButtonScale"
)
val buttonAlpha by animateFloatAsState(
targetValue = if (isPressed) 0.8f else 1f,
animationSpec = tween(100),
label = "ButtonAlpha"
)
Surface(
onClick = {
isPressed = true
onClick()
},
modifier = modifier
.size(64.dp)
.graphicsLayer(
scaleX = buttonScale,
scaleY = buttonScale,
alpha = buttonAlpha
),
shape = CircleShape,
color = backgroundColor,
shadowElevation = 8.dp
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
LaunchedEffect(isPressed) {
if (isPressed) {
kotlinx.coroutines.delay(150)
isPressed = false
}
}
}
/**
* 底部提示卡片组件
*/
@Composable
private fun BottomHintCard(
modifier: Modifier = Modifier
) {
var isVisible by remember { mutableStateOf(true) }
val cardAlpha by animateFloatAsState(
targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(
durationMillis = 500,
easing = EaseInOutCubic
),
label = "HintAlpha"
)
val cardTranslationY by animateFloatAsState(
targetValue = if (isVisible) 0f else 100f,
animationSpec = tween(
durationMillis = 500,
easing = EaseInOutCubic
),
label = "HintTranslation"
)
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(4000)
isVisible = false
}
Card(
modifier = modifier
.fillMaxWidth()
.padding(24.dp)
.alpha(cardAlpha)
.graphicsLayer {
translationY = cardTranslationY
},
colors = CardDefaults.cardColors(
containerColor = Color.Black.copy(alpha = 0.85f)
),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
) {
Text(
text = stringResource(id = R.string.image_editor_hint),
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(20.dp)
.fillMaxWidth()
)
}
}

View file

@ -0,0 +1,110 @@
package com.sukisu.ultra.ui.theme.util
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix
import android.net.Uri
import android.util.Log
import androidx.core.graphics.createBitmap
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
data class BackgroundTransformation(
val scale: Float = 1f,
val offsetX: Float = 0f,
val offsetY: Float = 0f
)
fun Context.getImageBitmap(uri: Uri): Bitmap? {
return try {
val contentResolver: ContentResolver = contentResolver
val inputStream: InputStream = contentResolver.openInputStream(uri) ?: return null
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
bitmap
} catch (e: Exception) {
Log.e("BackgroundUtils", "Failed to get image bitmap: ${e.message}")
null
}
}
fun Context.applyTransformationToBitmap(bitmap: Bitmap, transformation: BackgroundTransformation): Bitmap {
val width = bitmap.width
val height = bitmap.height
// 创建与屏幕比例相同的目标位图
val displayMetrics = resources.displayMetrics
val screenWidth = displayMetrics.widthPixels
val screenHeight = displayMetrics.heightPixels
val screenRatio = screenHeight.toFloat() / screenWidth.toFloat()
// 计算目标宽高
val targetWidth: Int
val targetHeight: Int
if (width.toFloat() / height.toFloat() > screenRatio) {
targetHeight = height
targetWidth = (height / screenRatio).toInt()
} else {
targetWidth = width
targetHeight = (width * screenRatio).toInt()
}
// 创建与目标相同大小的位图
val scaledBitmap = createBitmap(targetWidth, targetHeight)
val canvas = Canvas(scaledBitmap)
val matrix = Matrix()
// 确保缩放值有效
val safeScale = maxOf(0.1f, transformation.scale)
matrix.postScale(safeScale, safeScale)
// 计算偏移量,确保不会出现负最大值的问题
val widthDiff = (bitmap.width * safeScale - targetWidth)
val heightDiff = (bitmap.height * safeScale - targetHeight)
// 安全计算偏移量边界
val maxOffsetX = maxOf(0f, widthDiff / 2)
val maxOffsetY = maxOf(0f, heightDiff / 2)
// 限制偏移范围
val safeOffsetX = if (maxOffsetX > 0)
transformation.offsetX.coerceIn(-maxOffsetX, maxOffsetX) else 0f
val safeOffsetY = if (maxOffsetY > 0)
transformation.offsetY.coerceIn(-maxOffsetY, maxOffsetY) else 0f
// 应用偏移量到矩阵
val translationX = -widthDiff / 2 + safeOffsetX
val translationY = -heightDiff / 2 + safeOffsetY
matrix.postTranslate(translationX, translationY)
// 将原始位图绘制到新位图上
canvas.drawBitmap(bitmap, matrix, null)
return scaledBitmap
}
fun Context.saveTransformedBackground(uri: Uri, transformation: BackgroundTransformation): Uri? {
try {
val bitmap = getImageBitmap(uri) ?: return null
val transformedBitmap = applyTransformationToBitmap(bitmap, transformation)
val fileName = "custom_background_transformed.jpg"
val file = File(filesDir, fileName)
val outputStream = FileOutputStream(file)
transformedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
outputStream.flush()
outputStream.close()
return Uri.fromFile(file)
} catch (e: Exception) {
Log.e("BackgroundUtils", "Failed to save transformed image: ${e.message}", e)
return null
}
}

View file

@ -0,0 +1,8 @@
package com.sukisu.ultra.ui.util
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.compositionLocalOf
val LocalSnackbarHost = compositionLocalOf<SnackbarHostState> {
error("CompositionLocal LocalSnackbarController not present")
}

View file

@ -0,0 +1,319 @@
package com.sukisu.ultra.ui.util
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.sukisu.ultra.ui.util.module.LatestVersionInfo
import java.io.File
import java.util.concurrent.TimeUnit
private const val TAG = "DownloadUtil"
private val CUSTOM_USER_AGENT = "SukiSU-Ultra/2.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
private const val MAX_RETRY_COUNT = 3
private const val RETRY_DELAY_MS = 3000L
/**
* @author weishu
* @date 2023/6/22.
*/
@SuppressLint("Range")
fun download(
context: Context,
url: String,
fileName: String,
description: String,
onDownloaded: (Uri) -> Unit = {},
onDownloading: () -> Unit = {},
onError: (String) -> Unit = {}
) {
Log.d(TAG, "Start Download: $url")
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val query = DownloadManager.Query()
query.setFilterByStatus(DownloadManager.STATUS_RUNNING or DownloadManager.STATUS_PAUSED or DownloadManager.STATUS_PENDING)
downloadManager.query(query).use { cursor ->
while (cursor.moveToNext()) {
val uri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_URI))
val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
val columnTitle = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_TITLE))
if (url == uri || fileName == columnTitle) {
if (status == DownloadManager.STATUS_RUNNING || status == DownloadManager.STATUS_PENDING) {
onDownloading()
return
} else if (status == DownloadManager.STATUS_SUCCESSFUL) {
onDownloaded(localUri.toUri())
return
}
}
}
}
val downloadFile = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName
)
if (downloadFile.exists()) {
downloadFile.delete()
}
val request = DownloadManager.Request(url.toUri())
.setDestinationInExternalPublicDir(
Environment.DIRECTORY_DOWNLOADS,
fileName
)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setMimeType("application/zip")
.setTitle(fileName)
.setDescription(description)
.addRequestHeader("User-Agent", CUSTOM_USER_AGENT)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
try {
val downloadId = downloadManager.enqueue(request)
Log.d(TAG, "Successful launch of the downloadID: $downloadId")
monitorDownload(context, downloadManager, downloadId, url, fileName, description, onDownloaded, onDownloading, onError)
} catch (e: Exception) {
Log.e(TAG, "Download startup failure", e)
onError("Download startup failure: ${e.message}")
}
}
private fun monitorDownload(
context: Context,
downloadManager: DownloadManager,
downloadId: Long,
url: String,
fileName: String,
description: String,
onDownloaded: (Uri) -> Unit,
onDownloading: () -> Unit,
onError: (String) -> Unit,
retryCount: Int = 0
) {
val handler = Handler(Looper.getMainLooper())
val query = DownloadManager.Query().setFilterById(downloadId)
var lastProgress = -1
var stuckCounter = 0
val runnable = object : Runnable {
override fun run() {
downloadManager.query(query).use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
@SuppressLint("Range")
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> {
@SuppressLint("Range")
val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
Log.d(TAG, "Download Successfully: $localUri")
onDownloaded(localUri.toUri())
return
}
DownloadManager.STATUS_FAILED -> {
@SuppressLint("Range")
val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON))
Log.d(TAG, "Download failed with reason code: $reason")
if (retryCount < MAX_RETRY_COUNT) {
Log.d(TAG, "Attempts to re download, number of retries: ${retryCount + 1}")
handler.postDelayed({
downloadManager.remove(downloadId)
download(context, url, fileName, description, onDownloaded, onDownloading, onError)
}, RETRY_DELAY_MS)
} else {
onError("Download failed, please check network connection or storage space")
}
return
}
DownloadManager.STATUS_RUNNING, DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED -> {
@SuppressLint("Range")
val totalBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
@SuppressLint("Range")
val downloadedBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
if (totalBytes > 0) {
val progress = (downloadedBytes * 100 / totalBytes).toInt()
if (progress == lastProgress) {
stuckCounter++
if (stuckCounter > 30) {
if (retryCount < MAX_RETRY_COUNT) {
Log.d(TAG, "Download stalled and restarted")
downloadManager.remove(downloadId)
download(context, url, fileName, description, onDownloaded, onDownloading, onError)
return
}
}
} else {
lastProgress = progress
stuckCounter = 0
Log.d(TAG, "Download progress: $progress% ($downloadedBytes/$totalBytes)")
}
}
}
}
}
}
handler.postDelayed(this, 1000)
}
}
handler.post(runnable)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
if (id == downloadId) {
handler.removeCallbacks(runnable)
val query = DownloadManager.Query().setFilterById(downloadId)
downloadManager.query(query).use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
@SuppressLint("Range")
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
if (status == DownloadManager.STATUS_SUCCESSFUL) {
@SuppressLint("Range")
val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
onDownloaded(localUri.toUri())
} else {
if (retryCount < MAX_RETRY_COUNT) {
download(context!!, url, fileName, description, onDownloaded, onDownloading, onError)
} else {
onError("Download failed, please try again later")
}
}
}
}
context?.unregisterReceiver(this)
}
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
ContextCompat.RECEIVER_EXPORTED
)
}
fun checkNewVersion(): LatestVersionInfo {
val url = "https://api.github.com/repos/ShirkNeko/SukiSU-Ultra/releases/latest"
val defaultValue = LatestVersionInfo()
return runCatching {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder()
.url(url)
.header("User-Agent", CUSTOM_USER_AGENT)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.d("CheckUpdate", "Network request failed: ${response.message}")
return defaultValue
}
val body = response.body?.string()
if (body == null) {
Log.d("CheckUpdate", "Return data is null")
return defaultValue
}
Log.d("CheckUpdate", "Return data: $body")
val json = org.json.JSONObject(body)
// 直接从 tag_name 提取版本号(如 v1.1
val tagName = json.optString("tag_name", "")
val versionName = tagName.removePrefix("v") // 移除前缀 "v"
// 从 body 字段获取更新日志(保留换行符)
val changelog = json.optString("body")
.replace("\\r\\n", "\n") // 转换换行符
val assets = json.getJSONArray("assets")
for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i)
val name = asset.getString("name")
if (!name.endsWith(".apk")) continue
val regex = Regex("SukiSU.*_(\\d+)-release")
val matchResult = regex.find(name)
if (matchResult == null) {
Log.d("CheckUpdate", "No matches found: $name, skip over")
continue
}
val versionCode = matchResult.groupValues[1].toInt()
val downloadUrl = asset.getString("browser_download_url")
return LatestVersionInfo(
versionCode,
downloadUrl,
changelog,
versionName
)
}
Log.d("CheckUpdate", "No valid APK resource found, return default value")
defaultValue
}
}.getOrDefault(defaultValue)
}
@Composable
fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) {
DisposableEffect(context) {
val receiver = object : BroadcastReceiver() {
@SuppressLint("Range")
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE) {
val id = intent.getLongExtra(
DownloadManager.EXTRA_DOWNLOAD_ID, -1
)
val query = DownloadManager.Query().setFilterById(id)
val downloadManager =
context?.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val status = cursor.getInt(
cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
)
if (status == DownloadManager.STATUS_SUCCESSFUL) {
val uri = cursor.getString(
cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
)
onDownloaded(uri.toUri())
}
}
}
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
ContextCompat.RECEIVER_EXPORTED
)
onDispose {
context.unregisterReceiver(receiver)
}
}
}

View file

@ -0,0 +1,522 @@
package com.sukisu.ultra.ui.util
/*
* Copyright (C) 2009 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.text.TextUtils
import android.util.Log
import java.text.Collator
import java.util.Locale
class HanziToPinyin private constructor(val hasChinaCollator: Boolean) {
class Token(
var type: Int = 0,
var source: String = "",
var target: String = ""
) {
companion object {
const val LATIN = 1
const val PINYIN = 2
const val UNKNOWN = 3
}
}
private fun getToken(character: Char): Token {
val token = Token()
val letter = character.toString()
token.source = letter
var offset = -1
var cmp: Int
if (character < 256.toChar()) {
token.type = Token.LATIN
token.target = letter
return token
} else {
cmp = COLLATOR.compare(letter, FIRST_PINYIN_UNIHAN)
if (cmp < 0) {
token.type = Token.UNKNOWN
token.target = letter
return token
} else if (cmp == 0) {
token.type = Token.PINYIN
offset = 0
} else {
cmp = COLLATOR.compare(letter, LAST_PINYIN_UNIHAN)
if (cmp > 0) {
token.type = Token.UNKNOWN
token.target = letter
return token
} else if (cmp == 0) {
token.type = Token.PINYIN
offset = UNIHANS.size - 1
}
}
}
token.type = Token.PINYIN
if (offset < 0) {
var begin = 0
var end = UNIHANS.size - 1
while (begin <= end) {
offset = (begin + end) / 2
val unihan = UNIHANS[offset].toString()
cmp = COLLATOR.compare(letter, unihan)
when {
cmp == 0 -> break
cmp > 0 -> begin = offset + 1
else -> end = offset - 1
}
}
}
if (cmp < 0) {
offset--
}
val pinyin = StringBuilder()
for (j in PINYINS[offset].indices) {
if (PINYINS[offset][j] == 0.toByte()) break
pinyin.append(PINYINS[offset][j].toInt().toChar())
}
token.target = pinyin.toString()
if (TextUtils.isEmpty(token.target)) {
token.type = Token.UNKNOWN
token.target = token.source
}
return token
}
fun get(input: String?): ArrayList<Token> {
val tokens = ArrayList<Token>()
if (!hasChinaCollator || TextUtils.isEmpty(input)) {
return tokens
}
val inputLength = input!!.length
val sb = StringBuilder()
var tokenType = Token.LATIN
for (i in 0 until inputLength) {
val character = input[i]
when {
character == ' ' -> {
if (sb.isNotEmpty()) {
addToken(sb, tokens, tokenType)
}
}
character < 256.toChar() -> {
if (tokenType != Token.LATIN && sb.isNotEmpty()) {
addToken(sb, tokens, tokenType)
}
tokenType = Token.LATIN
sb.append(character)
}
else -> {
val t = getToken(character)
if (t.type == Token.PINYIN) {
if (sb.isNotEmpty()) {
addToken(sb, tokens, tokenType)
}
tokens.add(t)
tokenType = Token.PINYIN
} else {
if (tokenType != t.type && sb.isNotEmpty()) {
addToken(sb, tokens, tokenType)
}
tokenType = t.type
sb.append(character)
}
}
}
}
if (sb.isNotEmpty()) {
addToken(sb, tokens, tokenType)
}
return tokens
}
private fun addToken(sb: StringBuilder, tokens: ArrayList<Token>, tokenType: Int) {
val str = sb.toString()
tokens.add(Token(tokenType, str, str))
sb.setLength(0)
}
fun toPinyinString(string: String?): String? {
if (string == null) {
return null
}
val sb = StringBuilder()
val tokens = get(string)
for (token in tokens) {
sb.append(token.target)
}
return sb.toString().lowercase()
}
companion object {
private const val TAG = "HanziToPinyin"
private const val DEBUG = false
val UNIHANS = charArrayOf(
'阿', '哎', '安', '肮', '凹', '八',
'挀', '扳', '邦', '勹', '陂', '奔',
'伻', '屄', '边', '灬', '憋', '汃',
'冫', '癶', '峬', '嚓', '偲', '参',
'仓', '撡', '冊', '嵾', '曽', '曾',
'層', '叉', '芆', '辿', '伥', '抄',
'车', '抻', '沈', '沉', '阷', '吃',
'充', '抽', '出', '欻', '揣', '巛',
'刅', '吹', '旾', '逴', '呲', '匆',
'凑', '粗', '汆', '崔', '邨', '搓',
'咑', '呆', '丹', '当', '刀', '嘚',
'扥', '灯', '氐', '嗲', '甸', '刁',
'爹', '丁', '丟', '东', '吺', '厾',
'耑', '襨', '吨', '多', '妸', '诶',
'奀', '鞥', '儿', '发', '帆', '匚',
'飞', '分', '丰', '覅', '仏', '紑',
'伕', '旮', '侅', '甘', '冈', '皋',
'戈', '给', '根', '刯', '工', '勾',
'估', '瓜', '乖', '关', '光', '归',
'丨', '呙', '哈', '咍', '佄', '夯',
'茠', '诃', '黒', '拫', '亨', '噷',
'叿', '齁', '乯', '花', '怀', '犿',
'巟', '灰', '昏', '吙', '丌', '加',
'戋', '江', '艽', '阶', '巾', '坕',
'冂', '丩', '凥', '姢', '噘', '军',
'咔', '开', '刊', '忼', '尻', '匼',
'肎', '劥', '空', '抠', '扝', '夸',
'蒯', '宽', '匡', '亏', '坤', '扩',
'垃', '来', '兰', '啷', '捞', '肋',
'勒', '崚', '刕', '俩', '奁', '良',
'撩', '列', '拎', '刢', '溜', '囖',
'龙', '瞜', '噜', '娈', '畧', '抡',
'罗', '呣', '妈', '埋', '嫚', '牤',
'猫', '么', '呅', '门', '甿', '咪',
'宀', '喵', '乜', '民', '名', '谬',
'摸', '哞', '毪', '嗯', '拏', '腉',
'囡', '囔', '孬', '疒', '娞', '恁',
'能', '妮', '拈', '嬢', '鸟', '捏',
'囜', '宁', '妞', '农', '羺', '奴',
'奻', '疟', '黁', '郍', '喔', '讴',
'妑', '拍', '眅', '乓', '抛', '呸',
'喷', '匉', '丕', '囨', '剽', '氕',
'姘', '乒', '钋', '剖', '仆', '七',
'掐', '千', '呛', '悄', '癿', '亲',
'狅', '芎', '丘', '区', '峑', '缺',
'夋', '呥', '穣', '娆', '惹', '人',
'扔', '日', '茸', '厹', '邚', '挼',
'堧', '婑', '瞤', '捼', '仨', '毢',
'三', '桒', '掻', '閪', '森', '僧',
'杀', '筛', '山', '伤', '弰', '奢',
'申', '莘', '敒', '升', '尸', '収',
'书', '刷', '衰', '闩', '双', '谁',
'吮', '说', '厶', '忪', '捜', '苏',
'狻', '夊', '孙', '唆', '他', '囼',
'坍', '汤', '夲', '忑', '熥', '剔',
'天', '旫', '帖', '厅', '囲', '偷',
'凸', '湍', '推', '吞', '乇', '穵',
'歪', '弯', '尣', '危', '昷', '翁',
'挝', '乌', '夕', '虲', '仚', '乡',
'灱', '些', '心', '星', '凶', '休',
'吁', '吅', '削', '坃', '丫', '恹',
'央', '幺', '倻', '一', '囙', '应',
'哟', '佣', '优', '扜', '囦', '曰',
'晕', '筠', '筼', '帀', '災', '兂',
'匨', '傮', '则', '贼', '怎', '増',
'扎', '捚', '沾', '张', '长', '長',
'佋', '蜇', '贞', '争', '之', '峙',
'庢', '中', '州', '朱', '抓', '拽',
'专', '妆', '隹', '宒', '卓', '乲',
'宗', '邹', '租', '钻', '厜', '尊',
'昨', '兙', '鿃', '鿄'
)
val PINYINS = arrayOf(
byteArrayOf(65, 0, 0, 0, 0, 0), byteArrayOf(65, 73, 0, 0, 0, 0),
byteArrayOf(65, 78, 0, 0, 0, 0), byteArrayOf(65, 78, 71, 0, 0, 0),
byteArrayOf(65, 79, 0, 0, 0, 0), byteArrayOf(66, 65, 0, 0, 0, 0),
byteArrayOf(66, 65, 73, 0, 0, 0), byteArrayOf(66, 65, 78, 0, 0, 0),
byteArrayOf(66, 65, 78, 71, 0, 0), byteArrayOf(66, 65, 79, 0, 0, 0),
byteArrayOf(66, 69, 73, 0, 0, 0), byteArrayOf(66, 69, 78, 0, 0, 0),
byteArrayOf(66, 69, 78, 71, 0, 0), byteArrayOf(66, 73, 0, 0, 0, 0),
byteArrayOf(66, 73, 65, 78, 0, 0), byteArrayOf(66, 73, 65, 79, 0, 0),
byteArrayOf(66, 73, 69, 0, 0, 0), byteArrayOf(66, 73, 78, 0, 0, 0),
byteArrayOf(66, 73, 78, 71, 0, 0), byteArrayOf(66, 79, 0, 0, 0, 0),
byteArrayOf(66, 85, 0, 0, 0, 0), byteArrayOf(67, 65, 0, 0, 0, 0),
byteArrayOf(67, 65, 73, 0, 0, 0), byteArrayOf(67, 65, 78, 0, 0, 0),
byteArrayOf(67, 65, 78, 71, 0, 0), byteArrayOf(67, 65, 79, 0, 0, 0),
byteArrayOf(67, 69, 0, 0, 0, 0), byteArrayOf(67, 69, 78, 0, 0, 0),
byteArrayOf(67, 69, 78, 71, 0, 0), byteArrayOf(90, 69, 78, 71, 0, 0),
byteArrayOf(67, 69, 78, 71, 0, 0), byteArrayOf(67, 72, 65, 0, 0, 0),
byteArrayOf(67, 72, 65, 73, 0, 0), byteArrayOf(67, 72, 65, 78, 0, 0),
byteArrayOf(67, 72, 65, 78, 71, 0), byteArrayOf(67, 72, 65, 79, 0, 0),
byteArrayOf(67, 72, 69, 0, 0, 0), byteArrayOf(67, 72, 69, 78, 0, 0),
byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(67, 72, 69, 78, 0, 0),
byteArrayOf(67, 72, 69, 78, 71, 0), byteArrayOf(67, 72, 73, 0, 0, 0),
byteArrayOf(67, 72, 79, 78, 71, 0), byteArrayOf(67, 72, 79, 85, 0, 0),
byteArrayOf(67, 72, 85, 0, 0, 0), byteArrayOf(67, 72, 85, 65, 0, 0),
byteArrayOf(67, 72, 85, 65, 73, 0), byteArrayOf(67, 72, 85, 65, 78, 0),
byteArrayOf(67, 72, 85, 65, 78, 71), byteArrayOf(67, 72, 85, 73, 0, 0),
byteArrayOf(67, 72, 85, 78, 0, 0), byteArrayOf(67, 72, 85, 79, 0, 0),
byteArrayOf(67, 73, 0, 0, 0, 0), byteArrayOf(67, 79, 78, 71, 0, 0),
byteArrayOf(67, 79, 85, 0, 0, 0), byteArrayOf(67, 85, 0, 0, 0, 0),
byteArrayOf(67, 85, 65, 78, 0, 0), byteArrayOf(67, 85, 73, 0, 0, 0),
byteArrayOf(67, 85, 78, 0, 0, 0), byteArrayOf(67, 85, 79, 0, 0, 0),
byteArrayOf(68, 65, 0, 0, 0, 0), byteArrayOf(68, 65, 73, 0, 0, 0),
byteArrayOf(68, 65, 78, 0, 0, 0), byteArrayOf(68, 65, 78, 71, 0, 0),
byteArrayOf(68, 65, 79, 0, 0, 0), byteArrayOf(68, 69, 0, 0, 0, 0),
byteArrayOf(68, 69, 78, 0, 0, 0), byteArrayOf(68, 69, 78, 71, 0, 0),
byteArrayOf(68, 73, 0, 0, 0, 0), byteArrayOf(68, 73, 65, 0, 0, 0),
byteArrayOf(68, 73, 65, 78, 0, 0), byteArrayOf(68, 73, 65, 79, 0, 0),
byteArrayOf(68, 73, 69, 0, 0, 0), byteArrayOf(68, 73, 78, 71, 0, 0),
byteArrayOf(68, 73, 85, 0, 0, 0), byteArrayOf(68, 79, 78, 71, 0, 0),
byteArrayOf(68, 79, 85, 0, 0, 0), byteArrayOf(68, 85, 0, 0, 0, 0),
byteArrayOf(68, 85, 65, 78, 0, 0), byteArrayOf(68, 85, 73, 0, 0, 0),
byteArrayOf(68, 85, 78, 0, 0, 0), byteArrayOf(68, 85, 79, 0, 0, 0),
byteArrayOf(69, 0, 0, 0, 0, 0), byteArrayOf(69, 73, 0, 0, 0, 0),
byteArrayOf(69, 78, 0, 0, 0, 0), byteArrayOf(69, 78, 71, 0, 0, 0),
byteArrayOf(69, 82, 0, 0, 0, 0), byteArrayOf(70, 65, 0, 0, 0, 0),
byteArrayOf(70, 65, 78, 0, 0, 0), byteArrayOf(70, 65, 78, 71, 0, 0),
byteArrayOf(70, 69, 73, 0, 0, 0), byteArrayOf(70, 69, 78, 0, 0, 0),
byteArrayOf(70, 69, 78, 71, 0, 0), byteArrayOf(70, 73, 65, 79, 0, 0),
byteArrayOf(70, 79, 0, 0, 0, 0), byteArrayOf(70, 79, 85, 0, 0, 0),
byteArrayOf(70, 85, 0, 0, 0, 0), byteArrayOf(71, 65, 0, 0, 0, 0),
byteArrayOf(71, 65, 73, 0, 0, 0), byteArrayOf(71, 65, 78, 0, 0, 0),
byteArrayOf(71, 65, 78, 71, 0, 0), byteArrayOf(71, 65, 79, 0, 0, 0),
byteArrayOf(71, 69, 0, 0, 0, 0), byteArrayOf(71, 69, 73, 0, 0, 0),
byteArrayOf(71, 69, 78, 0, 0, 0), byteArrayOf(71, 69, 78, 71, 0, 0),
byteArrayOf(71, 79, 78, 71, 0, 0), byteArrayOf(71, 79, 85, 0, 0, 0),
byteArrayOf(71, 85, 0, 0, 0, 0), byteArrayOf(71, 85, 65, 0, 0, 0),
byteArrayOf(71, 85, 65, 73, 0, 0), byteArrayOf(71, 85, 65, 78, 0, 0),
byteArrayOf(71, 85, 65, 78, 71, 0), byteArrayOf(71, 85, 73, 0, 0, 0),
byteArrayOf(71, 85, 78, 0, 0, 0), byteArrayOf(71, 85, 79, 0, 0, 0),
byteArrayOf(72, 65, 0, 0, 0, 0), byteArrayOf(72, 65, 73, 0, 0, 0),
byteArrayOf(72, 65, 78, 0, 0, 0), byteArrayOf(72, 65, 78, 71, 0, 0),
byteArrayOf(72, 65, 79, 0, 0, 0), byteArrayOf(72, 69, 0, 0, 0, 0),
byteArrayOf(72, 69, 73, 0, 0, 0), byteArrayOf(72, 69, 78, 0, 0, 0),
byteArrayOf(72, 69, 78, 71, 0, 0), byteArrayOf(72, 77, 0, 0, 0, 0),
byteArrayOf(72, 79, 78, 71, 0, 0), byteArrayOf(72, 79, 85, 0, 0, 0),
byteArrayOf(72, 85, 0, 0, 0, 0), byteArrayOf(72, 85, 65, 0, 0, 0),
byteArrayOf(72, 85, 65, 73, 0, 0), byteArrayOf(72, 85, 65, 78, 0, 0),
byteArrayOf(72, 85, 65, 78, 71, 0), byteArrayOf(72, 85, 73, 0, 0, 0),
byteArrayOf(72, 85, 78, 0, 0, 0), byteArrayOf(72, 85, 79, 0, 0, 0),
byteArrayOf(74, 73, 0, 0, 0, 0), byteArrayOf(74, 73, 65, 0, 0, 0),
byteArrayOf(74, 73, 65, 78, 0, 0), byteArrayOf(74, 73, 65, 78, 71, 0),
byteArrayOf(74, 73, 65, 79, 0, 0), byteArrayOf(74, 73, 69, 0, 0, 0),
byteArrayOf(74, 73, 78, 0, 0, 0), byteArrayOf(74, 73, 78, 71, 0, 0),
byteArrayOf(74, 73, 79, 78, 71, 0), byteArrayOf(74, 73, 85, 0, 0, 0),
byteArrayOf(74, 85, 0, 0, 0, 0), byteArrayOf(74, 85, 65, 78, 0, 0),
byteArrayOf(74, 85, 69, 0, 0, 0), byteArrayOf(74, 85, 78, 0, 0, 0),
byteArrayOf(75, 65, 0, 0, 0, 0), byteArrayOf(75, 65, 73, 0, 0, 0),
byteArrayOf(75, 65, 78, 0, 0, 0), byteArrayOf(75, 65, 78, 71, 0, 0),
byteArrayOf(75, 65, 79, 0, 0, 0), byteArrayOf(75, 69, 0, 0, 0, 0),
byteArrayOf(75, 69, 78, 0, 0, 0), byteArrayOf(75, 69, 78, 71, 0, 0),
byteArrayOf(75, 79, 78, 71, 0, 0), byteArrayOf(75, 79, 85, 0, 0, 0),
byteArrayOf(75, 85, 0, 0, 0, 0), byteArrayOf(75, 85, 65, 0, 0, 0),
byteArrayOf(75, 85, 65, 73, 0, 0), byteArrayOf(75, 85, 65, 78, 0, 0),
byteArrayOf(75, 85, 65, 78, 71, 0), byteArrayOf(75, 85, 73, 0, 0, 0),
byteArrayOf(75, 85, 78, 0, 0, 0), byteArrayOf(75, 85, 79, 0, 0, 0),
byteArrayOf(76, 65, 0, 0, 0, 0), byteArrayOf(76, 65, 73, 0, 0, 0),
byteArrayOf(76, 65, 78, 0, 0, 0), byteArrayOf(76, 65, 78, 71, 0, 0),
byteArrayOf(76, 65, 79, 0, 0, 0), byteArrayOf(76, 69, 0, 0, 0, 0),
byteArrayOf(76, 69, 73, 0, 0, 0), byteArrayOf(76, 69, 78, 71, 0, 0),
byteArrayOf(76, 73, 0, 0, 0, 0), byteArrayOf(76, 73, 65, 0, 0, 0),
byteArrayOf(76, 73, 65, 78, 0, 0), byteArrayOf(76, 73, 65, 78, 71, 0),
byteArrayOf(76, 73, 65, 79, 0, 0), byteArrayOf(76, 73, 69, 0, 0, 0),
byteArrayOf(76, 73, 78, 0, 0, 0), byteArrayOf(76, 73, 78, 71, 0, 0),
byteArrayOf(76, 73, 85, 0, 0, 0), byteArrayOf(76, 79, 0, 0, 0, 0),
byteArrayOf(76, 79, 78, 71, 0, 0), byteArrayOf(76, 79, 85, 0, 0, 0),
byteArrayOf(76, 85, 0, 0, 0, 0), byteArrayOf(76, 85, 65, 78, 0, 0),
byteArrayOf(76, 85, 69, 0, 0, 0), byteArrayOf(76, 85, 78, 0, 0, 0),
byteArrayOf(76, 85, 79, 0, 0, 0), byteArrayOf(77, 0, 0, 0, 0, 0),
byteArrayOf(77, 65, 0, 0, 0, 0), byteArrayOf(77, 65, 73, 0, 0, 0),
byteArrayOf(77, 65, 78, 0, 0, 0), byteArrayOf(77, 65, 78, 71, 0, 0),
byteArrayOf(77, 65, 79, 0, 0, 0), byteArrayOf(77, 69, 0, 0, 0, 0),
byteArrayOf(77, 69, 73, 0, 0, 0), byteArrayOf(77, 69, 78, 0, 0, 0),
byteArrayOf(77, 69, 78, 71, 0, 0), byteArrayOf(77, 73, 0, 0, 0, 0),
byteArrayOf(77, 73, 65, 78, 0, 0), byteArrayOf(77, 73, 65, 79, 0, 0),
byteArrayOf(77, 73, 69, 0, 0, 0), byteArrayOf(77, 73, 78, 0, 0, 0),
byteArrayOf(77, 73, 78, 71, 0, 0), byteArrayOf(77, 73, 85, 0, 0, 0),
byteArrayOf(77, 79, 0, 0, 0, 0), byteArrayOf(77, 79, 85, 0, 0, 0),
byteArrayOf(77, 85, 0, 0, 0, 0), byteArrayOf(78, 0, 0, 0, 0, 0),
byteArrayOf(78, 65, 0, 0, 0, 0), byteArrayOf(78, 65, 73, 0, 0, 0),
byteArrayOf(78, 65, 78, 0, 0, 0), byteArrayOf(78, 65, 78, 71, 0, 0),
byteArrayOf(78, 65, 79, 0, 0, 0), byteArrayOf(78, 69, 0, 0, 0, 0),
byteArrayOf(78, 69, 73, 0, 0, 0), byteArrayOf(78, 69, 78, 0, 0, 0),
byteArrayOf(78, 69, 78, 71, 0, 0), byteArrayOf(78, 73, 0, 0, 0, 0),
byteArrayOf(78, 73, 65, 78, 0, 0), byteArrayOf(78, 73, 65, 78, 71, 0),
byteArrayOf(78, 73, 65, 79, 0, 0), byteArrayOf(78, 73, 69, 0, 0, 0),
byteArrayOf(78, 73, 78, 0, 0, 0), byteArrayOf(78, 73, 78, 71, 0, 0),
byteArrayOf(78, 73, 85, 0, 0, 0), byteArrayOf(78, 79, 78, 71, 0, 0),
byteArrayOf(78, 79, 85, 0, 0, 0), byteArrayOf(78, 85, 0, 0, 0, 0),
byteArrayOf(78, 85, 65, 78, 0, 0), byteArrayOf(78, 85, 69, 0, 0, 0),
byteArrayOf(78, 85, 78, 0, 0, 0), byteArrayOf(78, 85, 79, 0, 0, 0),
byteArrayOf(79, 0, 0, 0, 0, 0), byteArrayOf(79, 85, 0, 0, 0, 0),
byteArrayOf(80, 65, 0, 0, 0, 0), byteArrayOf(80, 65, 73, 0, 0, 0),
byteArrayOf(80, 65, 78, 0, 0, 0), byteArrayOf(80, 65, 78, 71, 0, 0),
byteArrayOf(80, 65, 79, 0, 0, 0), byteArrayOf(80, 69, 73, 0, 0, 0),
byteArrayOf(80, 69, 78, 0, 0, 0), byteArrayOf(80, 69, 78, 71, 0, 0),
byteArrayOf(80, 73, 0, 0, 0, 0), byteArrayOf(80, 73, 65, 78, 0, 0),
byteArrayOf(80, 73, 65, 79, 0, 0), byteArrayOf(80, 73, 69, 0, 0, 0),
byteArrayOf(80, 73, 78, 0, 0, 0), byteArrayOf(80, 73, 78, 71, 0, 0),
byteArrayOf(80, 79, 0, 0, 0, 0), byteArrayOf(80, 79, 85, 0, 0, 0),
byteArrayOf(80, 85, 0, 0, 0, 0), byteArrayOf(81, 73, 0, 0, 0, 0),
byteArrayOf(81, 73, 65, 0, 0, 0), byteArrayOf(81, 73, 65, 78, 0, 0),
byteArrayOf(81, 73, 65, 78, 71, 0), byteArrayOf(81, 73, 65, 79, 0, 0),
byteArrayOf(81, 73, 69, 0, 0, 0), byteArrayOf(81, 73, 78, 0, 0, 0),
byteArrayOf(81, 73, 78, 71, 0, 0), byteArrayOf(81, 73, 79, 78, 71, 0),
byteArrayOf(81, 73, 85, 0, 0, 0), byteArrayOf(81, 85, 0, 0, 0, 0),
byteArrayOf(81, 85, 65, 78, 0, 0), byteArrayOf(81, 85, 69, 0, 0, 0),
byteArrayOf(81, 85, 78, 0, 0, 0), byteArrayOf(82, 65, 78, 0, 0, 0),
byteArrayOf(82, 65, 78, 71, 0, 0), byteArrayOf(82, 65, 79, 0, 0, 0),
byteArrayOf(82, 69, 0, 0, 0, 0), byteArrayOf(82, 69, 78, 0, 0, 0),
byteArrayOf(82, 69, 78, 71, 0, 0), byteArrayOf(82, 73, 0, 0, 0, 0),
byteArrayOf(82, 79, 78, 71, 0, 0), byteArrayOf(82, 79, 85, 0, 0, 0),
byteArrayOf(82, 85, 0, 0, 0, 0), byteArrayOf(82, 85, 65, 0, 0, 0),
byteArrayOf(82, 85, 65, 78, 0, 0), byteArrayOf(82, 85, 73, 0, 0, 0),
byteArrayOf(82, 85, 78, 0, 0, 0), byteArrayOf(82, 85, 79, 0, 0, 0),
byteArrayOf(83, 65, 0, 0, 0, 0), byteArrayOf(83, 65, 73, 0, 0, 0),
byteArrayOf(83, 65, 78, 0, 0, 0), byteArrayOf(83, 65, 78, 71, 0, 0),
byteArrayOf(83, 65, 79, 0, 0, 0), byteArrayOf(83, 69, 0, 0, 0, 0),
byteArrayOf(83, 69, 78, 0, 0, 0), byteArrayOf(83, 69, 78, 71, 0, 0),
byteArrayOf(83, 72, 65, 0, 0, 0), byteArrayOf(83, 72, 65, 73, 0, 0),
byteArrayOf(83, 72, 65, 78, 0, 0), byteArrayOf(83, 72, 65, 78, 71, 0),
byteArrayOf(83, 72, 65, 79, 0, 0), byteArrayOf(83, 72, 69, 0, 0, 0),
byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(88, 73, 78, 0, 0, 0),
byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(83, 72, 69, 78, 71, 0),
byteArrayOf(83, 72, 73, 0, 0, 0), byteArrayOf(83, 72, 79, 85, 0, 0),
byteArrayOf(83, 72, 85, 0, 0, 0), byteArrayOf(83, 72, 85, 65, 0, 0),
byteArrayOf(83, 72, 85, 65, 73, 0), byteArrayOf(83, 72, 85, 65, 78, 0),
byteArrayOf(83, 72, 85, 65, 78, 71), byteArrayOf(83, 72, 85, 73, 0, 0),
byteArrayOf(83, 72, 85, 78, 0, 0), byteArrayOf(83, 72, 85, 79, 0, 0),
byteArrayOf(83, 73, 0, 0, 0, 0), byteArrayOf(83, 79, 78, 71, 0, 0),
byteArrayOf(83, 79, 85, 0, 0, 0), byteArrayOf(83, 85, 0, 0, 0, 0),
byteArrayOf(83, 85, 65, 78, 0, 0), byteArrayOf(83, 85, 73, 0, 0, 0),
byteArrayOf(83, 85, 78, 0, 0, 0), byteArrayOf(83, 85, 79, 0, 0, 0),
byteArrayOf(84, 65, 0, 0, 0, 0), byteArrayOf(84, 65, 73, 0, 0, 0),
byteArrayOf(84, 65, 78, 0, 0, 0), byteArrayOf(84, 65, 78, 71, 0, 0),
byteArrayOf(84, 65, 79, 0, 0, 0), byteArrayOf(84, 69, 0, 0, 0, 0),
byteArrayOf(84, 69, 78, 71, 0, 0), byteArrayOf(84, 73, 0, 0, 0, 0),
byteArrayOf(84, 73, 65, 78, 0, 0), byteArrayOf(84, 73, 65, 79, 0, 0),
byteArrayOf(84, 73, 69, 0, 0, 0), byteArrayOf(84, 73, 78, 71, 0, 0),
byteArrayOf(84, 79, 78, 71, 0, 0), byteArrayOf(84, 79, 85, 0, 0, 0),
byteArrayOf(84, 85, 0, 0, 0, 0), byteArrayOf(84, 85, 65, 78, 0, 0),
byteArrayOf(84, 85, 73, 0, 0, 0), byteArrayOf(84, 85, 78, 0, 0, 0),
byteArrayOf(84, 85, 79, 0, 0, 0), byteArrayOf(87, 65, 0, 0, 0, 0),
byteArrayOf(87, 65, 73, 0, 0, 0), byteArrayOf(87, 65, 78, 0, 0, 0),
byteArrayOf(87, 65, 78, 71, 0, 0), byteArrayOf(87, 69, 73, 0, 0, 0),
byteArrayOf(87, 69, 78, 0, 0, 0), byteArrayOf(87, 69, 78, 71, 0, 0),
byteArrayOf(87, 79, 0, 0, 0, 0), byteArrayOf(87, 85, 0, 0, 0, 0),
byteArrayOf(88, 73, 0, 0, 0, 0), byteArrayOf(88, 73, 65, 0, 0, 0),
byteArrayOf(88, 73, 65, 78, 0, 0), byteArrayOf(88, 73, 65, 78, 71, 0),
byteArrayOf(88, 73, 65, 79, 0, 0), byteArrayOf(88, 73, 69, 0, 0, 0),
byteArrayOf(88, 73, 78, 0, 0, 0), byteArrayOf(88, 73, 78, 71, 0, 0),
byteArrayOf(88, 73, 79, 78, 71, 0), byteArrayOf(88, 73, 85, 0, 0, 0),
byteArrayOf(88, 85, 0, 0, 0, 0), byteArrayOf(88, 85, 65, 78, 0, 0),
byteArrayOf(88, 85, 69, 0, 0, 0), byteArrayOf(88, 85, 78, 0, 0, 0),
byteArrayOf(89, 65, 0, 0, 0, 0), byteArrayOf(89, 65, 78, 0, 0, 0),
byteArrayOf(89, 65, 78, 71, 0, 0), byteArrayOf(89, 65, 79, 0, 0, 0),
byteArrayOf(89, 69, 0, 0, 0, 0), byteArrayOf(89, 73, 0, 0, 0, 0),
byteArrayOf(89, 73, 78, 0, 0, 0), byteArrayOf(89, 73, 78, 71, 0, 0),
byteArrayOf(89, 79, 0, 0, 0, 0), byteArrayOf(89, 79, 78, 71, 0, 0),
byteArrayOf(89, 79, 85, 0, 0, 0), byteArrayOf(89, 85, 0, 0, 0, 0),
byteArrayOf(89, 85, 65, 78, 0, 0), byteArrayOf(89, 85, 69, 0, 0, 0),
byteArrayOf(89, 85, 78, 0, 0, 0), byteArrayOf(74, 85, 78, 0, 0, 0),
byteArrayOf(89, 85, 78, 0, 0, 0), byteArrayOf(90, 65, 0, 0, 0, 0),
byteArrayOf(90, 65, 73, 0, 0, 0), byteArrayOf(90, 65, 78, 0, 0, 0),
byteArrayOf(90, 65, 78, 71, 0, 0), byteArrayOf(90, 65, 79, 0, 0, 0),
byteArrayOf(90, 69, 0, 0, 0, 0), byteArrayOf(90, 69, 73, 0, 0, 0),
byteArrayOf(90, 69, 78, 0, 0, 0), byteArrayOf(90, 69, 78, 71, 0, 0),
byteArrayOf(90, 72, 65, 0, 0, 0), byteArrayOf(90, 72, 65, 73, 0, 0),
byteArrayOf(90, 72, 65, 78, 0, 0), byteArrayOf(90, 72, 65, 78, 71, 0),
byteArrayOf(67, 72, 65, 78, 71, 0), byteArrayOf(90, 72, 65, 78, 71, 0),
byteArrayOf(90, 72, 65, 79, 0, 0), byteArrayOf(90, 72, 69, 0, 0, 0),
byteArrayOf(90, 72, 69, 78, 0, 0), byteArrayOf(90, 72, 69, 78, 71, 0),
byteArrayOf(90, 72, 73, 0, 0, 0), byteArrayOf(83, 72, 73, 0, 0, 0),
byteArrayOf(90, 72, 73, 0, 0, 0), byteArrayOf(90, 72, 79, 78, 71, 0),
byteArrayOf(90, 72, 79, 85, 0, 0), byteArrayOf(90, 72, 85, 0, 0, 0),
byteArrayOf(90, 72, 85, 65, 0, 0), byteArrayOf(90, 72, 85, 65, 73, 0),
byteArrayOf(90, 72, 85, 65, 78, 0), byteArrayOf(90, 72, 85, 65, 78, 71),
byteArrayOf(90, 72, 85, 73, 0, 0), byteArrayOf(90, 72, 85, 78, 0, 0),
byteArrayOf(90, 72, 85, 79, 0, 0), byteArrayOf(90, 73, 0, 0, 0, 0),
byteArrayOf(90, 79, 78, 71, 0, 0), byteArrayOf(90, 79, 85, 0, 0, 0),
byteArrayOf(90, 85, 0, 0, 0, 0), byteArrayOf(90, 85, 65, 78, 0, 0),
byteArrayOf(90, 85, 73, 0, 0, 0), byteArrayOf(90, 85, 78, 0, 0, 0),
byteArrayOf(90, 85, 79, 0, 0, 0), byteArrayOf(0, 0, 0, 0, 0, 0),
byteArrayOf(83, 72, 65, 78, 0, 0), byteArrayOf(0, 0, 0, 0, 0, 0)
)
private const val FIRST_PINYIN_UNIHAN = ""
private const val LAST_PINYIN_UNIHAN = "鿿"
private val COLLATOR: Collator = Collator.getInstance(Locale.CHINA)
private var sInstance: HanziToPinyin? = null
fun getInstance(): HanziToPinyin {
synchronized(HanziToPinyin::class.java) {
if (sInstance != null) {
return sInstance!!
}
val locale = Collator.getAvailableLocales()
for (value in locale) {
if (value == Locale.CHINA || value.language.contains("zh")) {
if (DEBUG) {
Log.d(TAG, "Self validation. Result: ${doSelfValidation()}")
}
sInstance = HanziToPinyin(true)
return sInstance!!
}
}
if (sInstance == null) {
if (Locale.CHINA == Locale.getDefault()) {
sInstance = HanziToPinyin(true)
return sInstance!!
}
}
Log.w(TAG, "There is no Chinese collator, HanziToPinyin is disabled")
sInstance = HanziToPinyin(false)
return sInstance!!
}
}
private fun doSelfValidation(): Boolean {
val lastChar = UNIHANS[0]
var lastString = lastChar.toString()
for (c in UNIHANS) {
if (lastChar == c) {
continue
}
val curString = c.toString()
val cmp = COLLATOR.compare(lastString, curString)
if (cmp >= 0) {
Log.e(
TAG,
"Internal error in Unihan table. The last string \"$lastString\" " +
"is greater than current string \"$curString\"."
)
return false
}
lastString = curString
}
return true
}
}
}

View file

@ -0,0 +1,88 @@
package com.sukisu.ultra.ui.util
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import java.util.regex.Pattern
@Composable
fun LinkifyText(
text: String,
modifier: Modifier = Modifier
) {
val uriHandler = LocalUriHandler.current
val layoutResult = remember {
mutableStateOf<TextLayoutResult?>(null)
}
val linksList = extractUrls(text)
val annotatedString = buildAnnotatedString {
append(text)
linksList.forEach {
addStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
),
start = it.start,
end = it.end
)
addStringAnnotation(
tag = "URL",
annotation = it.url,
start = it.start,
end = it.end
)
}
}
Text(
text = annotatedString,
modifier = modifier.pointerInput(Unit) {
detectTapGestures { offsetPosition ->
layoutResult.value?.let {
val position = it.getOffsetForPosition(offsetPosition)
annotatedString.getStringAnnotations(position, position).firstOrNull()
?.let { result ->
if (result.tag == "URL") {
uriHandler.openUri(result.item)
}
}
}
}
},
onTextLayout = { layoutResult.value = it }
)
}
private val urlPattern: Pattern = Pattern.compile(
"(?:^|\\W)((ht|f)tp(s?)://|www\\.)"
+ "(([\\w\\-]+\\.)+([\\w\\-.~]+/?)*"
+ "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]*$~@!:/{};']*)",
Pattern.CASE_INSENSITIVE or Pattern.MULTILINE or Pattern.DOTALL
)
private data class LinkInfo(
val url: String,
val start: Int,
val end: Int
)
@Suppress("HttpUrlsUsage")
private fun extractUrls(text: String): List<LinkInfo> = buildList {
val matcher = urlPattern.matcher(text)
while (matcher.find()) {
val matchStart = matcher.start(1)
val matchEnd = matcher.end()
val url = text.substring(matchStart, matchEnd).replaceFirst("http://", "https://")
add(LinkInfo(url, matchStart, matchEnd))
}
}

View file

@ -0,0 +1,738 @@
package com.sukisu.ultra.ui.util
import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Environment
import android.os.Parcelable
import android.os.SystemClock
import android.provider.OpenableColumns
import android.system.Os
import android.util.Log
import com.topjohnwu.superuser.CallbackList
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ShellUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import com.sukisu.ultra.BuildConfig
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ksuApp
import org.json.JSONArray
import java.io.File
/**
* @author weishu
* @date 2023/1/1.
*/
private const val TAG = "KsuCli"
private fun getKsuDaemonPath(): String {
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksud.so"
}
object KsuCli {
var SHELL: Shell = createRootShell()
val GLOBAL_MNT_SHELL: Shell = createRootShell(true)
}
fun getRootShell(globalMnt: Boolean = false): Shell {
return if (globalMnt) KsuCli.GLOBAL_MNT_SHELL else {
KsuCli.SHELL
}
}
inline fun <T> withNewRootShell(
globalMnt: Boolean = false,
block: Shell.() -> T
): T {
return createRootShell(globalMnt).use(block)
}
fun Uri.getFileName(context: Context): String? {
var fileName: String? = null
val contentResolver: ContentResolver = context.contentResolver
val cursor: Cursor? = contentResolver.query(this, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
fileName = it.getString(it.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
}
}
return fileName
}
fun createRootShell(globalMnt: Boolean = false): Shell {
Shell.enableVerboseLogging = BuildConfig.DEBUG
val builder = Shell.Builder.create()
return try {
if (globalMnt) {
builder.build(getKsuDaemonPath(), "debug", "su", "-g")
} else {
builder.build(getKsuDaemonPath(), "debug", "su")
}
} catch (e: Throwable) {
Log.w(TAG, "ksu failed: ", e)
try {
if (globalMnt) {
builder.build("su", "-mm")
} else {
builder.build("su")
}
} catch (e: Throwable) {
Log.e(TAG, "su failed: ", e)
builder.build("sh")
}
}
}
fun execKsud(args: String, newShell: Boolean = false): Boolean {
return if (newShell) {
withNewRootShell {
ShellUtils.fastCmdResult(this, "${getKsuDaemonPath()} $args")
}
} else {
ShellUtils.fastCmdResult(getRootShell(), "${getKsuDaemonPath()} $args")
}
}
suspend fun getFeatureStatus(feature: String): String = withContext(Dispatchers.IO) {
val shell = getRootShell()
val out = shell.newJob()
.add("${getKsuDaemonPath()} feature check $feature").to(ArrayList<String>(), null).exec().out
out.firstOrNull()?.trim().orEmpty()
}
fun install() {
val start = SystemClock.elapsedRealtime()
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so").absolutePath
val result = execKsud("install --magiskboot $magiskboot", true)
Log.w(TAG, "install result: $result, cost: ${SystemClock.elapsedRealtime() - start}ms")
}
fun listModules(): String {
val shell = getRootShell()
val out = shell.newJob()
.add("${getKsuDaemonPath()} module list").to(ArrayList(), null).exec().out
return out.joinToString("\n").ifBlank { "[]" }
}
fun getModuleCount(): Int {
val result = listModules()
runCatching {
val array = JSONArray(result)
return array.length()
}.getOrElse { return 0 }
}
fun getSuperuserCount(): Int {
return Natives.allowList.size
}
fun toggleModule(id: String, enable: Boolean): Boolean {
val cmd = if (enable) {
"module enable $id"
} else {
"module disable $id"
}
val result = execKsud(cmd, true)
Log.i(TAG, "$cmd result: $result")
return result
}
fun uninstallModule(id: String): Boolean {
val cmd = "module uninstall $id"
val result = execKsud(cmd, true)
Log.i(TAG, "uninstall module $id result: $result")
return result
}
fun restoreModule(id: String): Boolean {
val cmd = "module restore $id"
val result = execKsud(cmd, true)
Log.i(TAG, "restore module $id result: $result")
return result
}
fun undoUninstallModule(id: String): Boolean {
val cmd = "module undo-uninstall $id"
val result = execKsud(cmd, true)
Log.i(TAG, "undo uninstall module $id result: $result")
return result
}
private fun flashWithIO(
cmd: String,
onStdout: (String) -> Unit,
onStderr: (String) -> Unit
): Shell.Result {
val stdoutCallback: CallbackList<String?> = object : CallbackList<String?>() {
override fun onAddElement(s: String?) {
onStdout(s ?: "")
}
}
val stderrCallback: CallbackList<String?> = object : CallbackList<String?>() {
override fun onAddElement(s: String?) {
onStderr(s ?: "")
}
}
return withNewRootShell {
newJob().add(cmd).to(stdoutCallback, stderrCallback).exec()
}
}
fun flashModule(
uri: Uri,
onFinish: (Boolean, Int) -> Unit,
onStdout: (String) -> Unit,
onStderr: (String) -> Unit
): Boolean {
val resolver = ksuApp.contentResolver
with(resolver.openInputStream(uri)) {
val file = File(ksuApp.cacheDir, "module.zip")
file.outputStream().use { output ->
this?.copyTo(output)
}
val cmd = "module install ${file.absolutePath}"
val result = flashWithIO("${getKsuDaemonPath()} $cmd", onStdout, onStderr)
Log.i("KernelSU", "install module $uri result: $result")
file.delete()
onFinish(result.isSuccess, result.code)
return result.isSuccess
}
}
fun runModuleAction(
moduleId: String, onStdout: (String) -> Unit, onStderr: (String) -> Unit
): Boolean {
val shell = createRootShell(true)
val stdoutCallback: CallbackList<String?> = object : CallbackList<String?>() {
override fun onAddElement(s: String?) {
onStdout(s ?: "")
}
}
val stderrCallback: CallbackList<String?> = object : CallbackList<String?>() {
override fun onAddElement(s: String?) {
onStderr(s ?: "")
}
}
val result = shell.newJob().add("${getKsuDaemonPath()} module action $moduleId")
.to(stdoutCallback, stderrCallback).exec()
Log.i("KernelSU", "Module runAction result: $result")
return result.isSuccess
}
fun restoreBoot(
onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit
): Boolean {
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so")
val result = flashWithIO(
"${getKsuDaemonPath()} boot-restore -f --magiskboot $magiskboot",
onStdout,
onStderr
)
onFinish(result.isSuccess, result.code)
return result.isSuccess
}
fun uninstallPermanently(
onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit
): Boolean {
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so")
val result =
flashWithIO("${getKsuDaemonPath()} uninstall --magiskboot $magiskboot", onStdout, onStderr)
onFinish(result.isSuccess, result.code)
return result.isSuccess
}
@Parcelize
sealed class LkmSelection : Parcelable {
data class LkmUri(val uri: Uri) : LkmSelection()
data class KmiString(val value: String) : LkmSelection()
data object KmiNone : LkmSelection()
}
fun installBoot(
bootUri: Uri?,
lkm: LkmSelection,
ota: Boolean,
partition: String?,
onFinish: (Boolean, Int) -> Unit,
onStdout: (String) -> Unit,
onStderr: (String) -> Unit,
): Boolean {
val resolver = ksuApp.contentResolver
val bootFile = bootUri?.let { uri ->
with(resolver.openInputStream(uri)) {
val bootFile = File(ksuApp.cacheDir, "boot.img")
bootFile.outputStream().use { output ->
this?.copyTo(output)
}
bootFile
}
}
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so")
var cmd = "boot-patch --magiskboot ${magiskboot.absolutePath}"
cmd += if (bootFile == null) {
// no boot.img, use -f to force install
" -f"
} else {
" -b ${bootFile.absolutePath}"
}
if (ota) {
cmd += " -u"
}
var lkmFile: File? = null
when (lkm) {
is LkmSelection.LkmUri -> {
lkmFile = with(resolver.openInputStream(lkm.uri)) {
val file = File(ksuApp.cacheDir, "kernelsu-tmp-lkm.ko")
file.outputStream().use { output ->
this?.copyTo(output)
}
file
}
cmd += " -m ${lkmFile.absolutePath}"
}
is LkmSelection.KmiString -> {
cmd += " --kmi ${lkm.value}"
}
LkmSelection.KmiNone -> {
// do nothing
}
}
// output dir
val downloadsDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
cmd += " -o $downloadsDir"
partition?.let { part ->
cmd += " --partition $part"
}
val result = flashWithIO("${getKsuDaemonPath()} $cmd", onStdout, onStderr)
Log.i("KernelSU", "install boot result: ${result.isSuccess}")
bootFile?.delete()
lkmFile?.delete()
// if boot uri is empty, it is direct install, when success, we should show reboot button
onFinish(bootUri == null && result.isSuccess, result.code)
if (bootUri == null && result.isSuccess) {
install()
}
return result.isSuccess
}
fun reboot(reason: String = "") {
val shell = getRootShell()
if (reason == "recovery") {
// KEYCODE_POWER = 26, hide incorrect "Factory data reset" message
ShellUtils.fastCmd(shell, "/system/bin/input keyevent 26")
}
ShellUtils.fastCmd(shell, "/system/bin/svc power reboot $reason || /system/bin/reboot $reason")
}
fun rootAvailable(): Boolean {
val shell = getRootShell()
return shell.isRoot
}
suspend fun getCurrentKmi(): String = withContext(Dispatchers.IO) {
val shell = getRootShell()
val cmd = "boot-info current-kmi"
ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd")
}
suspend fun getSupportedKmis(): List<String> = withContext(Dispatchers.IO) {
val shell = getRootShell()
val cmd = "boot-info supported-kmis"
val out = shell.newJob().add("${getKsuDaemonPath()} $cmd").to(ArrayList(), null).exec().out
out.filter { it.isNotBlank() }.map { it.trim() }
}
suspend fun isAbDevice(): Boolean = withContext(Dispatchers.IO) {
val shell = getRootShell()
val cmd = "boot-info is-ab-device"
ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd").trim().toBoolean()
}
suspend fun getDefaultPartition(): String = withContext(Dispatchers.IO) {
val shell = getRootShell()
if (shell.isRoot) {
val cmd = "boot-info default-partition"
ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd").trim()
} else {
if (!Os.uname().release.contains("android12-")) "init_boot" else "boot"
}
}
suspend fun getSlotSuffix(ota: Boolean): String = withContext(Dispatchers.IO) {
val shell = getRootShell()
val cmd = if (ota) {
"boot-info slot-suffix --ota"
} else {
"boot-info slot-suffix"
}
ShellUtils.fastCmd(shell, "${getKsuDaemonPath()} $cmd").trim()
}
suspend fun getAvailablePartitions(): List<String> = withContext(Dispatchers.IO) {
val shell = getRootShell()
val cmd = "boot-info available-partitions"
val out = shell.newJob().add("${getKsuDaemonPath()} $cmd").to(ArrayList(), null).exec().out
out.filter { it.isNotBlank() }.map { it.trim() }
}
fun hasMagisk(): Boolean {
val shell = getRootShell(true)
val result = shell.newJob().add("which magisk").exec()
Log.i(TAG, "has magisk: ${result.isSuccess}")
return result.isSuccess
}
fun isSepolicyValid(rules: String?): Boolean {
if (rules == null) {
return true
}
val shell = getRootShell()
val result =
shell.newJob().add("${getKsuDaemonPath()} sepolicy check '$rules'").to(ArrayList(), null)
.exec()
return result.isSuccess
}
fun getSepolicy(pkg: String): String {
val shell = getRootShell()
val result =
shell.newJob().add("${getKsuDaemonPath()} profile get-sepolicy $pkg").to(ArrayList(), null)
.exec()
Log.i(TAG, "code: ${result.code}, out: ${result.out}, err: ${result.err}")
return result.out.joinToString("\n")
}
fun setSepolicy(pkg: String, rules: String): Boolean {
val shell = getRootShell()
val result = shell.newJob().add("${getKsuDaemonPath()} profile set-sepolicy $pkg '$rules'")
.to(ArrayList(), null).exec()
Log.i(TAG, "set sepolicy result: ${result.code}")
return result.isSuccess
}
fun listAppProfileTemplates(): List<String> {
val shell = getRootShell()
return shell.newJob().add("${getKsuDaemonPath()} profile list-templates").to(ArrayList(), null)
.exec().out
}
fun getAppProfileTemplate(id: String): String {
val shell = getRootShell()
return shell.newJob().add("${getKsuDaemonPath()} profile get-template '${id}'")
.to(ArrayList(), null).exec().out.joinToString("\n")
}
fun setAppProfileTemplate(id: String, template: String): Boolean {
val shell = getRootShell()
val escapedTemplate = template.replace("\"", "\\\"")
val cmd = """${getKsuDaemonPath()} profile set-template "$id" "$escapedTemplate'""""
return shell.newJob().add(cmd)
.to(ArrayList(), null).exec().isSuccess
}
fun deleteAppProfileTemplate(id: String): Boolean {
val shell = getRootShell()
return shell.newJob().add("${getKsuDaemonPath()} profile delete-template '${id}'")
.to(ArrayList(), null).exec().isSuccess
}
// KPM控制
fun loadKpmModule(path: String, args: String? = null): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm load $path ${args ?: ""}"
return ShellUtils.fastCmd(shell, cmd)
}
fun unloadKpmModule(name: String): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm unload $name"
return ShellUtils.fastCmd(shell, cmd)
}
fun getKpmModuleCount(): Int {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm num"
val result = ShellUtils.fastCmd(shell, cmd)
return result.trim().toIntOrNull() ?: 0
}
fun runCmd(shell: Shell, cmd: String): String {
return shell.newJob()
.add(cmd)
.to(mutableListOf<String>(), null)
.exec().out
.joinToString("\n")
}
fun listKpmModules(): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm list"
return try {
runCmd(shell, cmd).trim()
} catch (e: Exception) {
Log.e(TAG, "Failed to list KPM modules", e)
""
}
}
fun getKpmModuleInfo(name: String): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm info $name"
return try {
runCmd(shell, cmd).trim()
} catch (e: Exception) {
Log.e(TAG, "Failed to get KPM module info: $name", e)
""
}
}
fun controlKpmModule(name: String, args: String? = null): Int {
val shell = getRootShell()
val cmd = """${getKsuDaemonPath()} kpm control $name "${args ?: ""}""""
val result = runCmd(shell, cmd)
return result.trim().toIntOrNull() ?: -1
}
fun getKpmVersion(): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm version"
val result = ShellUtils.fastCmd(shell, cmd)
return result.trim()
}
fun forceStopApp(packageName: String) {
val shell = getRootShell()
val result = shell.newJob().add("am force-stop $packageName").exec()
Log.i(TAG, "force stop $packageName result: $result")
}
fun launchApp(packageName: String) {
val shell = getRootShell()
val result =
shell.newJob()
.add("cmd package resolve-activity --brief $packageName | tail -n 1 | xargs cmd activity start-activity -n")
.exec()
Log.i(TAG, "launch $packageName result: $result")
}
fun restartApp(packageName: String) {
forceStopApp(packageName)
launchApp(packageName)
}
fun getSuSFSDaemonPath(): String {
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksu_susfs.so"
}
fun getSuSFSVersion(): String {
val shell = getRootShell()
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} show version")
return result
}
fun getSuSFSVariant(): String {
val shell = getRootShell()
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} show variant")
return result
}
fun getSuSFSFeatures(): String {
val shell = getRootShell()
val cmd = "${getSuSFSDaemonPath()} show enabled_features"
return runCmd(shell, cmd)
}
fun getZygiskImplement(): String {
val shell = getRootShell()
val zygiskModuleIds = listOf(
"zygisksu",
"rezygisk",
"shirokozygisk"
)
for (moduleId in zygiskModuleIds) {
val modulePath = "/data/adb/modules/$moduleId"
when {
ShellUtils.fastCmdResult(shell, "test -f $modulePath/module.prop && test ! -f $modulePath/disable") -> {
val result = ShellUtils.fastCmd(shell, "grep '^name=' $modulePath/module.prop | cut -d'=' -f2")
Log.i(TAG, "Zygisk implement: $result")
return result
}
}
}
Log.i(TAG, "Zygisk implement: None")
return "None"
}
fun getUidScannerDaemonPath(): String {
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libuid_scanner.so"
}
private const val targetPath = "/data/adb/uid_scanner"
fun ensureUidScannerExecutable(): Boolean {
val shell = getRootShell()
val uidScannerPath = getUidScannerDaemonPath()
if (!ShellUtils.fastCmdResult(shell, "test -f $targetPath")) {
val copyResult = ShellUtils.fastCmdResult(shell, "cp $uidScannerPath $targetPath")
if (!copyResult) {
return false
}
}
val result = ShellUtils.fastCmdResult(shell, "chmod 755 $targetPath")
return result
}
fun setUidAutoScan(enabled: Boolean): Boolean {
val shell = getRootShell()
if (!ensureUidScannerExecutable()) {
return false
}
val enableValue = if (enabled) 1 else 0
val cmd = "$targetPath --auto-scan $enableValue && $targetPath reload"
val result = ShellUtils.fastCmdResult(shell, cmd)
val throneResult = Natives.setUidScannerEnabled(enabled)
return result && throneResult
}
fun setUidMultiUserScan(enabled: Boolean): Boolean {
val shell = getRootShell()
if (!ensureUidScannerExecutable()) {
return false
}
val enableValue = if (enabled) 1 else 0
val cmd = "$targetPath --multi-user $enableValue && $targetPath reload"
val result = ShellUtils.fastCmdResult(shell, cmd)
return result
}
fun getUidMultiUserScan(): Boolean {
val shell = getRootShell()
val cmd = "grep 'multi_user_scan=' /data/misc/user_uid/uid_scanner.conf | cut -d'=' -f2"
val result = ShellUtils.fastCmd(shell, cmd).trim()
return try {
result.toInt() == 1
} catch (_: NumberFormatException) {
false
}
}
fun cleanRuntimeEnvironment(): Boolean {
val shell = getRootShell()
return try {
try {
ShellUtils.fastCmd(shell, "/data/adb/uid_scanner stop")
} catch (_: Exception) {
}
ShellUtils.fastCmdResult(shell, "rm -rf /data/misc/user_uid")
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/uid_scanner")
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/ksu/bin/user_uid")
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/service.d/uid_scanner.sh")
Natives.clearUidScannerEnvironment()
true
} catch (_: Exception) {
false
}
}
fun readUidScannerFile(): Boolean {
val shell = getRootShell()
return try {
ShellUtils.fastCmd(shell, "cat /data/adb/ksu/.uid_scanner").trim() == "1"
} catch (_: Exception) {
false
}
}
fun addUmountPath(path: String, flags: Int): Boolean {
val shell = getRootShell()
val flagsArg = if (flags >= 0) "--flags $flags" else ""
val cmd = "${getKsuDaemonPath()} umount add $path $flagsArg"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "add umount path $path result: $result")
return result
}
fun removeUmountPath(path: String): Boolean {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount remove $path"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "remove umount path $path result: $result")
return result
}
fun listUmountPaths(): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount list"
return try {
runCmd(shell, cmd).trim()
} catch (e: Exception) {
Log.e(TAG, "Failed to list umount paths", e)
""
}
}
fun clearCustomUmountPaths(): Boolean {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount clear-custom"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "clear custom umount paths result: $result")
return result
}
fun saveUmountConfig(): Boolean {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount save"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "save umount config result: $result")
return result
}
fun applyUmountConfigToKernel(): Boolean {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount apply"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "apply umount config to kernel result: $result")
return result
}

View file

@ -0,0 +1,111 @@
package com.sukisu.ultra.ui.util
import android.content.Context
import android.os.Build
import android.system.Os
import com.topjohnwu.superuser.ShellUtils
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.screen.getManagerVersion
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
fun getBugreportFile(context: Context): File {
val bugreportDir = File(context.cacheDir, "bugreport")
bugreportDir.mkdirs()
val dmesgFile = File(bugreportDir, "dmesg.txt")
val logcatFile = File(bugreportDir, "logcat.txt")
val tombstonesFile = File(bugreportDir, "tombstones.tar.gz")
val dropboxFile = File(bugreportDir, "dropbox.tar.gz")
val pstoreFile = File(bugreportDir, "pstore.tar.gz")
// Xiaomi/Readmi devices have diag in /data/vendor/diag
val diagFile = File(bugreportDir, "diag.tar.gz")
val oplusFile = File(bugreportDir, "oplus.tar.gz")
val bootlogFile = File(bugreportDir, "bootlog.tar.gz")
val mountsFile = File(bugreportDir, "mounts.txt")
val fileSystemsFile = File(bugreportDir, "filesystems.txt")
val adbFileTree = File(bugreportDir, "adb_tree.txt")
val adbFileDetails = File(bugreportDir, "adb_details.txt")
val ksuFileSize = File(bugreportDir, "ksu_size.txt")
val appListFile = File(bugreportDir, "packages.txt")
val propFile = File(bugreportDir, "props.txt")
val allowListFile = File(bugreportDir, "allowlist.bin")
val procModules = File(bugreportDir, "proc_modules.txt")
val bootConfig = File(bugreportDir, "boot_config.txt")
val kernelConfig = File(bugreportDir, "defconfig.gz")
val shell = getRootShell(true)
shell.newJob().add("dmesg > ${dmesgFile.absolutePath}").exec()
shell.newJob().add("logcat -d > ${logcatFile.absolutePath}").exec()
shell.newJob().add("tar -czf ${tombstonesFile.absolutePath} -C /data/tombstones .").exec()
shell.newJob().add("tar -czf ${dropboxFile.absolutePath} -C /data/system/dropbox .").exec()
shell.newJob().add("tar -czf ${pstoreFile.absolutePath} -C /sys/fs/pstore .").exec()
shell.newJob().add("tar -czf ${diagFile.absolutePath} -C /data/vendor/diag . --exclude=./minidump.gz").exec()
shell.newJob().add("tar -czf ${oplusFile.absolutePath} -C /mnt/oplus/op2/media/log/boot_log/ .").exec()
shell.newJob().add("tar -czf ${bootlogFile.absolutePath} -C /data/adb/ksu/log .").exec()
shell.newJob().add("cat /proc/1/mountinfo > ${mountsFile.absolutePath}").exec()
shell.newJob().add("cat /proc/filesystems > ${fileSystemsFile.absolutePath}").exec()
shell.newJob().add("busybox tree /data/adb > ${adbFileTree.absolutePath}").exec()
shell.newJob().add("ls -alRZ /data/adb > ${adbFileDetails.absolutePath}").exec()
shell.newJob().add("du -sh /data/adb/ksu/* > ${ksuFileSize.absolutePath}").exec()
shell.newJob().add("cp /data/system/packages.list ${appListFile.absolutePath}").exec()
shell.newJob().add("getprop > ${propFile.absolutePath}").exec()
shell.newJob().add("cp /data/adb/ksu/.allowlist ${allowListFile.absolutePath}").exec()
shell.newJob().add("cp /proc/modules ${procModules.absolutePath}").exec()
shell.newJob().add("cp /proc/bootconfig ${bootConfig.absolutePath}").exec()
shell.newJob().add("cp /proc/config.gz ${kernelConfig.absolutePath}").exec()
val selinux = ShellUtils.fastCmd(shell, "getenforce")
// basic information
val buildInfo = File(bugreportDir, "basic.txt")
PrintWriter(FileWriter(buildInfo)).use { pw ->
pw.println("Kernel: ${System.getProperty("os.version")}")
pw.println("BRAND: " + Build.BRAND)
pw.println("MODEL: " + Build.MODEL)
pw.println("PRODUCT: " + Build.PRODUCT)
pw.println("MANUFACTURER: " + Build.MANUFACTURER)
pw.println("SDK: " + Build.VERSION.SDK_INT)
pw.println("PREVIEW_SDK: " + Build.VERSION.PREVIEW_SDK_INT)
pw.println("FINGERPRINT: " + Build.FINGERPRINT)
pw.println("DEVICE: " + Build.DEVICE)
pw.println("Manager: " + getManagerVersion(context))
pw.println("SELinux: $selinux")
val uname = Os.uname()
pw.println("KernelRelease: ${uname.release}")
pw.println("KernelVersion: ${uname.version}")
pw.println("Machine: ${uname.machine}")
pw.println("Nodename: ${uname.nodename}")
pw.println("Sysname: ${uname.sysname}")
val ksuKernel = Natives.version
pw.println("KernelSU: $ksuKernel")
val safeMode = Natives.isSafeMode
pw.println("SafeMode: $safeMode")
val lkmMode = Natives.isLkmMode
pw.println("LKM: $lkmMode")
}
// modules
val modulesFile = File(bugreportDir, "modules.json")
modulesFile.writeText(listModules())
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm")
val current = LocalDateTime.now().format(formatter)
val targetFile = File(context.cacheDir, "KernelSU_bugreport_${current}.tar.gz")
shell.newJob().add("tar czf ${targetFile.absolutePath} -C ${bugreportDir.absolutePath} .").exec()
shell.newJob().add("rm -rf ${bugreportDir.absolutePath}").exec()
shell.newJob().add("chmod 0644 ${targetFile.absolutePath}").exec()
return targetFile
}

View file

@ -0,0 +1,19 @@
package com.sukisu.ultra.ui.util
import android.content.Context
import com.sukisu.ultra.R
import com.topjohnwu.superuser.io.SuFile
fun getSELinuxStatus(context: Context) = SuFile("/sys/fs/selinux/enforce").run {
when {
!exists() -> context.getString(R.string.selinux_status_disabled)
!isFile -> context.getString(R.string.selinux_status_unknown)
!canRead() -> context.getString(R.string.selinux_status_enforcing)
else -> when (runCatching { newInputStream() }.getOrNull()?.bufferedReader()
?.use { it.runCatching { readLine() }.getOrNull()?.trim()?.toIntOrNull() }) {
1 -> context.getString(R.string.selinux_status_enforcing)
0 -> context.getString(R.string.selinux_status_permissive)
else -> context.getString(R.string.selinux_status_unknown)
}
}
}

View file

@ -0,0 +1,8 @@
package com.sukisu.ultra.ui.util.module
data class LatestVersionInfo(
val versionCode : Int = 0,
val downloadUrl : String = "",
val changelog : String = "",
val versionName: String = ""
)

View file

@ -0,0 +1,457 @@
package com.sukisu.ultra.ui.util.module
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.reboot
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.text.SimpleDateFormat
import java.util.*
object ModuleModify {
@Composable
fun RestoreConfirmationDialog(
showDialog: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
if (showDialog) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = context.getString(R.string.restore_confirm_title),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Text(
text = context.getString(R.string.restore_confirm_message),
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(context.getString(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(context.getString(R.string.cancel))
}
}
)
}
}
@Composable
fun AllowlistRestoreConfirmationDialog(
showDialog: Boolean,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
if (showDialog) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = context.getString(R.string.allowlist_restore_confirm_title),
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Text(
text = context.getString(R.string.allowlist_restore_confirm_message),
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(context.getString(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(context.getString(R.string.cancel))
}
}
)
}
}
suspend fun backupModules(context: Context, snackBarHost: SnackbarHostState, uri: Uri) {
withContext(Dispatchers.IO) {
try {
val busyboxPath = "/data/adb/ksu/bin/busybox"
val moduleDir = "/data/adb/modules"
// 直接将tar输出重定向到用户选择的文件
val command = """
cd "$moduleDir" &&
$busyboxPath tar -cz ./* > /proc/self/fd/1
""".trimIndent()
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
// 直接将tar输出写入到用户选择的文件
context.contentResolver.openOutputStream(uri)?.use { output ->
process.inputStream.copyTo(output)
}
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
if (process.exitValue() != 0) {
throw IOException(context.getString(R.string.command_execution_failed, error))
}
withContext(Dispatchers.Main) {
snackBarHost.showSnackbar(
context.getString(R.string.backup_success),
duration = SnackbarDuration.Long
)
}
} catch (e: Exception) {
Log.e("Backup", context.getString(R.string.backup_failed, ""), e)
withContext(Dispatchers.Main) {
snackBarHost.showSnackbar(
context.getString(R.string.backup_failed, e.message),
duration = SnackbarDuration.Long
)
}
}
}
}
suspend fun restoreModules(
context: Context,
snackBarHost: SnackbarHostState,
uri: Uri,
showConfirmDialog: (Boolean) -> Unit,
confirmResult: CompletableDeferred<Boolean>
) {
// 显示确认对话框
withContext(Dispatchers.Main) {
showConfirmDialog(true)
}
val userConfirmed = confirmResult.await()
if (!userConfirmed) return
withContext(Dispatchers.IO) {
try {
val busyboxPath = "/data/adb/ksu/bin/busybox"
val moduleDir = "/data/adb/modules"
// 直接从用户选择的文件读取并解压
val process = Runtime.getRuntime()
.exec(arrayOf("su", "-c", "$busyboxPath tar -xz -C $moduleDir"))
context.contentResolver.openInputStream(uri)?.use { input ->
input.copyTo(process.outputStream)
}
process.outputStream.close()
process.waitFor()
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
if (process.exitValue() != 0) {
throw IOException(context.getString(R.string.command_execution_failed, error))
}
withContext(Dispatchers.Main) {
val snackbarResult = snackBarHost.showSnackbar(
message = context.getString(R.string.restore_success),
actionLabel = context.getString(R.string.restart_now),
duration = SnackbarDuration.Long
)
if (snackbarResult == SnackbarResult.ActionPerformed) {
reboot()
}
}
} catch (e: Exception) {
Log.e("Restore", context.getString(R.string.restore_failed, ""), e)
withContext(Dispatchers.Main) {
snackBarHost.showSnackbar(
message = context.getString(
R.string.restore_failed,
e.message ?: context.getString(R.string.unknown_error)
),
duration = SnackbarDuration.Long
)
}
}
}
}
suspend fun backupAllowlist(context: Context, snackBarHost: SnackbarHostState, uri: Uri) {
withContext(Dispatchers.IO) {
try {
val allowlistPath = "/data/adb/ksu/.allowlist"
// 直接复制文件到用户选择的位置
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $allowlistPath"))
context.contentResolver.openOutputStream(uri)?.use { output ->
process.inputStream.copyTo(output)
}
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
if (process.exitValue() != 0) {
throw IOException(context.getString(R.string.command_execution_failed, error))
}
withContext(Dispatchers.Main) {
snackBarHost.showSnackbar(
context.getString(R.string.allowlist_backup_success),
duration = SnackbarDuration.Long
)
}
} catch (e: Exception) {
Log.e("AllowlistBackup", context.getString(R.string.allowlist_backup_failed, ""), e)
withContext(Dispatchers.Main) {
snackBarHost.showSnackbar(
context.getString(R.string.allowlist_backup_failed, e.message),
duration = SnackbarDuration.Long
)
}
}
}
}
suspend fun restoreAllowlist(
context: Context,
snackBarHost: SnackbarHostState,
uri: Uri,
showConfirmDialog: (Boolean) -> Unit,
confirmResult: CompletableDeferred<Boolean>
) {
// 显示确认对话框
withContext(Dispatchers.Main) {
showConfirmDialog(true)
}
val userConfirmed = confirmResult.await()
if (!userConfirmed) return
withContext(Dispatchers.IO) {
try {
val allowlistPath = "/data/adb/ksu/.allowlist"
// 直接从用户选择的文件读取并写入到目标位置
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat > $allowlistPath"))
context.contentResolver.openInputStream(uri)?.use { input ->
input.copyTo(process.outputStream)
}
process.outputStream.close()
process.waitFor()
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
if (process.exitValue() != 0) {
throw IOException(context.getString(R.string.command_execution_failed, error))
}
withContext(Dispatchers.Main) {
snackBarHost.showSnackbar(
context.getString(R.string.allowlist_restore_success),
duration = SnackbarDuration.Long
)
}
} catch (e: Exception) {
Log.e(
"AllowlistRestore",
context.getString(R.string.allowlist_restore_failed, ""),
e
)
withContext(Dispatchers.Main) {
snackBarHost.showSnackbar(
context.getString(R.string.allowlist_restore_failed, e.message),
duration = SnackbarDuration.Long
)
}
}
}
}
@Composable
fun rememberModuleBackupLauncher(
context: Context,
snackBarHost: SnackbarHostState,
scope: CoroutineScope = rememberCoroutineScope()
) = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
scope.launch {
backupModules(context, snackBarHost, uri)
}
}
}
}
@Composable
fun rememberModuleRestoreLauncher(
context: Context,
snackBarHost: SnackbarHostState,
scope: CoroutineScope = rememberCoroutineScope()
): ActivityResultLauncher<Intent> {
var showRestoreDialog by remember { mutableStateOf(false) }
var restoreConfirmResult by remember { mutableStateOf<CompletableDeferred<Boolean>?>(null) }
// 显示恢复确认对话框
RestoreConfirmationDialog(
showDialog = showRestoreDialog,
onConfirm = {
showRestoreDialog = false
restoreConfirmResult?.complete(true)
},
onDismiss = {
showRestoreDialog = false
restoreConfirmResult?.complete(false)
}
)
return rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
scope.launch {
val confirmResult = CompletableDeferred<Boolean>()
restoreConfirmResult = confirmResult
restoreModules(
context = context,
snackBarHost = snackBarHost,
uri = uri,
showConfirmDialog = { show -> showRestoreDialog = show },
confirmResult = confirmResult
)
}
}
}
}
}
@Composable
fun rememberAllowlistBackupLauncher(
context: Context,
snackBarHost: SnackbarHostState,
scope: CoroutineScope = rememberCoroutineScope()
) = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
scope.launch {
backupAllowlist(context, snackBarHost, uri)
}
}
}
}
@Composable
fun rememberAllowlistRestoreLauncher(
context: Context,
snackBarHost: SnackbarHostState,
scope: CoroutineScope = rememberCoroutineScope()
): ActivityResultLauncher<Intent> {
var showAllowlistRestoreDialog by remember { mutableStateOf(false) }
var allowlistRestoreConfirmResult by remember {
mutableStateOf<CompletableDeferred<Boolean>?>(
null
)
}
// 显示允许列表恢复确认对话框
AllowlistRestoreConfirmationDialog(
showDialog = showAllowlistRestoreDialog,
onConfirm = {
showAllowlistRestoreDialog = false
allowlistRestoreConfirmResult?.complete(true)
},
onDismiss = {
showAllowlistRestoreDialog = false
allowlistRestoreConfirmResult?.complete(false)
}
)
return rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
scope.launch {
val confirmResult = CompletableDeferred<Boolean>()
allowlistRestoreConfirmResult = confirmResult
restoreAllowlist(
context = context,
snackBarHost = snackBarHost,
uri = uri,
showConfirmDialog = { show -> showAllowlistRestoreDialog = show },
confirmResult = confirmResult
)
}
}
}
}
}
fun createBackupIntent(): Intent {
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/zip"
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
putExtra(Intent.EXTRA_TITLE, "modules_backup_$timestamp.zip")
}
}
fun createRestoreIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/zip"
}
}
fun createAllowlistBackupIntent(): Intent {
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/octet-stream"
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
putExtra(Intent.EXTRA_TITLE, "ksu_allowlist_backup_$timestamp.dat")
}
}
fun createAllowlistRestoreIntent(): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/octet-stream"
}
}
}

View file

@ -0,0 +1,139 @@
package com.sukisu.ultra.ui.util.module
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import com.sukisu.ultra.R
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.util.zip.ZipInputStream
object ModuleUtils {
private const val TAG = "ModuleUtils"
fun extractModuleName(context: Context, uri: Uri): String {
if (uri == Uri.EMPTY) {
Log.e(TAG, "The supplied URI is empty")
return context.getString(R.string.unknown_module)
}
return try {
Log.d(TAG, "Start extracting module names from URIs: $uri")
// 从URI路径中提取文件名
val fileName = uri.lastPathSegment?.let { path ->
val lastSlash = path.lastIndexOf('/')
if (lastSlash != -1 && lastSlash < path.length - 1) {
path.substring(lastSlash + 1)
} else {
path
}
}?.removeSuffix(".zip") ?: context.getString(R.string.unknown_module)
val formattedFileName = fileName.replace(Regex("[^a-zA-Z0-9\\s\\-_.@()\\u4e00-\\u9fa5]"), "").trim()
var moduleName = formattedFileName
try {
// 打开ZIP文件输入流
val inputStream = context.contentResolver.openInputStream(uri)
if (inputStream == null) {
Log.e(TAG, "Unable to get input stream from URI: $uri")
return formattedFileName
}
val zipInputStream = ZipInputStream(inputStream)
var entry = zipInputStream.nextEntry
// 遍历ZIP文件中的条目查找module.prop文件
while (entry != null) {
if (entry.name == "module.prop") {
val reader = BufferedReader(InputStreamReader(zipInputStream, StandardCharsets.UTF_8))
var line: String?
while (reader.readLine().also { line = it } != null) {
if (line?.startsWith("name=") == true) {
moduleName = line.substringAfter("=")
moduleName = moduleName.replace(Regex("[^a-zA-Z0-9\\s\\-_.@()\\u4e00-\\u9fa5]"), "").trim()
break
}
}
break
}
entry = zipInputStream.nextEntry
}
zipInputStream.close()
Log.d(TAG, "Successfully extracted module name: $moduleName")
moduleName
} catch (e: IOException) {
Log.e(TAG, "Error reading ZIP file: ${e.message}")
formattedFileName
}
} catch (e: Exception) {
Log.e(TAG, "Exception when extracting module name: ${e.message}")
context.getString(R.string.unknown_module)
}
}
// 验证URI是否有效并可访问
fun isUriAccessible(context: Context, uri: Uri): Boolean {
if (uri == Uri.EMPTY) return false
return try {
val inputStream = context.contentResolver.openInputStream(uri)
inputStream?.close()
inputStream != null
} catch (e: Exception) {
Log.e(TAG, "The URI is inaccessible: $uri, Error: ${e.message}")
false
}
}
// 获取URI的持久权限
fun takePersistableUriPermission(context: Context, uri: Uri) {
try {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, flags)
Log.d(TAG, "Persistent permissions for URIs have been obtained: $uri")
} catch (e: Exception) {
Log.e(TAG, "Unable to get persistent permissions on URIs: $uri, Error: ${e.message}")
}
}
fun extractModuleId(context: Context, uri: Uri): String? {
if (uri == Uri.EMPTY) {
return null
}
return try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
val zipInputStream = ZipInputStream(inputStream)
var entry = zipInputStream.nextEntry
var moduleId: String? = null
// 遍历ZIP文件中的条目查找module.prop文件
while (entry != null) {
if (entry.name == "module.prop") {
val reader = BufferedReader(InputStreamReader(zipInputStream, StandardCharsets.UTF_8))
var line: String?
while (reader.readLine().also { line = it } != null) {
if (line?.startsWith("id=") == true) {
moduleId = line.substringAfter("=").trim()
break
}
}
break
}
entry = zipInputStream.nextEntry
}
zipInputStream.close()
moduleId
} catch (e: Exception) {
Log.e(TAG, "提取模块ID时发生异常: ${e.message}", e)
null
}
}
}

View file

@ -0,0 +1,233 @@
package com.sukisu.ultra.ui.util.module
import android.content.Context
import android.net.Uri
import android.util.Log
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ui.util.getRootShell
import java.io.File
import java.io.FileOutputStream
/**
* @author ShirkNeko
* @date 2025/8/3
*/
// 模块签名验证工具类
object ModuleSignatureUtils {
private const val TAG = "ModuleSignatureUtils"
fun verifyModuleSignature(context: Context, moduleUri: Uri): Boolean {
return try {
// 创建临时文件
val tempFile = File(context.cacheDir, "temp_module_${System.currentTimeMillis()}.zip")
// 复制URI内容到临时文件
context.contentResolver.openInputStream(moduleUri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
// 调用native方法验证签名
val isVerified = Natives.verifyModuleSignature(tempFile.absolutePath)
// 清理临时文件
tempFile.delete()
Log.d(TAG, "Module signature verification result: $isVerified")
isVerified
} catch (e: Exception) {
Log.e(TAG, "Error verifying module signature", e)
false
}
}
}
// 验证模块签名
fun verifyModuleSignature(context: Context, moduleUri: Uri): Boolean {
return ModuleSignatureUtils.verifyModuleSignature(context, moduleUri)
}
object ModuleOperationUtils {
private const val TAG = "ModuleOperationUtils"
fun handleModuleInstallSuccess(context: Context, moduleUri: Uri, isSignatureVerified: Boolean) {
if (!isSignatureVerified) {
Log.d(TAG, "模块签名未验证,跳过创建验证标志")
return
}
try {
// 从ZIP文件提取模块ID
val moduleId = ModuleUtils.extractModuleId(context, moduleUri)
if (moduleId == null) {
Log.e(TAG, "无法提取模块ID无法创建验证标志")
return
}
// 创建验证标志文件
val success = ModuleVerificationManager.createVerificationFlag(moduleId)
if (success) {
Log.d(TAG, "模块 $moduleId 验证标志创建成功")
} else {
Log.e(TAG, "模块 $moduleId 验证标志创建失败")
}
} catch (e: Exception) {
Log.e(TAG, "处理模块安装成功时发生异常", e)
}
}
fun handleModuleUninstall(moduleId: String) {
try {
val success = ModuleVerificationManager.removeVerificationFlag(moduleId)
if (success) {
Log.d(TAG, "模块 $moduleId 验证标志移除成功")
} else {
Log.d(TAG, "模块 $moduleId 验证标志移除失败或不存在")
}
} catch (e: Exception) {
Log.e(TAG, "处理模块卸载时发生异常: $moduleId", e)
}
}
fun handleModuleUpdate(context: Context, moduleUri: Uri, isSignatureVerified: Boolean) {
try {
val moduleId = ModuleUtils.extractModuleId(context, moduleUri)
if (moduleId == null) {
Log.e(TAG, "无法提取模块ID无法处理验证标志")
return
}
if (isSignatureVerified) {
// 签名验证通过,创建或更新验证标志
val success = ModuleVerificationManager.createVerificationFlag(moduleId)
if (success) {
Log.d(TAG, "模块 $moduleId 更新后验证标志已更新")
} else {
Log.e(TAG, "模块 $moduleId 更新后验证标志更新失败")
}
} else {
// 签名验证失败,移除验证标志
ModuleVerificationManager.removeVerificationFlag(moduleId)
Log.d(TAG, "模块 $moduleId 更新后签名未验证,验证标志已移除")
}
} catch (e: Exception) {
Log.e(TAG, "处理模块更新时发生异常", e)
}
}
}
object ModuleVerificationManager {
private const val TAG = "ModuleVerificationManager"
private const val VERIFICATION_FLAGS_DIR = "/data/adb/ksu/verified_modules"
// 为指定模块创建验证标志文件
fun createVerificationFlag(moduleId: String): Boolean {
return try {
val shell = getRootShell()
val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId"
// 确保目录存在
val createDirCommand = "mkdir -p '$VERIFICATION_FLAGS_DIR'"
shell.newJob().add(createDirCommand).exec()
// 创建验证标志文件,写入验证时间戳
val timestamp = System.currentTimeMillis()
val command = "echo '$timestamp' > '$flagFilePath'"
val result = shell.newJob().add(command).exec()
if (result.isSuccess) {
Log.d(TAG, "验证标志文件创建成功: $flagFilePath")
true
} else {
Log.e(TAG, "验证标志文件创建失败: $moduleId")
false
}
} catch (e: Exception) {
Log.e(TAG, "创建验证标志文件时发生异常: $moduleId", e)
false
}
}
fun removeVerificationFlag(moduleId: String): Boolean {
return try {
val shell = getRootShell()
val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId"
val command = "rm -f '$flagFilePath'"
val result = shell.newJob().add(command).exec()
if (result.isSuccess) {
Log.d(TAG, "验证标志文件移除成功: $flagFilePath")
true
} else {
Log.e(TAG, "验证标志文件移除失败: $moduleId")
false
}
} catch (e: Exception) {
Log.e(TAG, "移除验证标志文件时发生异常: $moduleId", e)
false
}
}
fun getVerificationTimestamp(moduleId: String): Long {
return try {
val shell = getRootShell()
val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId"
val command = "cat '$flagFilePath' 2>/dev/null || echo '0'"
val result = shell.newJob().add(command).to(ArrayList(), null).exec()
if (result.isSuccess && result.out.isNotEmpty()) {
val timestampStr = result.out.firstOrNull()?.trim() ?: "0"
timestampStr.toLongOrNull() ?: 0L
} else {
0L
}
} catch (e: Exception) {
Log.e(TAG, "获取验证时间戳时发生异常: $moduleId", e)
0L
}
}
fun batchCheckVerificationStatus(moduleIds: List<String>): Map<String, Boolean> {
if (moduleIds.isEmpty()) return emptyMap()
return try {
val shell = getRootShell()
val result = mutableMapOf<String, Boolean>()
// 确保目录存在
val createDirCommand = "mkdir -p '$VERIFICATION_FLAGS_DIR'"
shell.newJob().add(createDirCommand).exec()
// 批量检查所有模块的验证标志文件
val commands = moduleIds.map { moduleId ->
"test -f '$VERIFICATION_FLAGS_DIR/$moduleId' && echo '$moduleId:true' || echo '$moduleId:false'"
}
val command = commands.joinToString(" && ")
val shellResult = shell.newJob().add(command).to(ArrayList(), null).exec()
if (shellResult.isSuccess) {
shellResult.out.forEach { line ->
val parts = line.split(":")
if (parts.size == 2) {
val moduleId = parts[0]
val isVerified = parts[1] == "true"
result[moduleId] = isVerified
}
}
}
Log.d(TAG, "批量验证检查完成,共检查 ${moduleIds.size} 个模块")
result
} catch (e: Exception) {
Log.e(TAG, "批量检查验证状态时发生异常", e)
// 返回默认值,所有模块都标记为未验证
moduleIds.associateWith { false }
}
}
}

View file

@ -0,0 +1,590 @@
package com.sukisu.ultra.ui.viewmodel
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.system.Os
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sukisu.ultra.KernelVersion
import com.sukisu.ultra.Natives
import com.sukisu.ultra.getKernelVersion
import com.sukisu.ultra.ksuApp
import com.sukisu.ultra.ui.util.*
import com.sukisu.ultra.ui.util.module.LatestVersionInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class HomeViewModel : ViewModel() {
// 系统状态
data class SystemStatus(
val isManager: Boolean = false,
val ksuVersion: Int? = null,
val ksuFullVersion : String? = null,
val lkmMode: Boolean? = null,
val kernelVersion: KernelVersion = getKernelVersion(),
val isRootAvailable: Boolean = false,
val isKpmConfigured: Boolean = false,
val requireNewKernel: Boolean = false
)
// 系统信息
data class SystemInfo(
val kernelRelease: String = "",
val androidVersion: String = "",
val deviceModel: String = "",
val managerVersion: Pair<String, Long> = Pair("", 0L),
val seLinuxStatus: String = "",
val kpmVersion: String = "",
val suSFSStatus: String = "",
val suSFSVersion: String = "",
val suSFSVariant: String = "",
val suSFSFeatures: String = "",
val superuserCount: Int = 0,
val moduleCount: Int = 0,
val kpmModuleCount: Int = 0,
val managersList: Natives.ManagersList? = null,
val isDynamicSignEnabled: Boolean = false,
val zygiskImplement: String = ""
)
// 状态变量
var systemStatus by mutableStateOf(SystemStatus())
private set
var systemInfo by mutableStateOf(SystemInfo())
private set
var latestVersionInfo by mutableStateOf(LatestVersionInfo())
private set
var isSimpleMode by mutableStateOf(false)
private set
var isKernelSimpleMode by mutableStateOf(false)
private set
var isHideVersion by mutableStateOf(false)
private set
var isHideOtherInfo by mutableStateOf(false)
private set
var isHideSusfsStatus by mutableStateOf(false)
private set
var isHideZygiskImplement by mutableStateOf(false)
private set
var isHideLinkCard by mutableStateOf(false)
private set
var showKpmInfo by mutableStateOf(false)
private set
var isCoreDataLoaded by mutableStateOf(false)
private set
var isExtendedDataLoaded by mutableStateOf(false)
private set
var isRefreshing by mutableStateOf(false)
private set
// 数据刷新状态流,用于监听变化
private val _dataRefreshTrigger = MutableStateFlow(0L)
val dataRefreshTrigger: StateFlow<Long> = _dataRefreshTrigger
private var loadingJobs = mutableListOf<Job>()
private var lastRefreshTime = 0L
private val refreshCooldown = 2000L
fun loadUserSettings(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
val settingsPrefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
isSimpleMode = settingsPrefs.getBoolean("is_simple_mode", false)
isKernelSimpleMode = settingsPrefs.getBoolean("is_kernel_simple_mode", false)
isHideVersion = settingsPrefs.getBoolean("is_hide_version", false)
isHideOtherInfo = settingsPrefs.getBoolean("is_hide_other_info", false)
isHideSusfsStatus = settingsPrefs.getBoolean("is_hide_susfs_status", false)
isHideLinkCard = settingsPrefs.getBoolean("is_hide_link_card", false)
isHideZygiskImplement = settingsPrefs.getBoolean("is_hide_zygisk_Implement", false)
showKpmInfo = settingsPrefs.getBoolean("show_kpm_info", false)
}
}
fun loadCoreData() {
if (isCoreDataLoaded) return
val job = viewModelScope.launch(Dispatchers.IO) {
try {
val kernelVersion = getKernelVersion()
val isManager = try {
Natives.isManager
} catch (_: Exception) {
false
}
val ksuVersion = if (isManager) Natives.version else null
val fullVersion = try {
Natives.getFullVersion()
} catch (_: Exception) {
"Unknown"
}
val ksuFullVersion = if (isKernelSimpleMode) {
try {
val startIndex = fullVersion.indexOf('v')
if (startIndex >= 0) {
val endIndex = fullVersion.indexOf('-', startIndex)
val versionStr = if (endIndex > startIndex) {
fullVersion.substring(startIndex, endIndex)
} else {
fullVersion.substring(startIndex)
}
val numericVersion = "v" + (Regex("""\d+(\.\d+)*""").find(versionStr)?.value ?: versionStr)
numericVersion
} else {
fullVersion
}
} catch (_: Exception) {
fullVersion
}
} else {
fullVersion
}
val lkmMode = ksuVersion?.let {
if (kernelVersion.isGKI()) Natives.isLkmMode else null
}
val isRootAvailable = try {
rootAvailable()
} catch (_: Exception) {
false
}
val isKpmConfigured = try {
Natives.isKPMEnabled()
} catch (_: Exception) {
false
}
val requireNewKernel = try {
isManager && Natives.requireNewKernel()
} catch (_: Exception) {
false
}
systemStatus = SystemStatus(
isManager = isManager,
ksuVersion = ksuVersion,
ksuFullVersion = ksuFullVersion,
lkmMode = lkmMode,
kernelVersion = kernelVersion,
isRootAvailable = isRootAvailable,
isKpmConfigured = isKpmConfigured,
requireNewKernel = requireNewKernel
)
isCoreDataLoaded = true
} catch (_: Exception) {
}
}
loadingJobs.add(job)
}
fun loadExtendedData(context: Context) {
if (isExtendedDataLoaded) return
val job = viewModelScope.launch(Dispatchers.IO) {
try {
// 分批加载
delay(50)
val basicInfo = loadBasicSystemInfo(context)
systemInfo = systemInfo.copy(
kernelRelease = basicInfo.first,
androidVersion = basicInfo.second,
deviceModel = basicInfo.third,
managerVersion = basicInfo.fourth,
seLinuxStatus = basicInfo.fifth
)
delay(100)
// 加载模块信息
if (!isSimpleMode) {
val moduleInfo = loadModuleInfo()
systemInfo = systemInfo.copy(
kpmVersion = moduleInfo.first,
superuserCount = moduleInfo.second,
moduleCount = moduleInfo.third,
kpmModuleCount = moduleInfo.fourth,
zygiskImplement = moduleInfo.fifth
)
}
delay(100)
// 加载SuSFS信息
if (!isHideSusfsStatus) {
val suSFSInfo = loadSuSFSInfo()
systemInfo = systemInfo.copy(
suSFSStatus = suSFSInfo.first,
suSFSVersion = suSFSInfo.second,
suSFSVariant = suSFSInfo.third,
suSFSFeatures = suSFSInfo.fourth,
)
}
delay(100)
// 加载管理器列表
val managerInfo = loadManagerInfo()
systemInfo = systemInfo.copy(
managersList = managerInfo.first,
isDynamicSignEnabled = managerInfo.second
)
isExtendedDataLoaded = true
} catch (_: Exception) {
// 静默处理错误
}
}
loadingJobs.add(job)
}
fun refreshData(context: Context, forceRefresh: Boolean = false) {
val currentTime = System.currentTimeMillis()
// 如果不是强制刷新,检查冷却时间
if (!forceRefresh && currentTime - lastRefreshTime < refreshCooldown) {
return
}
lastRefreshTime = currentTime
viewModelScope.launch {
isRefreshing = true
try {
// 取消正在进行的加载任务
loadingJobs.forEach { it.cancel() }
loadingJobs.clear()
// 重置状态
isCoreDataLoaded = false
isExtendedDataLoaded = false
// 触发数据刷新状态流
_dataRefreshTrigger.value = currentTime
// 重新加载用户设置
loadUserSettings(context)
// 重新加载核心数据
loadCoreData()
delay(100)
// 重新加载扩展数据
loadExtendedData(context)
// 检查更新
val settingsPrefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val checkUpdate = settingsPrefs.getBoolean("check_update", true)
if (checkUpdate) {
try {
val newVersionInfo = withContext(Dispatchers.IO) {
checkNewVersion()
}
latestVersionInfo = newVersionInfo
} catch (_: Exception) {
}
}
} catch (_: Exception) {
// 静默处理错误
} finally {
isRefreshing = false
}
}
}
// 手动触发刷新(下拉刷新使用)
fun onPullRefresh(context: Context) {
refreshData(context, forceRefresh = true)
}
// 自动刷新数据(当检测到变化时)
fun autoRefreshIfNeeded(context: Context) {
viewModelScope.launch {
// 检查是否需要刷新数据
val needsRefresh = checkIfDataNeedsRefresh()
if (needsRefresh) {
refreshData(context)
}
}
}
private suspend fun checkIfDataNeedsRefresh(): Boolean {
return withContext(Dispatchers.IO) {
try {
// 检查KSU状态是否发生变化
val currentKsuVersion = try {
if (Natives.isManager) {
Natives.version
} else null
} catch (_: Exception) {
null
}
// 如果KSU版本发生变化需要刷新
if (currentKsuVersion != systemStatus.ksuVersion) {
return@withContext true
}
// 检查模块数量是否发生变化
val currentModuleCount = try {
getModuleCount()
} catch (_: Exception) {
systemInfo.moduleCount
}
if (currentModuleCount != systemInfo.moduleCount) {
return@withContext true
}
false
} catch (_: Exception) {
false
}
}
}
private suspend fun loadBasicSystemInfo(context: Context): Tuple5<String, String, String, Pair<String, Long>, String> {
return withContext(Dispatchers.IO) {
val uname = try {
Os.uname()
} catch (_: Exception) {
null
}
val deviceModel = try {
getDeviceModel()
} catch (_: Exception) {
"Unknown"
}
val managerVersion = try {
getManagerVersion(context)
} catch (_: Exception) {
Pair("Unknown", 0L)
}
val seLinuxStatus = try {
getSELinuxStatus(ksuApp.applicationContext)
} catch (_: Exception) {
"Unknown"
}
Tuple5(
uname?.release ?: "Unknown",
Build.VERSION.RELEASE ?: "Unknown",
deviceModel,
managerVersion,
seLinuxStatus
)
}
}
private suspend fun loadModuleInfo(): Tuple5<String, Int, Int, Int, String> {
return withContext(Dispatchers.IO) {
val kpmVersion = try {
getKpmVersion()
} catch (_: Exception) {
"Unknown"
}
val superuserCount = try {
getSuperuserCount()
} catch (_: Exception) {
0
}
val moduleCount = try {
getModuleCount()
} catch (_: Exception) {
0
}
val kpmModuleCount = try {
getKpmModuleCount()
} catch (_: Exception) {
0
}
val zygiskImplement = try {
getZygiskImplement()
} catch (_: Exception) {
"None"
}
Tuple5(kpmVersion, superuserCount, moduleCount, kpmModuleCount, zygiskImplement)
}
}
private suspend fun loadSuSFSInfo(): Tuple4<String, String, String, String> {
return withContext(Dispatchers.IO) {
val suSFS = try {
val rawFeature = getSuSFSFeatures()
if (rawFeature.isNotEmpty() && !rawFeature.startsWith("[-]")) {
"Supported"
} else {
rawFeature
}
} catch (_: Exception) {
"Unknown"
}
if (suSFS != "Supported") {
return@withContext Tuple4(suSFS, "", "", "")
}
val suSFSVersion = try {
getSuSFSVersion()
} catch (_: Exception) {
""
}
if (suSFSVersion.isEmpty()) {
return@withContext Tuple4(suSFS, "", "", "")
}
val suSFSVariant = try {
getSuSFSVariant()
} catch (_: Exception) {
""
}
val suSFSFeatures = try {
getSuSFSFeatures()
} catch (_: Exception) {
""
}
Tuple4(suSFS, suSFSVersion, suSFSVariant, suSFSFeatures)
}
}
private suspend fun loadManagerInfo(): Pair<Natives.ManagersList?, Boolean> {
return withContext(Dispatchers.IO) {
val dynamicSignConfig = try {
Natives.getDynamicManager()
} catch (_: Exception) {
null
}
val isDynamicSignEnabled = try {
dynamicSignConfig?.isValid() == true
} catch (_: Exception) {
false
}
val managersList = if (isDynamicSignEnabled) {
try {
Natives.getManagersList()
} catch (_: Exception) {
null
}
} else {
null
}
Pair(managersList, isDynamicSignEnabled)
}
}
@SuppressLint("PrivateApi")
private fun getDeviceModel(): String {
return try {
val systemProperties = Class.forName("android.os.SystemProperties")
val getMethod = systemProperties.getMethod("get", String::class.java, String::class.java)
val marketNameKeys = listOf(
"ro.product.marketname",
"ro.vendor.oplus.market.name",
"ro.vivo.market.name",
"ro.config.marketing_name"
)
var result = getDeviceInfo()
for (key in marketNameKeys) {
try {
val marketName = getMethod.invoke(null, key, "") as String
if (marketName.isNotEmpty()) {
result = marketName
break
}
} catch (_: Exception) {
}
}
result
} catch (
_: Exception) {
getDeviceInfo()
}
}
private fun getDeviceInfo(): String {
return try {
var manufacturer = Build.MANUFACTURER ?: "Unknown"
manufacturer = manufacturer[0].uppercaseChar().toString() + manufacturer.substring(1)
val brand = Build.BRAND ?: ""
if (brand.isNotEmpty() && !brand.equals(Build.MANUFACTURER, ignoreCase = true)) {
manufacturer += " " + brand[0].uppercaseChar() + brand.substring(1)
}
val model = Build.MODEL ?: ""
if (model.isNotEmpty()) {
manufacturer += " $model "
}
manufacturer
} catch (_: Exception) {
"Unknown Device"
}
}
private fun getManagerVersion(context: Context): Pair<String, Long> {
return try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val versionCode = androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(packageInfo)
val versionName = packageInfo.versionName ?: "Unknown"
Pair(versionName, versionCode)
} catch (_: Exception) {
Pair("Unknown", 0L)
}
}
data class Tuple5<T1, T2, T3, T4, T5>(
val first: T1,
val second: T2,
val third: T3,
val fourth: T4,
val fifth: T5
)
data class Tuple4<T1, T2, T3, T4>(
val first: T1,
val second: T2,
val third: T3,
val fourth: T4
)
override fun onCleared() {
super.onCleared()
loadingJobs.forEach { it.cancel() }
loadingJobs.clear()
}
}

View file

@ -0,0 +1,160 @@
package com.sukisu.ultra.ui.viewmodel
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sukisu.ultra.ui.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* @author ShirkNeko
* @date 2025/5/31.
*/
class KpmViewModel : ViewModel() {
var moduleList by mutableStateOf(emptyList<ModuleInfo>())
private set
var search by mutableStateOf("")
internal set
var isRefreshing by mutableStateOf(false)
private set
var currentModuleDetail by mutableStateOf("")
private set
fun fetchModuleList() {
viewModelScope.launch {
isRefreshing = true
try {
val moduleCount = getKpmModuleCount()
Log.d("KsuCli", "Module count: $moduleCount")
moduleList = getAllKpmModuleInfo()
// 获取 KPM 版本信息
val kpmVersion = getKpmVersion()
Log.d("KsuCli", "KPM Version: $kpmVersion")
} catch (e: Exception) {
Log.e("KsuCli", "获取模块列表失败", e)
} finally {
isRefreshing = false
}
}
}
private fun getAllKpmModuleInfo(): List<ModuleInfo> {
val result = mutableListOf<ModuleInfo>()
try {
val str = listKpmModules()
val moduleNames = str
.split("\n")
.filter { it.isNotBlank() }
for (name in moduleNames) {
try {
val moduleInfo = parseModuleInfo(name)
moduleInfo?.let { result.add(it) }
} catch (e: Exception) {
Log.e("KsuCli", "Error processing module $name", e)
}
}
} catch (e: Exception) {
Log.e("KsuCli", "Failed to get module list", e)
}
return result
}
private fun parseModuleInfo(name: String): ModuleInfo? {
val info = getKpmModuleInfo(name)
if (info.isBlank()) return null
val properties = info.lineSequence()
.filter { line ->
val trimmed = line.trim()
trimmed.isNotEmpty() && !trimmed.startsWith("#")
}
.mapNotNull { line ->
line.split("=", limit = 2).let { parts ->
when (parts.size) {
2 -> parts[0].trim() to parts[1].trim()
1 -> parts[0].trim() to ""
else -> null
}
}
}
.toMap()
return ModuleInfo(
id = name,
name = properties["name"] ?: name,
version = properties["version"] ?: "",
author = properties["author"] ?: "",
description = properties["description"] ?: "",
args = properties["args"] ?: "",
enabled = true,
hasAction = true
)
}
fun loadModuleDetail(moduleId: String) {
viewModelScope.launch {
try {
currentModuleDetail = withContext(Dispatchers.IO) {
getKpmModuleInfo(moduleId)
}
Log.d("KsuCli", "Module detail loaded: $currentModuleDetail")
} catch (e: Exception) {
Log.e("KsuCli", "Failed to load module detail", e)
currentModuleDetail = "Error: ${e.message}"
}
}
}
var showInputDialog by mutableStateOf(false)
private set
var selectedModuleId by mutableStateOf<String?>(null)
private set
var inputArgs by mutableStateOf("")
private set
fun showInputDialog(moduleId: String) {
selectedModuleId = moduleId
showInputDialog = true
}
fun hideInputDialog() {
showInputDialog = false
selectedModuleId = null
inputArgs = ""
}
fun updateInputArgs(args: String) {
inputArgs = args
}
fun executeControl(): Int {
val moduleId = selectedModuleId ?: return -1
val result = controlKpmModule(moduleId, inputArgs)
hideInputDialog()
return result
}
data class ModuleInfo(
val id: String,
val name: String,
val version: String,
val author: String,
val description: String,
val args: String,
val enabled: Boolean,
val hasAction: Boolean
)
}

View file

@ -0,0 +1,504 @@
package com.sukisu.ultra.ui.viewmodel
import android.content.Context
import android.os.SystemClock
import android.util.Log
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.dergoogler.mmrl.platform.model.ModuleConfig
import com.dergoogler.mmrl.platform.model.ModuleConfig.Companion.asModuleConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.sukisu.ultra.ui.util.HanziToPinyin
import com.sukisu.ultra.ui.util.listModules
import com.sukisu.ultra.ui.util.getRootShell
import com.sukisu.ultra.ui.util.module.ModuleVerificationManager
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.text.Collator
import java.text.DecimalFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.math.log10
import kotlin.math.pow
import androidx.core.content.edit
/**
* @author ShirkNeko
* @date 2025/5/31.
*/
class ModuleViewModel : ViewModel() {
companion object {
private const val TAG = "ModuleViewModel"
private var modules by mutableStateOf<List<ModuleInfo>>(emptyList())
private const val CUSTOM_USER_AGENT = "SukiSU-Ultra/2.0"
}
// 模块大小缓存管理器
private lateinit var moduleSizeCache: ModuleSizeCache
fun initializeCache(context: Context) {
if (!::moduleSizeCache.isInitialized) {
moduleSizeCache = ModuleSizeCache(context)
}
}
fun getModuleSize(dirId: String): String {
if (!::moduleSizeCache.isInitialized) {
return "0 KB"
}
val size = moduleSizeCache.getModuleSize(dirId)
return formatFileSize(size)
}
/**
* 刷新所有模块的大小缓存
* 只在安装卸载更新模块后调用
*/
fun refreshModuleSizeCache() {
if (!::moduleSizeCache.isInitialized) return
viewModelScope.launch(Dispatchers.IO) {
Log.d(TAG, "开始刷新模块大小缓存")
val currentModules = modules.map { it.dirId }
moduleSizeCache.refreshCache(currentModules)
Log.d(TAG, "模块大小缓存刷新完成")
}
}
class ModuleInfo(
val id: String,
val name: String,
val author: String,
val version: String,
val versionCode: Int,
val description: String,
val enabled: Boolean,
val update: Boolean,
val remove: Boolean,
val updateJson: String,
val hasWebUi: Boolean,
val hasActionScript: Boolean,
val dirId: String, // real module id (dir name)
var config: ModuleConfig? = null,
var isVerified: Boolean = false, // 添加验证状态字段
var verificationTimestamp: Long = 0L, // 添加验证时间戳
)
var isRefreshing by mutableStateOf(false)
private set
var search by mutableStateOf("")
var sortEnabledFirst by mutableStateOf(false)
var sortActionFirst by mutableStateOf(false)
val moduleList by derivedStateOf {
val comparator =
compareBy<ModuleInfo>(
{ if (sortEnabledFirst) !it.enabled else 0 },
{ if (sortActionFirst) !it.hasWebUi && !it.hasActionScript else 0 },
).thenBy(Collator.getInstance(Locale.getDefault()), ModuleInfo::id)
modules.filter {
it.id.contains(search, true) || it.name.contains(search, true) || HanziToPinyin.getInstance()
.toPinyinString(it.name)?.contains(search, true) == true
}.sortedWith(comparator).also {
isRefreshing = false
}
}
var isNeedRefresh by mutableStateOf(false)
private set
fun markNeedRefresh() {
isNeedRefresh = true
// 标记需要刷新时,同时刷新大小缓存
refreshModuleSizeCache()
}
fun fetchModuleList() {
viewModelScope.launch(Dispatchers.IO) {
isRefreshing = true
val oldModuleList = modules
val start = SystemClock.elapsedRealtime()
kotlin.runCatching {
val result = listModules()
Log.i(TAG, "result: $result")
val array = JSONArray(result)
val moduleInfos = (0 until array.length())
.asSequence()
.map { array.getJSONObject(it) }
.map { obj ->
ModuleInfo(
obj.getString("id"),
obj.optString("name"),
obj.optString("author", "Unknown"),
obj.optString("version", "Unknown"),
obj.getIntCompat("versionCode", 0),
obj.optString("description"),
obj.getBooleanCompat("enabled"),
obj.getBooleanCompat("update"),
obj.getBooleanCompat("remove"),
obj.optString("updateJson"),
obj.getBooleanCompat("web"),
obj.getBooleanCompat("action"),
obj.optString("dir_id", obj.getString("id"))
)
}.toList()
// 批量检查所有模块的验证状态
val moduleIds = moduleInfos.map { it.dirId }
val verificationStatus = ModuleVerificationManager.batchCheckVerificationStatus(moduleIds)
// 更新模块验证状态
modules = moduleInfos.map { moduleInfo ->
val isVerified = verificationStatus[moduleInfo.dirId] ?: false
val verificationTimestamp = if (isVerified) {
ModuleVerificationManager.getVerificationTimestamp(moduleInfo.dirId)
} else {
0L
}
moduleInfo.copy(
isVerified = isVerified,
verificationTimestamp = verificationTimestamp
)
}
launch {
modules.forEach { module ->
withContext(Dispatchers.IO) {
try {
runCatching {
module.config = module.id.asModuleConfig
}.onFailure { e ->
Log.e(TAG, "Failed to load config from id for module ${module.id}", e)
}
if (module.config == null) {
runCatching {
module.config = module.name.asModuleConfig
}.onFailure { e ->
Log.e(TAG, "Failed to load config from name for module ${module.id}", e)
}
}
if (module.config == null) {
runCatching {
module.config = module.description.asModuleConfig
}.onFailure { e ->
Log.e(TAG, "Failed to load config from description for module ${module.id}", e)
}
}
if (module.config == null) {
module.config = ModuleConfig()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load any config for module ${module.id}", e)
module.config = ModuleConfig()
}
}
}
}
// 首次加载模块列表时,初始化缓存
if (::moduleSizeCache.isInitialized) {
val currentModules = modules.map { it.dirId }
moduleSizeCache.initializeCacheIfNeeded(currentModules)
}
isNeedRefresh = false
}.onFailure { e ->
Log.e(TAG, "fetchModuleList: ", e)
isRefreshing = false
}
// when both old and new is kotlin.collections.EmptyList
// moduleList update will don't trigger
if (oldModuleList === modules) {
isRefreshing = false
}
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}, modules: $modules")
}
}
private fun sanitizeVersionString(version: String): String {
return version.replace(Regex("[^a-zA-Z0-9.\\-_]"), "_")
}
fun checkUpdate(m: ModuleInfo): Triple<String, String, String> {
val empty = Triple("", "", "")
if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) {
return empty
}
// download updateJson
val result = kotlin.runCatching {
val url = m.updateJson
Log.i(TAG, "checkUpdate url: $url")
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder()
.url(url)
.header("User-Agent", CUSTOM_USER_AGENT)
.build()
val response = client.newCall(request).execute()
Log.d(TAG, "checkUpdate code: ${response.code}")
if (response.isSuccessful) {
response.body?.string() ?: ""
} else {
Log.d(TAG, "checkUpdate failed: ${response.message}")
""
}
}.getOrElse { e ->
Log.e(TAG, "checkUpdate exception", e)
""
}
Log.i(TAG, "checkUpdate result: $result")
if (result.isEmpty()) {
return empty
}
val updateJson = kotlin.runCatching {
JSONObject(result)
}.getOrNull() ?: return empty
var version = updateJson.optString("version", "")
version = sanitizeVersionString(version)
val versionCode = updateJson.optInt("versionCode", 0)
val zipUrl = updateJson.optString("zipUrl", "")
val changelog = updateJson.optString("changelog", "")
if (versionCode <= m.versionCode || zipUrl.isEmpty()) {
return empty
}
return Triple(zipUrl, version, changelog)
}
}
fun ModuleViewModel.ModuleInfo.copy(
id: String = this.id,
name: String = this.name,
author: String = this.author,
version: String = this.version,
versionCode: Int = this.versionCode,
description: String = this.description,
enabled: Boolean = this.enabled,
update: Boolean = this.update,
remove: Boolean = this.remove,
updateJson: String = this.updateJson,
hasWebUi: Boolean = this.hasWebUi,
hasActionScript: Boolean = this.hasActionScript,
dirId: String = this.dirId,
config: ModuleConfig? = this.config,
isVerified: Boolean = this.isVerified,
verificationTimestamp: Long = this.verificationTimestamp
): ModuleViewModel.ModuleInfo {
return ModuleViewModel.ModuleInfo(
id, name, author, version, versionCode, description,
enabled, update, remove, updateJson, hasWebUi, hasActionScript,
dirId, config, isVerified, verificationTimestamp
)
}
/**
* 模块大小缓存管理器
*/
class ModuleSizeCache(context: Context) {
companion object {
private const val TAG = "ModuleSizeCache"
private const val CACHE_PREFS_NAME = "module_size_cache"
private const val CACHE_VERSION_KEY = "cache_version"
private const val CACHE_INITIALIZED_KEY = "cache_initialized"
private const val CURRENT_CACHE_VERSION = 1
}
private val cachePrefs = context.getSharedPreferences(CACHE_PREFS_NAME, Context.MODE_PRIVATE)
private val sizeCache = mutableMapOf<String, Long>()
init {
loadCacheFromPrefs()
}
/**
* 从SharedPreferences加载缓存
*/
private fun loadCacheFromPrefs() {
try {
val cacheVersion = cachePrefs.getInt(CACHE_VERSION_KEY, 0)
if (cacheVersion != CURRENT_CACHE_VERSION) {
Log.d(TAG, "缓存版本不匹配,清空缓存")
clearCache()
return
}
val allEntries = cachePrefs.all
for ((key, value) in allEntries) {
if (key != CACHE_VERSION_KEY && key != CACHE_INITIALIZED_KEY && value is Long) {
sizeCache[key] = value
}
}
Log.d(TAG, "从缓存加载了 ${sizeCache.size} 个模块大小数据")
} catch (e: Exception) {
Log.e(TAG, "加载缓存失败", e)
clearCache()
}
}
/**
* 保存缓存到SharedPreferences
*/
private fun saveCacheToPrefs() {
try {
cachePrefs.edit {
putInt(CACHE_VERSION_KEY, CURRENT_CACHE_VERSION)
putBoolean(CACHE_INITIALIZED_KEY, true)
for ((dirId, size) in sizeCache) {
putLong(dirId, size)
}
}
Log.d(TAG, "保存了 ${sizeCache.size} 个模块大小到缓存")
} catch (e: Exception) {
Log.e(TAG, "保存缓存失败", e)
}
}
/**
* 获取模块大小从缓存
*/
fun getModuleSize(dirId: String): Long {
return sizeCache[dirId] ?: 0L
}
/**
* 检查缓存是否已初始化如果没有则初始化
*/
fun initializeCacheIfNeeded(currentModules: List<String>) {
val isInitialized = cachePrefs.getBoolean(CACHE_INITIALIZED_KEY, false)
if (!isInitialized || sizeCache.isEmpty()) {
Log.d(TAG, "首次初始化缓存,计算所有模块大小")
refreshCache(currentModules)
} else {
// 检查是否有新模块需要计算大小
val newModules = currentModules.filter { !sizeCache.containsKey(it) }
if (newModules.isNotEmpty()) {
Log.d(TAG, "发现 ${newModules.size} 个新模块,计算大小: $newModules")
for (dirId in newModules) {
val size = calculateModuleFolderSize(dirId)
sizeCache[dirId] = size
Log.d(TAG, "新模块 $dirId 大小: ${formatFileSize(size)}")
}
saveCacheToPrefs()
}
}
}
/**
* 刷新所有模块的大小缓存
*/
fun refreshCache(currentModules: List<String>) {
try {
// 清理不存在的模块缓存
val toRemove = sizeCache.keys.filter { it !in currentModules }
toRemove.forEach { sizeCache.remove(it) }
if (toRemove.isNotEmpty()) {
Log.d(TAG, "清理了 ${toRemove.size} 个不存在的模块缓存: $toRemove")
}
// 计算所有当前模块的大小
for (dirId in currentModules) {
val size = calculateModuleFolderSize(dirId)
sizeCache[dirId] = size
Log.d(TAG, "更新模块 $dirId 大小: ${formatFileSize(size)}")
}
// 保存到持久化存储
saveCacheToPrefs()
} catch (e: Exception) {
Log.e(TAG, "刷新缓存失败", e)
}
}
/**
* 清空所有缓存
*/
private fun clearCache() {
sizeCache.clear()
cachePrefs.edit { clear() }
Log.d(TAG, "清空所有缓存")
}
/**
* 实际计算模块文件夹大小
*/
private fun calculateModuleFolderSize(dirId: String): Long {
return try {
val shell = getRootShell()
val command = "du -sb /data/adb/modules/$dirId"
val result = shell.newJob().add(command).to(ArrayList(), null).exec()
if (result.isSuccess && result.out.isNotEmpty()) {
val sizeStr = result.out.firstOrNull()?.split("\t")?.firstOrNull()
sizeStr?.toLongOrNull() ?: 0L
} else {
0L
}
} catch (e: Exception) {
Log.e(TAG, "计算模块大小失败 $dirId: ${e.message}")
0L
}
}
}
private fun JSONObject.getBooleanCompat(key: String, default: Boolean = false): Boolean {
if (!has(key)) return default
return when (val value = opt(key)) {
is Boolean -> value
is String -> value.equals("true", ignoreCase = true) || value == "1"
is Number -> value.toInt() != 0
else -> default
}
}
private fun JSONObject.getIntCompat(key: String, default: Int = 0): Int {
if (!has(key)) return default
return when (val value = opt(key)) {
is Int -> value
is Number -> value.toInt()
is String -> value.toIntOrNull() ?: default
else -> default
}
}
/**
* 格式化文件大小的工具函数
*/
fun formatFileSize(bytes: Long): String {
if (bytes <= 0) return "0 KB"
val units = arrayOf("B", "KB", "MB", "GB", "TB")
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble())
) + " " + units[digitGroups]
}

View file

@ -0,0 +1,401 @@
package com.sukisu.ultra.ui.viewmodel
import android.content.*
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.graphics.drawable.Drawable
import android.os.IBinder
import android.os.Parcelable
import android.util.Log
import androidx.compose.runtime.*
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ksuApp
import com.sukisu.ultra.ui.KsuService
import com.sukisu.ultra.ui.util.*
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.text.Collator
import java.util.*
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import com.sukisu.zako.IKsuInterface
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
enum class AppCategory(val displayNameRes: Int, val persistKey: String) {
ALL(com.sukisu.ultra.R.string.category_all_apps, "ALL"),
ROOT(com.sukisu.ultra.R.string.category_root_apps, "ROOT"),
CUSTOM(com.sukisu.ultra.R.string.category_custom_apps, "CUSTOM"),
DEFAULT(com.sukisu.ultra.R.string.category_default_apps, "DEFAULT");
companion object {
fun fromPersistKey(key: String): AppCategory = entries.find { it.persistKey == key } ?: ALL
}
}
enum class SortType(val displayNameRes: Int, val persistKey: String) {
NAME_ASC(com.sukisu.ultra.R.string.sort_name_asc, "NAME_ASC"),
NAME_DESC(com.sukisu.ultra.R.string.sort_name_desc, "NAME_DESC"),
INSTALL_TIME_NEW(com.sukisu.ultra.R.string.sort_install_time_new, "INSTALL_TIME_NEW"),
INSTALL_TIME_OLD(com.sukisu.ultra.R.string.sort_install_time_old, "INSTALL_TIME_OLD"),
SIZE_DESC(com.sukisu.ultra.R.string.sort_size_desc, "SIZE_DESC"),
SIZE_ASC(com.sukisu.ultra.R.string.sort_size_asc, "SIZE_ASC"),
USAGE_FREQ(com.sukisu.ultra.R.string.sort_usage_freq, "USAGE_FREQ");
companion object {
fun fromPersistKey(key: String): SortType = entries.find { it.persistKey == key } ?: NAME_ASC
}
}
class SuperUserViewModel : ViewModel() {
companion object {
private const val TAG = "SuperUserViewModel"
private val appsLock = Any()
var apps by mutableStateOf<List<AppInfo>>(emptyList())
private val _isAppListLoaded = MutableStateFlow(false)
val isAppListLoaded = _isAppListLoaded.asStateFlow()
@JvmStatic
fun getAppIconDrawable(context: Context, packageName: String): Drawable? {
val appList = synchronized(appsLock) { apps }
return appList.find { it.packageName == packageName }
?.packageInfo?.applicationInfo?.loadIcon(context.packageManager)
}
var appGroups by mutableStateOf<List<AppGroup>>(emptyList())
private const val PREFS_NAME = "settings"
private const val KEY_SHOW_SYSTEM_APPS = "show_system_apps"
private const val KEY_SELECTED_CATEGORY = "selected_category"
private const val KEY_CURRENT_SORT_TYPE = "current_sort_type"
private const val CORE_POOL_SIZE = 8
private const val MAX_POOL_SIZE = 16
private const val KEEP_ALIVE_TIME = 60L
private const val BATCH_SIZE = 20
}
@Immutable
@Parcelize
data class AppInfo(
val label: String,
val packageInfo: PackageInfo,
val profile: Natives.Profile?,
) : Parcelable {
@IgnoredOnParcel
val packageName: String = packageInfo.packageName
@IgnoredOnParcel
val uid: Int = packageInfo.applicationInfo!!.uid
}
@Immutable
@Parcelize
data class AppGroup(
val uid: Int,
val apps: List<AppInfo>,
val profile: Natives.Profile?
) : Parcelable {
@IgnoredOnParcel
val mainApp: AppInfo = apps.first()
@IgnoredOnParcel
val packageNames: List<String> = apps.map { it.packageName }
@IgnoredOnParcel
val allowSu: Boolean = profile?.allowSu == true
@IgnoredOnParcel
val userName: String? = Natives.getUserName(uid)
@IgnoredOnParcel
val hasCustomProfile : Boolean = profile?.let { if (it.allowSu) !it.rootUseDefault else !it.nonRootUseDefault } ?: false
}
private val appProcessingThreadPool = ThreadPoolExecutor(
CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS,
LinkedBlockingQueue()
) { runnable ->
Thread(runnable, "AppProcessing-${System.currentTimeMillis()}").apply {
isDaemon = true
priority = Thread.NORM_PRIORITY
}
}.asCoroutineDispatcher()
private val appListMutex = Mutex()
private val configChangeListeners = mutableSetOf<(String) -> Unit>()
private val prefs = ksuApp.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
var search by mutableStateOf("")
var showSystemApps by mutableStateOf(prefs.getBoolean(KEY_SHOW_SYSTEM_APPS, false))
private set
var selectedCategory by mutableStateOf(loadSelectedCategory())
private set
var currentSortType by mutableStateOf(loadCurrentSortType())
private set
var isRefreshing by mutableStateOf(false)
private set
var showBatchActions by mutableStateOf(false)
internal set
var selectedApps by mutableStateOf<Set<String>>(emptySet())
internal set
var loadingProgress by mutableFloatStateOf(0f)
private set
private fun loadSelectedCategory(): AppCategory {
val categoryKey = prefs.getString(KEY_SELECTED_CATEGORY, AppCategory.ALL.persistKey)
?: AppCategory.ALL.persistKey
return AppCategory.fromPersistKey(categoryKey)
}
private fun loadCurrentSortType(): SortType {
val sortKey = prefs.getString(KEY_CURRENT_SORT_TYPE, SortType.NAME_ASC.persistKey)
?: SortType.NAME_ASC.persistKey
return SortType.fromPersistKey(sortKey)
}
fun updateShowSystemApps(newValue: Boolean) {
showSystemApps = newValue
prefs.edit { putBoolean(KEY_SHOW_SYSTEM_APPS, newValue) }
notifyAppListChanged()
}
private fun notifyAppListChanged() {
val currentApps = apps
apps = emptyList()
apps = currentApps
}
fun updateSelectedCategory(newCategory: AppCategory) {
selectedCategory = newCategory
prefs.edit { putString(KEY_SELECTED_CATEGORY, newCategory.persistKey) }
}
fun updateCurrentSortType(newSortType: SortType) {
currentSortType = newSortType
prefs.edit { putString(KEY_CURRENT_SORT_TYPE, newSortType.persistKey) }
}
fun toggleBatchMode() {
showBatchActions = !showBatchActions
if (!showBatchActions) clearSelection()
}
fun toggleAppSelection(packageName: String) {
selectedApps = if (selectedApps.contains(packageName)) {
selectedApps - packageName
} else {
selectedApps + packageName
}
}
fun clearSelection() {
selectedApps = emptySet()
}
suspend fun updateBatchPermissions(allowSu: Boolean, umountModules: Boolean? = null) {
selectedApps.forEach { packageName ->
apps.find { it.packageName == packageName }?.let { app ->
val profile = Natives.getAppProfile(packageName, app.uid)
val updatedProfile = profile.copy(
allowSu = allowSu,
umountModules = umountModules ?: profile.umountModules,
nonRootUseDefault = false
)
if (Natives.setAppProfile(updatedProfile)) {
updateAppProfileLocally(packageName, updatedProfile)
notifyConfigChange(packageName)
}
}
}
clearSelection()
showBatchActions = false
refreshAppConfigurations()
}
fun updateAppProfileLocally(packageName: String, updatedProfile: Natives.Profile) {
appListMutex.tryLock().let { locked ->
if (locked) {
try {
apps = apps.map { app ->
if (app.packageName == packageName) {
app.copy(profile = updatedProfile)
} else app
}
} finally {
appListMutex.unlock()
}
}
}
}
private fun notifyConfigChange(packageName: String) {
configChangeListeners.forEach { listener ->
try {
listener(packageName)
} catch (e: Exception) {
Log.e(TAG, "Error notifying config change for $packageName", e)
}
}
}
suspend fun refreshAppConfigurations() {
withContext(appProcessingThreadPool) {
supervisorScope {
val currentApps = apps.toList()
val batches = currentApps.chunked(BATCH_SIZE)
loadingProgress = 0f
val updatedApps = batches.mapIndexed { batchIndex, batch ->
async {
val batchResult = batch.map { app ->
try {
val updatedProfile = Natives.getAppProfile(app.packageName, app.uid)
app.copy(profile = updatedProfile)
} catch (e: Exception) {
Log.e(TAG, "Error refreshing profile for ${app.packageName}", e)
app
}
}
loadingProgress = (batchIndex + 1).toFloat() / batches.size
batchResult
}
}.awaitAll().flatten()
appListMutex.withLock { apps = updatedApps }
loadingProgress = 1f
}
}
}
private var serviceConnection: ServiceConnection? = null
private suspend fun connectKsuService(onDisconnect: () -> Unit = {}): IBinder? =
suspendCoroutine { continuation ->
val connection = object : ServiceConnection {
override fun onServiceDisconnected(name: ComponentName?) {
onDisconnect()
serviceConnection = null
}
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
continuation.resume(binder)
}
}
serviceConnection = connection
val intent = Intent(ksuApp, KsuService::class.java)
try {
val task = com.topjohnwu.superuser.ipc.RootService.bindOrTask(
intent, Shell.EXECUTOR, connection
)
task?.let { Shell.getShell().execTask(it) }
} catch (e: Exception) {
Log.e(TAG, "Failed to bind KsuService", e)
continuation.resume(null)
}
}
private fun stopKsuService() {
serviceConnection?.let {
try {
val intent = Intent(ksuApp, KsuService::class.java)
com.topjohnwu.superuser.ipc.RootService.stop(intent)
serviceConnection = null
} catch (e: Exception) {
Log.e(TAG, "Failed to stop KsuService", e)
}
}
}
suspend fun fetchAppList() {
isRefreshing = true
loadingProgress = 0f
val binder = connectKsuService() ?: run { isRefreshing = false; return }
withContext(Dispatchers.IO) {
val pm = ksuApp.packageManager
val allPackages = IKsuInterface.Stub.asInterface(binder)
val total = allPackages.packageCount
val pageSize = 100
val result = mutableListOf<AppInfo>()
var start = 0
while (start < total) {
val page = allPackages.getPackages(start, pageSize)
if (page.isEmpty()) break
result += page.mapNotNull { packageInfo ->
packageInfo.applicationInfo?.let { appInfo ->
AppInfo(
label = appInfo.loadLabel(pm).toString(),
packageInfo = packageInfo,
profile = Natives.getAppProfile(packageInfo.packageName, appInfo.uid)
)
}
}
start += page.size
loadingProgress = start.toFloat() / total
}
stopKsuService()
synchronized(appsLock) {
_isAppListLoaded.value = true
}
appListMutex.withLock {
val filteredApps = result.filter { it.packageName != ksuApp.packageName }
apps = filteredApps
appGroups = groupAppsByUid(filteredApps)
}
loadingProgress = 1f
}
isRefreshing = false
}
val appGroupList by derivedStateOf {
appGroups.filter { group ->
group.apps.any { app ->
app.label.contains(search, true) ||
app.packageName.contains(search, true) ||
HanziToPinyin.getInstance().toPinyinString(app.label)?.contains(search, true) == true
}
}.filter { group ->
group.uid == 2000 || showSystemApps ||
group.apps.any { it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0 }
}
}
private fun groupAppsByUid(appList: List<AppInfo>): List<AppGroup> {
return appList.groupBy { it.uid }
.map { (uid, apps) ->
val sortedApps = apps.sortedBy { it.label }
val profile = apps.firstOrNull()?.let { Natives.getAppProfile(it.packageName, uid) }
AppGroup(uid = uid, apps = sortedApps, profile = profile)
}
.sortedWith(
compareBy<AppGroup> {
when {
it.allowSu -> 0
it.hasCustomProfile -> 1
else -> 2
}
}.thenBy(Collator.getInstance(Locale.getDefault())) {
it.userName?.takeIf { name -> name.isNotBlank() } ?: it.uid.toString()
}.thenBy(Collator.getInstance(Locale.getDefault())) { it.mainApp.label }
)
}
override fun onCleared() {
super.onCleared()
try {
stopKsuService()
appProcessingThreadPool.close()
configChangeListeners.clear()
} catch (e: Exception) {
Log.e(TAG, "Error cleaning up resources", e)
}
}
}

View file

@ -0,0 +1,328 @@
package com.sukisu.ultra.ui.viewmodel
import android.os.Parcelable
import android.util.Log
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.sukisu.ultra.Natives
import com.sukisu.ultra.profile.Capabilities
import com.sukisu.ultra.profile.Groups
import com.sukisu.ultra.ui.util.getAppProfileTemplate
import com.sukisu.ultra.ui.util.listAppProfileTemplates
import com.sukisu.ultra.ui.util.setAppProfileTemplate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.text.Collator
import java.util.*
import java.util.concurrent.TimeUnit
/**
* @author weishu
* @date 2023/10/20.
*/
const val TEMPLATE_INDEX_URL = "https://kernelsu.org/templates/index.json"
const val TEMPLATE_URL = "https://kernelsu.org/templates/%s"
const val TAG = "TemplateViewModel"
class TemplateViewModel : ViewModel() {
companion object {
private var templates by mutableStateOf<List<TemplateInfo>>(emptyList())
}
@Parcelize
data class TemplateInfo(
val id: String = "",
val name: String = "",
val description: String = "",
val author: String = "",
val local: Boolean = true,
val namespace: Int = Natives.Profile.Namespace.INHERITED.ordinal,
val uid: Int = Natives.ROOT_UID,
val gid: Int = Natives.ROOT_GID,
val groups: List<Int> = mutableListOf(),
val capabilities: List<Int> = mutableListOf(),
val context: String = Natives.KERNEL_SU_DOMAIN,
val rules: List<String> = mutableListOf(),
) : Parcelable
var isRefreshing by mutableStateOf(false)
private set
val templateList by derivedStateOf {
val comparator = compareBy(TemplateInfo::local).reversed().then(
compareBy(
Collator.getInstance(Locale.getDefault()), TemplateInfo::id
)
)
templates.sortedWith(comparator).apply {
isRefreshing = false
}
}
suspend fun fetchTemplates(sync: Boolean = false) {
isRefreshing = true
withContext(Dispatchers.IO) {
val localTemplateIds = listAppProfileTemplates()
Log.i(TAG, "localTemplateIds: $localTemplateIds")
if (localTemplateIds.isEmpty() || sync) {
// if no templates, fetch remote templates
fetchRemoteTemplates()
}
// fetch templates again
templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById)
isRefreshing = false
}
}
suspend fun importTemplates(
templates: String,
onSuccess: suspend () -> Unit,
onFailure: suspend (String) -> Unit
) {
withContext(Dispatchers.IO) {
runCatching {
JSONArray(templates)
}.getOrElse {
runCatching {
val json = JSONObject(templates)
JSONArray().apply { put(json) }
}.getOrElse {
onFailure("invalid templates: $templates")
return@withContext
}
}.let {
0.until(it.length()).forEach { i ->
runCatching {
val template = it.getJSONObject(i)
val id = template.getString("id")
template.put("local", true)
setAppProfileTemplate(id, template.toString())
}.onFailure { e ->
Log.e(TAG, "ignore invalid template: $it", e)
}
}
onSuccess()
}
}
}
suspend fun exportTemplates(onTemplateEmpty: () -> Unit, callback: (String) -> Unit) {
withContext(Dispatchers.IO) {
val templates = listAppProfileTemplates().mapNotNull(::getTemplateInfoById).filter {
it.local
}
templates.ifEmpty {
onTemplateEmpty()
return@withContext
}
JSONArray(templates.map {
it.toJSON()
}).toString().let(callback)
}
}
}
private fun fetchRemoteTemplates() {
runCatching {
val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
client.newCall(
Request.Builder().url(TEMPLATE_INDEX_URL).build()
).execute().use { response ->
if (!response.isSuccessful) {
return
}
val remoteTemplateIds = JSONArray(response.body!!.string())
Log.i(TAG, "fetchRemoteTemplates: $remoteTemplateIds")
0.until(remoteTemplateIds.length()).forEach { i ->
val id = remoteTemplateIds.getString(i)
Log.i(TAG, "fetch template: $id")
val templateJson = client.newCall(
Request.Builder().url(TEMPLATE_URL.format(id)).build()
).runCatching {
execute().use { response ->
if (!response.isSuccessful) {
return@forEach
}
response.body!!.string()
}
}.getOrNull() ?: return@forEach
Log.i(TAG, "template: $templateJson")
// validate remote template
runCatching {
val json = JSONObject(templateJson)
fromJSON(json)?.let {
// force local template
json.put("local", false)
setAppProfileTemplate(id, json.toString())
}
}.onFailure {
Log.e(TAG, "ignore invalid template: $it", it)
return@forEach
}
}
}
}.onFailure { Log.e(TAG, "fetchRemoteTemplates: $it", it) }
}
@Suppress("UNCHECKED_CAST")
private fun <T, R> JSONArray.mapCatching(
transform: (T) -> R, onFail: (Throwable) -> Unit
): List<R> {
return List(length()) { i -> get(i) as T }.mapNotNull { element ->
runCatching {
transform(element)
}.onFailure(onFail).getOrNull()
}
}
private inline fun <reified T : Enum<T>> getEnumOrdinals(
jsonArray: JSONArray?, enumClass: Class<T>
): List<T> {
return jsonArray?.mapCatching<String, T>({ name ->
enumValueOf(name.uppercase())
}, {
Log.e(TAG, "ignore invalid enum ${enumClass.simpleName}: $it", it)
}).orEmpty()
}
fun getTemplateInfoById(id: String): TemplateViewModel.TemplateInfo? {
return runCatching {
fromJSON(JSONObject(getAppProfileTemplate(id)))
}.onFailure {
Log.e(TAG, "ignore invalid template: $it", it)
}.getOrNull()
}
private fun getLocaleString(json: JSONObject, key: String): String {
val fallback = json.getString(key)
val locale = Locale.getDefault()
val localeKey = "${locale.language}_${locale.country}"
json.optJSONObject("locales")?.let {
// check locale first
it.optJSONObject(localeKey)?.let { json->
return json.optString(key, fallback)
}
// fallback to language
it.optJSONObject(locale.language)?.let { json->
return json.optString(key, fallback)
}
}
return fallback
}
private fun fromJSON(templateJson: JSONObject): TemplateViewModel.TemplateInfo? {
return runCatching {
val groupsJsonArray = templateJson.optJSONArray("groups")
val capabilitiesJsonArray = templateJson.optJSONArray("capabilities")
val context = templateJson.optString("context").takeIf { it.isNotEmpty() }
?: Natives.KERNEL_SU_DOMAIN
val namespace = templateJson.optString("namespace").takeIf { it.isNotEmpty() }
?: Natives.Profile.Namespace.INHERITED.name
val rulesJsonArray = templateJson.optJSONArray("rules")
val templateInfo = TemplateViewModel.TemplateInfo(
id = templateJson.getString("id"),
name = getLocaleString(templateJson, "name"),
description = getLocaleString(templateJson, "description"),
author = templateJson.optString("author"),
local = templateJson.optBoolean("local"),
namespace = Natives.Profile.Namespace.valueOf(
namespace.uppercase()
).ordinal,
uid = templateJson.optInt("uid", Natives.ROOT_UID),
gid = templateJson.optInt("gid", Natives.ROOT_GID),
groups = getEnumOrdinals(groupsJsonArray, Groups::class.java).map { it.gid },
capabilities = getEnumOrdinals(
capabilitiesJsonArray, Capabilities::class.java
).map { it.cap },
context = context,
rules = rulesJsonArray?.mapCatching<String, String>({ it }, {
Log.e(TAG, "ignore invalid rule: $it", it)
}).orEmpty()
)
templateInfo
}.onFailure {
Log.e(TAG, "ignore invalid template: $it", it)
}.getOrNull()
}
fun TemplateViewModel.TemplateInfo.toJSON(): JSONObject {
val template = this
return JSONObject().apply {
put("id", template.id)
put("name", template.name.ifBlank { template.id })
put("description", template.description.ifBlank { template.id })
if (template.author.isNotEmpty()) {
put("author", template.author)
}
put("namespace", Natives.Profile.Namespace.entries[template.namespace].name)
put("uid", template.uid)
put("gid", template.gid)
if (template.groups.isNotEmpty()) {
put("groups", JSONArray(
Groups.entries.filter {
template.groups.contains(it.gid)
}.map {
it.name
}
))
}
if (template.capabilities.isNotEmpty()) {
put("capabilities", JSONArray(
Capabilities.entries.filter {
template.capabilities.contains(it.cap)
}.map {
it.name
}
))
}
if (template.context.isNotEmpty()) {
put("context", template.context)
}
if (template.rules.isNotEmpty()) {
put("rules", JSONArray(template.rules))
}
}
}
@Suppress("unused")
fun generateTemplates() {
val templateJson = JSONObject()
templateJson.put("id", "com.example")
templateJson.put("name", "Example")
templateJson.put("description", "This is an example template")
templateJson.put("local", true)
templateJson.put("namespace", Natives.Profile.Namespace.INHERITED.name)
templateJson.put("uid", 0)
templateJson.put("gid", 0)
templateJson.put("groups", JSONArray().apply { put(Groups.INET.name) })
templateJson.put("capabilities", JSONArray().apply { put(Capabilities.CAP_NET_RAW.name) })
templateJson.put("context", "u:r:su:s0")
Log.i(TAG, "$templateJson")
}

View file

@ -0,0 +1,46 @@
package com.sukisu.ultra.ui.webui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.LruCache
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel.Companion.getAppIconDrawable
object AppIconUtil {
// Limit cache size to 200 icons
private const val CACHE_SIZE = 200
private val iconCache = LruCache<String?, Bitmap?>(CACHE_SIZE)
@Synchronized
fun loadAppIconSync(context: Context, packageName: String, sizePx: Int): Bitmap? {
val cached = iconCache.get(packageName)
if (cached != null) return cached
try {
val drawable = getAppIconDrawable(context, packageName) ?: return null
val raw = drawableToBitmap(drawable, sizePx)
val icon = raw.scale(sizePx, sizePx)
iconCache.put(packageName, icon)
return icon
} catch (_: Exception) {
return null
}
}
private fun drawableToBitmap(drawable: Drawable, size: Int): Bitmap {
if (drawable is BitmapDrawable) return drawable.bitmap
val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else size
val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else size
val bmp = createBitmap(width, height)
val canvas = Canvas(bmp)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bmp
}
}

View file

@ -0,0 +1,40 @@
package com.sukisu.ultra.ui.webui
/**
* Insets data class from GitHub@MMRLApp/WebUI-X-Portable
*
* Data class representing insets (top, bottom, left, right) for a view.
*
* This class provides methods to generate CSS code that can be injected into a WebView
* to apply these insets as CSS variables. This is useful for adapting web content
* to the safe areas of a device screen, considering notches, status bars, and navigation bars.
*
* @property top The top inset value in pixels.
* @property bottom The bottom inset value in pixels.
* @property left The left inset value in pixels.
* @property right The right inset value in pixels.
*/
data class Insets(
val top: Int,
val bottom: Int,
val left: Int,
val right: Int,
) {
val css
get() = buildString {
appendLine(":root {")
appendLine("\t--safe-area-inset-top: ${top}px;")
appendLine("\t--safe-area-inset-right: ${right}px;")
appendLine("\t--safe-area-inset-bottom: ${bottom}px;")
appendLine("\t--safe-area-inset-left: ${left}px;")
appendLine("\t--window-inset-top: var(--safe-area-inset-top, 0px);")
appendLine("\t--window-inset-bottom: var(--safe-area-inset-bottom, 0px);")
appendLine("\t--window-inset-left: var(--safe-area-inset-left, 0px);")
appendLine("\t--window-inset-right: var(--safe-area-inset-right, 0px);")
appendLine("\t--f7-safe-area-top: var(--window-inset-top, 0px) !important;")
appendLine("\t--f7-safe-area-bottom: var(--window-inset-bottom, 0px) !important;")
appendLine("\t--f7-safe-area-left: var(--window-inset-left, 0px) !important;")
appendLine("\t--f7-safe-area-right: var(--window-inset-right, 0px) !important;")
append("}")
}
}

View file

@ -0,0 +1,56 @@
package com.sukisu.ultra.ui.webui
import android.content.ServiceConnection
import android.util.Log
import com.dergoogler.mmrl.platform.Platform
import com.dergoogler.mmrl.platform.model.IProvider
import com.dergoogler.mmrl.platform.model.PlatformIntent
import com.sukisu.ultra.Natives
import com.sukisu.ultra.ksuApp
import com.topjohnwu.superuser.ipc.RootService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
class KsuLibSuProvider : IProvider {
override val name = "KsuLibSu"
override fun isAvailable() = true
override suspend fun isAuthorized() = Natives.isManager
private val serviceIntent
get() = PlatformIntent(
ksuApp,
Platform.KsuNext,
SuService::class.java
)
override fun bind(connection: ServiceConnection) {
RootService.bind(serviceIntent.intent, connection)
}
override fun unbind(connection: ServiceConnection) {
RootService.stop(serviceIntent.intent)
}
}
// webui x
suspend fun initPlatform() = withContext(Dispatchers.IO) {
try {
val active = Platform.init {
this.context = ksuApp
this.platform = Platform.KsuNext
this.provider = from(KsuLibSuProvider())
}
while (!active) {
delay(1000)
}
return@withContext true
} catch (e: Exception) {
Log.e("KsuLibSu", "Failed to initialize platform", e)
return@withContext false
}
}

View file

@ -0,0 +1,77 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.sukisu.ultra.ui.webui
import java.net.URLConnection
internal object MimeUtil {
fun getMimeFromFileName(fileName: String?): String? {
if (fileName == null) {
return null
}
val mimeType = URLConnection.guessContentTypeFromName(fileName)
if (mimeType != null) {
return mimeType
}
return guessHardcodedMime(fileName)
}
private fun guessHardcodedMime(fileName: String): String? {
val finalFullStop = fileName.lastIndexOf('.')
if (finalFullStop == -1) {
return null
}
val extension = fileName.substring(finalFullStop + 1).lowercase()
return when (extension) {
"webm" -> "video/webm"
"mpeg", "mpg" -> "video/mpeg"
"mp3" -> "audio/mpeg"
"wasm" -> "application/wasm"
"xhtml", "xht", "xhtm" -> "application/xhtml+xml"
"flac" -> "audio/flac"
"ogg", "oga", "opus" -> "audio/ogg"
"wav" -> "audio/wav"
"m4a" -> "audio/x-m4a"
"gif" -> "image/gif"
"jpeg", "jpg", "jfif", "pjpeg", "pjp" -> "image/jpeg"
"png" -> "image/png"
"apng" -> "image/apng"
"svg", "svgz" -> "image/svg+xml"
"webp" -> "image/webp"
"mht", "mhtml" -> "multipart/related"
"css" -> "text/css"
"html", "htm", "shtml", "shtm", "ehtml" -> "text/html"
"js", "mjs" -> "application/javascript"
"xml" -> "text/xml"
"mp4", "m4v" -> "video/mp4"
"ogv", "ogm" -> "video/ogg"
"ico" -> "image/x-icon"
"woff" -> "application/font-woff"
"gz", "tgz" -> "application/gzip"
"json" -> "application/json"
"pdf" -> "application/pdf"
"zip" -> "application/zip"
"bmp" -> "image/bmp"
"tiff", "tif" -> "image/tiff"
else -> null
}
}
}

View file

@ -0,0 +1,192 @@
package com.sukisu.ultra.ui.webui
import android.content.Context
import android.util.Log
import android.webkit.WebResourceResponse
import androidx.annotation.WorkerThread
import androidx.webkit.WebViewAssetLoader
import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.io.SuFile
import com.topjohnwu.superuser.io.SuFileInputStream
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.charset.StandardCharsets
import java.util.zip.GZIPInputStream
/**
* Handler class to open files from file system by root access
* For more information about android storage please refer to
* [Android Developers Docs: Data and file storage overview](https://developer.android.com/guide/topics/data/data-storage).
*
* To avoid leaking user or app data to the web, make sure to choose [directory]
* carefully, and assume any file under this directory could be accessed by any web page subject
* to same-origin rules.
*
* A typical usage would be like:
* ```
* val publicDir = File(context.filesDir, "public")
* // Host "files/public/" in app's data directory under:
* // http://appassets.androidplatform.net/public/...
* val assetLoader = WebViewAssetLoader.Builder()
* .addPathHandler("/public/", SuFilePathHandler(context, publicDir, shell, insetsSupplier))
* .build()
* ```
*/
class SuFilePathHandler(
directory: File,
private val shell: Shell,
private val insetsSupplier: InsetsSupplier
) : WebViewAssetLoader.PathHandler {
private val directory: File
init {
try {
this.directory = File(getCanonicalDirPath(directory))
if (!isAllowedInternalStorageDir()) {
throw IllegalArgumentException(
"The given directory \"$directory\" doesn't exist under an allowed app internal storage directory"
)
}
} catch (e: IOException) {
throw IllegalArgumentException(
"Failed to resolve the canonical path for the given directory: ${directory.path}",
e
)
}
}
fun interface InsetsSupplier {
fun get(): Insets
}
private fun isAllowedInternalStorageDir(): Boolean {
return try {
val dir = getCanonicalDirPath(directory)
FORBIDDEN_DATA_DIRS.none { dir.startsWith(it) }
} catch (_: IOException) {
false
}
}
/**
* Opens the requested file from the exposed data directory.
*
* The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
* requested file cannot be found or is outside the mounted directory a
* [WebResourceResponse] object with a `null` [InputStream] will be
* returned instead of `null`. This saves the time of falling back to network and
* trying to resolve a path that doesn't exist. A [WebResourceResponse] with
* `null` [InputStream] will be received as an HTTP response with status code
* `404` and no body.
*
* The MIME type for the file will be determined from the file's extension using
* [java.net.URLConnection.guessContentTypeFromName]. Developers should ensure that
* files are named using standard file extensions. If the file does not have a
* recognised extension, `"text/plain"` will be used by default.
*
* @param path the suffix path to be handled.
* @return [WebResourceResponse] for the requested file.
*/
@WorkerThread
override fun handle(path: String): WebResourceResponse {
if (path == "internal/insets.css") {
val css = insetsSupplier.get().css
return WebResourceResponse(
"text/css",
"utf-8",
ByteArrayInputStream(css.toByteArray(StandardCharsets.UTF_8))
)
}
try {
val file = getCanonicalFileIfChild(directory, path)
if (file != null) {
val inputStream = openFile(file, shell)
val mimeType = guessMimeType(path)
return WebResourceResponse(mimeType, null, inputStream)
} else {
Log.e(
TAG,
"The requested file: $path is outside the mounted directory: $directory"
)
}
} catch (e: IOException) {
Log.e(TAG, "Error opening the requested path: $path", e)
}
return WebResourceResponse(null, null, null)
}
companion object {
private const val TAG = "SuFilePathHandler"
/**
* Default value to be used as MIME type if guessing MIME type failed.
*/
const val DEFAULT_MIME_TYPE = "text/plain"
/**
* Forbidden subdirectories of [Context.getDataDir] that cannot be exposed by this
* handler. They are forbidden as they often contain sensitive information.
*
* Note: Any future addition to this list will be considered breaking changes to the API.
*/
private val FORBIDDEN_DATA_DIRS = arrayOf("/data/data", "/data/system")
@JvmStatic
@Throws(IOException::class)
fun getCanonicalDirPath(file: File): String {
var canonicalPath = file.canonicalPath
if (!canonicalPath.endsWith("/")) {
canonicalPath += "/"
}
return canonicalPath
}
@JvmStatic
@Throws(IOException::class)
fun getCanonicalFileIfChild(parent: File, child: String): File? {
val parentCanonicalPath = getCanonicalDirPath(parent)
val childCanonicalPath = File(parent, child).canonicalPath
return if (childCanonicalPath.startsWith(parentCanonicalPath)) {
File(childCanonicalPath)
} else {
null
}
}
@Throws(IOException::class)
private fun handleSvgzStream(path: String, stream: InputStream): InputStream {
return if (path.endsWith(".svgz")) {
GZIPInputStream(stream)
} else {
stream
}
}
@JvmStatic
@Throws(IOException::class)
fun openFile(file: File, shell: Shell): InputStream {
val suFile = SuFile(file.absolutePath).apply {
setShell(shell)
}
val fis = SuFileInputStream.open(suFile)
return handleSvgzStream(file.path, fis)
}
/**
* Use [MimeUtil.getMimeFromFileName] to guess MIME type or return the
* [DEFAULT_MIME_TYPE] if it can't guess.
*
* @param filePath path of the file to guess its MIME type.
* @return MIME type guessed from file extension or [DEFAULT_MIME_TYPE].
*/
@JvmStatic
fun guessMimeType(filePath: String): String {
return MimeUtil.getMimeFromFileName(filePath) ?: DEFAULT_MIME_TYPE
}
}
}

View file

@ -0,0 +1,14 @@
package com.sukisu.ultra.ui.webui
import android.content.Intent
import android.os.IBinder
import com.dergoogler.mmrl.platform.model.PlatformIntent.Companion.getPlatform
import com.dergoogler.mmrl.platform.service.ServiceManager
import com.topjohnwu.superuser.ipc.RootService
class SuService : RootService() {
override fun onBind(intent: Intent): IBinder {
val mode = intent.getPlatform()
return ServiceManager(mode)
}
}

View file

@ -0,0 +1,149 @@
package com.sukisu.ultra.ui.webui
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebViewAssetLoader
import com.dergoogler.mmrl.platform.model.ModId
import com.dergoogler.mmrl.webui.interfaces.WXOptions
import com.sukisu.ultra.ui.util.createRootShell
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.io.File
@SuppressLint("SetJavaScriptEnabled")
class WebUIActivity : ComponentActivity() {
private val rootShell by lazy { createRootShell(true) }
private lateinit var insets: Insets
private var webView = null as WebView?
override fun onCreate(savedInstanceState: Bundle?) {
// Enable edge to edge
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
super.onCreate(savedInstanceState)
setContent {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
lifecycleScope.launch {
SuperUserViewModel.isAppListLoaded.first { it }
setupWebView()
}
}
private fun setupWebView() {
val moduleId = intent.getStringExtra("id") ?: finishAndRemoveTask().let { return }
val name = intent.getStringExtra("name") ?: finishAndRemoveTask().let { return }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@Suppress("DEPRECATION")
setTaskDescription(ActivityManager.TaskDescription("SukiSU-Ultra - $name"))
} else {
val taskDescription =
ActivityManager.TaskDescription.Builder().setLabel("SukiSU-Ultra - $name").build()
setTaskDescription(taskDescription)
}
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
WebView.setWebContentsDebuggingEnabled(prefs.getBoolean("enable_web_debugging", false))
val moduleDir = "/data/adb/modules/${moduleId}"
val webRoot = File("${moduleDir}/webroot")
insets = Insets(0, 0, 0, 0)
val webViewAssetLoader = WebViewAssetLoader.Builder()
.setDomain("mui.kernelsu.org")
.addPathHandler(
"/",
SuFilePathHandler(webRoot, rootShell) { insets }
)
.build()
val webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? {
val url = request.url
// Handle ksu://icon/[packageName] to serve app icon via WebView
if (url.scheme.equals("ksu", ignoreCase = true) && url.host.equals("icon", ignoreCase = true)) {
val packageName = url.path?.substring(1)
if (!packageName.isNullOrEmpty()) {
val icon = AppIconUtil.loadAppIconSync(this@WebUIActivity, packageName, 512)
if (icon != null) {
val stream = java.io.ByteArrayOutputStream()
icon.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream)
val inputStream = java.io.ByteArrayInputStream(stream.toByteArray())
return WebResourceResponse("image/png", null, inputStream)
}
}
}
return webViewAssetLoader.shouldInterceptRequest(url)
}
}
val webView = WebView(this).apply {
webView = this
setBackgroundColor(Color.TRANSPARENT)
val density = resources.displayMetrics.density
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets ->
val inset = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
insets = Insets(
top = (inset.top / density).toInt(),
bottom = (inset.bottom / density).toInt(),
left = (inset.left / density).toInt(),
right = (inset.right / density).toInt()
)
WindowInsetsCompat.CONSUMED
}
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.allowFileAccess = false
addJavascriptInterface(WebViewInterface(WXOptions(this@WebUIActivity, this, ModId(moduleId))), "ksu")
setWebViewClient(webViewClient)
loadUrl("https://mui.kernelsu.org/index.html")
}
setContentView(webView)
}
override fun onDestroy() {
rootShell.runCatching { close() }
webView?.apply {
stopLoading()
removeAllViews()
destroy()
webView = null
}
super.onDestroy()
}
}

View file

@ -0,0 +1,116 @@
package com.sukisu.ultra.ui.webui
import android.app.ActivityManager
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.*
import androidx.lifecycle.lifecycleScope
import com.dergoogler.mmrl.platform.Platform
import com.dergoogler.mmrl.platform.model.ModId
import com.dergoogler.mmrl.ui.component.Loading
import com.dergoogler.mmrl.webui.model.WebUIConfig
import com.dergoogler.mmrl.webui.screen.WebUIScreen
import com.dergoogler.mmrl.webui.util.rememberWebUIOptions
import com.sukisu.ultra.BuildConfig
import com.sukisu.ultra.ui.theme.KernelSUTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class WebUIXActivity : ComponentActivity() {
private lateinit var webView: WebView
private val userAgent
get(): String {
val ksuVersion = BuildConfig.VERSION_CODE
val platform = Platform.get("Unknown") {
platform.name
}
val platformVersion = Platform.get(-1) {
moduleManager.versionCode
}
val osVersion = Build.VERSION.RELEASE
val deviceModel = Build.MODEL
return "SukiSU-Ultra /$ksuVersion (Linux; Android $osVersion; $deviceModel; $platform/$platformVersion)"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
webView = WebView(this)
lifecycleScope.launch {
initPlatform()
}
val moduleId = intent.getStringExtra("id")!!
val name = intent.getStringExtra("name")!!
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@Suppress("DEPRECATION")
setTaskDescription(ActivityManager.TaskDescription("SukiSU-Ultra - $name"))
} else {
val taskDescription =
ActivityManager.TaskDescription.Builder().setLabel("SukiSU-Ultra - $name").build()
setTaskDescription(taskDescription)
}
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
setContent {
KernelSUTheme {
var isLoading by remember { mutableStateOf(true) }
LaunchedEffect(Platform.isAlive) {
while (!Platform.isAlive) {
delay(1000)
}
isLoading = false
}
if (isLoading) {
Loading()
return@KernelSUTheme
}
val webDebugging = prefs.getBoolean("enable_web_debugging", false)
val erudaInject = prefs.getBoolean("use_webuix_eruda", false)
val dark = isSystemInDarkTheme()
val options = rememberWebUIOptions(
modId = ModId(moduleId),
debug = webDebugging,
appVersionCode = BuildConfig.VERSION_CODE,
isDarkMode = dark,
enableEruda = erudaInject,
cls = WebUIXActivity::class.java,
userAgentString = userAgent
)
// idk why webuix not allow root impl change webuiConfig
// so we use magic to force exitConfirm shutdown
val field = WebUIConfig::class.java.getDeclaredField("exitConfirm")
field.isAccessible = true
field.set(options.config, false)
field.isAccessible = false
WebUIScreen(
webView = webView,
options = options,
interfaces = listOf(
WebViewInterface.factory()
)
)
}
}
}
}

View file

@ -0,0 +1,279 @@
package com.sukisu.ultra.ui.webui
import android.app.Activity
import android.content.pm.ApplicationInfo
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.view.Window
import android.webkit.JavascriptInterface
import android.widget.Toast
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.dergoogler.mmrl.webui.interfaces.WXInterface
import com.dergoogler.mmrl.webui.interfaces.WXOptions
import com.dergoogler.mmrl.webui.model.JavaScriptInterface
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import com.sukisu.ultra.ui.util.*
import com.topjohnwu.superuser.CallbackList
import com.topjohnwu.superuser.ShellUtils
import com.topjohnwu.superuser.internal.UiThreadHandler
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.concurrent.CompletableFuture
@Suppress("unused")
class WebViewInterface(
wxOptions: WXOptions,
) : WXInterface(wxOptions) {
override var name: String = "ksu"
companion object {
fun factory() = JavaScriptInterface(WebViewInterface::class.java)
}
private val modDir get() = "/data/adb/modules/${modId.id}"
@JavascriptInterface
fun exec(cmd: String): String {
return withNewRootShell(true) { ShellUtils.fastCmd(this, cmd) }
}
@JavascriptInterface
fun exec(cmd: String, callbackFunc: String) {
exec(cmd, null, callbackFunc)
}
private fun processOptions(sb: StringBuilder, options: String?) {
val opts = if (options == null) JSONObject() else {
JSONObject(options)
}
val cwd = opts.optString("cwd")
if (!TextUtils.isEmpty(cwd)) {
sb.append("cd ${cwd};")
}
opts.optJSONObject("env")?.let { env ->
env.keys().forEach { key ->
sb.append("export ${key}=${env.getString(key)};")
}
}
}
@JavascriptInterface
fun exec(
cmd: String,
options: String?,
callbackFunc: String
) {
val finalCommand = buildString {
processOptions(this, options)
append(cmd)
}
val result = withNewRootShell(true) {
newJob().add(finalCommand).to(ArrayList(), ArrayList()).exec()
}
val stdout = result.out.joinToString(separator = "\n")
val stderr = result.err.joinToString(separator = "\n")
val jsCode =
"(function() { try { ${callbackFunc}(${result.code}, ${
JSONObject.quote(
stdout
)
}, ${JSONObject.quote(stderr)}); } catch(e) { console.error(e); } })();"
webView.post {
webView.evaluateJavascript(jsCode, null)
}
}
@JavascriptInterface
fun spawn(command: String, args: String, options: String?, callbackFunc: String) {
val finalCommand = buildString {
processOptions(this, options)
if (!TextUtils.isEmpty(args)) {
append(command).append(" ")
JSONArray(args).let { argsArray ->
for (i in 0 until argsArray.length()) {
append("${argsArray.getString(i)} ")
}
}
} else {
append(command)
}
}
val shell = createRootShell(true)
val emitData = fun(name: String, data: String) {
val jsCode =
"(function() { try { ${callbackFunc}.${name}.emit('data', ${
JSONObject.quote(
data
)
}); } catch(e) { console.error('emitData', e); } })();"
webView.post {
webView.evaluateJavascript(jsCode, null)
}
}
val stdout = object : CallbackList<String>(UiThreadHandler::runAndWait) {
override fun onAddElement(s: String) {
emitData("stdout", s)
}
}
val stderr = object : CallbackList<String>(UiThreadHandler::runAndWait) {
override fun onAddElement(s: String) {
emitData("stderr", s)
}
}
val future = shell.newJob().add(finalCommand).to(stdout, stderr).enqueue()
val completableFuture = CompletableFuture.supplyAsync {
future.get()
}
completableFuture.thenAccept { result ->
val emitExitCode =
$$"(function() { try { $${callbackFunc}.emit('exit', $${result.code}); } catch(e) { console.error(`emitExit error: ${e}`); } })();"
webView.post {
webView.evaluateJavascript(emitExitCode, null)
}
if (result.code != 0) {
val emitErrCode =
"(function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${
JSONObject.quote(
result.err.joinToString(
"\n"
)
)
};${callbackFunc}.emit('error', err); } catch(e) { console.error('emitErr', e); } })();"
webView.post {
webView.evaluateJavascript(emitErrCode, null)
}
}
}.whenComplete { _, _ ->
runCatching { shell.close() }
}
}
@JavascriptInterface
fun toast(msg: String) {
webView.post {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
@JavascriptInterface
fun fullScreen(enable: Boolean) {
if (context is Activity) {
Handler(Looper.getMainLooper()).post {
if (enable) {
hideSystemUI(activity.window)
} else {
showSystemUI(activity.window)
}
}
}
}
@JavascriptInterface
fun moduleInfo(): String {
val moduleInfos = JSONArray(listModules())
val currentModuleInfo = JSONObject()
currentModuleInfo.put("moduleDir", modDir)
val moduleId = File(modDir).getName()
for (i in 0 until moduleInfos.length()) {
val currentInfo = moduleInfos.getJSONObject(i)
if (currentInfo.getString("id") != moduleId) {
continue
}
val keys = currentInfo.keys()
for (key in keys) {
currentModuleInfo.put(key, currentInfo.get(key))
}
break
}
return currentModuleInfo.toString()
}
@JavascriptInterface
fun listPackages(type: String): String {
val packageNames = SuperUserViewModel.apps
.filter { appInfo ->
val flags = appInfo.packageInfo.applicationInfo?.flags ?: 0
when (type.lowercase()) {
"system" -> (flags and ApplicationInfo.FLAG_SYSTEM) != 0
"user" -> (flags and ApplicationInfo.FLAG_SYSTEM) == 0
else -> true
}
}
.map { it.packageName }
.sorted()
val jsonArray = JSONArray()
for (pkgName in packageNames) {
jsonArray.put(pkgName)
}
return jsonArray.toString()
}
@JavascriptInterface
fun getPackagesInfo(packageNamesJson: String): String {
val packageNames = JSONArray(packageNamesJson)
val jsonArray = JSONArray()
val appMap = SuperUserViewModel.apps.associateBy { it.packageName }
for (i in 0 until packageNames.length()) {
val pkgName = packageNames.getString(i)
val appInfo = appMap[pkgName]
if (appInfo != null) {
val pkg = appInfo.packageInfo
val app = pkg.applicationInfo
val obj = JSONObject()
obj.put("packageName", pkg.packageName)
obj.put("versionName", pkg.versionName ?: "")
obj.put("versionCode", PackageInfoCompat.getLongVersionCode(pkg))
obj.put("appLabel", appInfo.label)
obj.put("isSystem", if (app != null) ((app.flags and ApplicationInfo.FLAG_SYSTEM) != 0) else JSONObject.NULL)
obj.put("uid", app?.uid ?: JSONObject.NULL)
jsonArray.put(obj)
} else {
val obj = JSONObject()
obj.put("packageName", pkgName)
obj.put("error", "Package not found or inaccessible")
jsonArray.put(obj)
}
}
return jsonArray.toString()
}
// =================== KPM支持 =============================
@JavascriptInterface
fun listAllKpm(): String {
return listKpmModules()
}
@JavascriptInterface
fun controlKpm(name: String, args: String): Int {
return controlKpmModule(name, args)
}
}
fun hideSystemUI(window: Window) =
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
fun showSystemUI(window: Window) =
WindowInsetsControllerCompat(window, window.decorView).show(WindowInsetsCompat.Type.systemBars())

View file

@ -0,0 +1,26 @@
package com.sukisu.ultra.utils
import android.content.Context
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
object AssetsUtil {
@Throws(IOException::class)
fun exportFiles(context: Context, src: String, out: String) {
val fileNames = context.assets.list(src)
if (fileNames?.isNotEmpty() == true) {
val file = File(out)
file.mkdirs()
fileNames.forEach { fileName ->
exportFiles(context, "$src/$fileName", "$out/$fileName")
}
} else {
context.assets.open(src).use { inputStream ->
FileOutputStream(File(out)).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
}
}

View file

@ -0,0 +1,475 @@
package zako.zako.zako.zakoui.screen.kernelFlash
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.KeyEventBlocker
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.util.LocalSnackbarHost
import com.sukisu.ultra.ui.util.reboot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import zako.zako.zako.zakoui.screen.kernelFlash.state.FlashState
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelState
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelWorker
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* @author ShirkNeko
* @date 2025/5/31.
*/
private object KernelFlashStateHolder {
var currentState: HorizonKernelState? = null
var currentUri: Uri? = null
var currentSlot: String? = null
var currentKpmPatchEnabled: Boolean = false
var currentKpmUndoPatch: Boolean = false
var isFlashing = false
}
/**
* Kernel刷写界面
*/
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun KernelFlashScreen(
navigator: DestinationsNavigator,
kernelUri: Uri,
selectedSlot: String? = null,
kpmPatchEnabled: Boolean = false,
kpmUndoPatch: Boolean = false
) {
val context = LocalContext.current
val shouldAutoExit = remember {
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.getBoolean("auto_exit_after_flash", false)
}
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val snackBarHost = LocalSnackbarHost.current
val scope = rememberCoroutineScope()
var logText by rememberSaveable { mutableStateOf("") }
var showFloatAction by rememberSaveable { mutableStateOf(false) }
val logContent = rememberSaveable { StringBuilder() }
val horizonKernelState = remember {
if (KernelFlashStateHolder.currentState != null &&
KernelFlashStateHolder.currentUri == kernelUri &&
KernelFlashStateHolder.currentSlot == selectedSlot &&
KernelFlashStateHolder.currentKpmPatchEnabled == kpmPatchEnabled &&
KernelFlashStateHolder.currentKpmUndoPatch == kpmUndoPatch) {
KernelFlashStateHolder.currentState!!
} else {
HorizonKernelState().also {
KernelFlashStateHolder.currentState = it
KernelFlashStateHolder.currentUri = kernelUri
KernelFlashStateHolder.currentSlot = selectedSlot
KernelFlashStateHolder.currentKpmPatchEnabled = kpmPatchEnabled
KernelFlashStateHolder.currentKpmUndoPatch = kpmUndoPatch
KernelFlashStateHolder.isFlashing = false
}
}
}
val flashState by horizonKernelState.state.collectAsState()
val logSavedString = stringResource(R.string.log_saved)
val onFlashComplete = {
showFloatAction = true
KernelFlashStateHolder.isFlashing = false
// 如果需要自动退出延迟1.5秒后退出
if (shouldAutoExit) {
scope.launch {
delay(1500)
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
sharedPref.edit { remove("auto_exit_after_flash") }
(context as? ComponentActivity)?.finish()
}
}
}
// 开始刷写
LaunchedEffect(Unit) {
if (!KernelFlashStateHolder.isFlashing && !flashState.isCompleted && flashState.error.isEmpty()) {
withContext(Dispatchers.IO) {
KernelFlashStateHolder.isFlashing = true
val worker = HorizonKernelWorker(
context = context,
state = horizonKernelState,
slot = selectedSlot,
kpmPatchEnabled = kpmPatchEnabled,
kpmUndoPatch = kpmUndoPatch
)
worker.uri = kernelUri
worker.setOnFlashCompleteListener(onFlashComplete)
worker.start()
// 监听日志更新
while (flashState.error.isEmpty()) {
if (flashState.logs.isNotEmpty()) {
logText = flashState.logs.joinToString("\n")
logContent.clear()
logContent.append(logText)
}
delay(100)
}
if (flashState.error.isNotEmpty()) {
logText += "\n${flashState.error}\n"
logContent.append("\n${flashState.error}\n")
KernelFlashStateHolder.isFlashing = false
}
}
} else {
logText = flashState.logs.joinToString("\n")
if (flashState.error.isNotEmpty()) {
logText += "\n${flashState.error}\n"
} else if (flashState.isCompleted) {
logText += "\n${context.getString(R.string.horizon_flash_complete)}\n\n\n"
showFloatAction = true
}
}
}
val onBack: () -> Unit = {
if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) {
// 清理全局状态
if (flashState.isCompleted || flashState.error.isNotEmpty()) {
KernelFlashStateHolder.currentState = null
KernelFlashStateHolder.currentUri = null
KernelFlashStateHolder.currentSlot = null
KernelFlashStateHolder.currentKpmPatchEnabled = false
KernelFlashStateHolder.currentKpmUndoPatch = false
KernelFlashStateHolder.isFlashing = false
}
navigator.popBackStack()
}
}
DisposableEffect(shouldAutoExit) {
onDispose {
if (shouldAutoExit) {
KernelFlashStateHolder.currentState = null
KernelFlashStateHolder.currentUri = null
KernelFlashStateHolder.currentSlot = null
KernelFlashStateHolder.currentKpmPatchEnabled = false
KernelFlashStateHolder.currentKpmUndoPatch = false
KernelFlashStateHolder.isFlashing = false
}
}
}
BackHandler(enabled = true) {
onBack()
}
Scaffold(
topBar = {
TopBar(
flashState = flashState,
onBack = onBack,
onSave = {
scope.launch {
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
val date = format.format(Date())
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"KernelSU_kernel_flash_log_${date}.log"
)
file.writeText(logContent.toString())
snackBarHost.showSnackbar(logSavedString.format(file.absolutePath))
}
},
scrollBehavior = scrollBehavior
)
},
floatingActionButton = {
if (showFloatAction) {
ExtendedFloatingActionButton(
onClick = {
scope.launch {
withContext(Dispatchers.IO) {
reboot()
}
}
},
icon = {
Icon(
Icons.Filled.Refresh,
contentDescription = stringResource(id = R.string.reboot)
)
},
text = {
Text(text = stringResource(id = R.string.reboot))
},
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
expanded = true
)
}
},
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
containerColor = MaterialTheme.colorScheme.background
) { innerPadding ->
KeyEventBlocker {
it.key == Key.VolumeDown || it.key == Key.VolumeUp
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.nestedScroll(scrollBehavior.nestedScrollConnection),
) {
FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(scrollState)
) {
LaunchedEffect(logText) {
scrollState.animateScrollTo(scrollState.maxValue)
}
Text(
modifier = Modifier.padding(16.dp),
text = logText,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@Composable
private fun FlashProgressIndicator(
flashState: FlashState,
kpmPatchEnabled: Boolean = false,
kpmUndoPatch: Boolean = false
) {
val progressColor = when {
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
}
val progress = animateFloatAsState(
targetValue = flashState.progress,
label = "FlashProgress"
)
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 = when {
flashState.error.isNotEmpty() -> stringResource(R.string.flash_failed)
flashState.isCompleted -> stringResource(R.string.flash_success)
else -> stringResource(R.string.flashing)
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = progressColor
)
when {
flashState.error.isNotEmpty() -> {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
flashState.isCompleted -> {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary
)
}
}
}
// KPM状态显示
if (kpmPatchEnabled || kpmUndoPatch) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode)
else stringResource(R.string.kpm_patch_mode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
Spacer(modifier = Modifier.height(8.dp))
if (flashState.currentStep.isNotEmpty()) {
Text(
text = flashState.currentStep,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
}
LinearProgressIndicator(
progress = { progress.value },
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
if (flashState.error.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = flashState.error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
shape = MaterialTheme.shapes.small
)
.padding(8.dp)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopBar(
flashState: FlashState,
onBack: () -> Unit,
onSave: () -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior? = null
) {
val statusColor = when {
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
}
val colorScheme = MaterialTheme.colorScheme
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
colorScheme.surfaceContainerLow
} else {
colorScheme.background
}
val cardAlpha = CardConfig.cardAlpha
TopAppBar(
title = {
Text(
text = stringResource(
when {
flashState.error.isNotEmpty() -> R.string.flash_failed
flashState.isCompleted -> R.string.flash_success
else -> R.string.kernel_flashing
}
),
style = MaterialTheme.typography.titleLarge,
color = statusColor
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
}
},
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
)
}

View file

@ -0,0 +1,258 @@
package zako.zako.zako.zakoui.screen.kernelFlash.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SdStorage
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.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.sukisu.ultra.R
/**
* 槽位选择对话框组件
* 用于Kernel刷写时选择目标槽位
*/
@OptIn(ExperimentalMaterial3Api::class)
@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) }
LaunchedEffect(Unit) {
try {
currentSlot = getCurrentSlot()
// 设置默认选择为当前槽位
selectedSlot = when (currentSlot) {
"a" -> "a"
"b" -> "b"
else -> null
}
errorMessage = null
} catch (e: Exception) {
errorMessage = e.message
currentSlot = null
}
}
if (show) {
val cardColor = MaterialTheme.colorScheme.surfaceContainerHighest
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = stringResource(id = R.string.select_slot_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
},
text = {
Column(
modifier = Modifier.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (errorMessage != null) {
Text(
text = "Error: $errorMessage",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
} else {
Text(
text = stringResource(
id = R.string.current_slot,
currentSlot ?: "Unknown"
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = R.string.select_slot_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
// Horizontal arrangement for slot options with highlighted current slot
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.SpaceBetween
) {
val slotOptions = listOf(
ListOption(
titleText = stringResource(id = R.string.slot_a),
subtitleText = null,
icon = Icons.Filled.SdStorage
),
ListOption(
titleText = stringResource(id = R.string.slot_b),
subtitleText = null,
icon = Icons.Filled.SdStorage
)
)
slotOptions.forEachIndexed { index, option ->
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(
color = if (selectedSlot == when(index) {
0 -> "a"
else -> "b"
}) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
} else {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
}
)
.clickable {
selectedSlot = when(index) {
0 -> "a"
else -> "b"
}
}
.padding(vertical = 12.dp, horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = option.icon,
contentDescription = null,
tint = if (selectedSlot == when(index) {
0 -> "a"
else -> "b"
}) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.primary
},
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp)
)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = option.titleText,
style = MaterialTheme.typography.titleMedium,
color = if (selectedSlot == when(index) {
0 -> "a"
else -> "b"
}) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.primary
}
)
option.subtitleText?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = if (selectedSlot == when(index) {
0 -> "a"
else -> "b"
}) {
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
selectedSlot?.let { onSlotSelected(it) }
onDismiss()
},
enabled = selectedSlot != null
) {
Text(
text = stringResource(android.R.string.ok),
color = MaterialTheme.colorScheme.primary
)
}
},
dismissButton = {
TextButton(
onClick = onDismiss
) {
Text(
text = stringResource(android.R.string.cancel),
color = MaterialTheme.colorScheme.primary
)
}
},
containerColor = cardColor,
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 4.dp
)
}
}
// Data class for list options
data class ListOption(
val titleText: String,
val subtitleText: String?,
val icon: ImageVector
)
// Utility function to get current slot
private fun getCurrentSlot(): String? {
return runCommandGetOutput(true, "getprop ro.boot.slot_suffix")?.let {
if (it.startsWith("_")) it.substring(1) else it
}
}
private fun runCommandGetOutput(su: Boolean, cmd: String): String? {
return try {
val process = ProcessBuilder(if (su) "su" else "sh").start()
process.outputStream.bufferedWriter().use { writer ->
writer.write("$cmd\n")
writer.write("exit\n")
writer.flush()
}
process.inputStream.bufferedReader().use { reader ->
reader.readText().trim()
}
} catch (_: Exception) {
null
}
}

View file

@ -0,0 +1,524 @@
package zako.zako.zako.zakoui.screen.kernelFlash.state
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import com.sukisu.ultra.R
import com.sukisu.ultra.network.RemoteToolsDownloader
import com.sukisu.ultra.ui.util.install
import com.sukisu.ultra.ui.util.rootAvailable
import com.sukisu.ultra.utils.AssetsUtil
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* @author ShirkNeko
* @date 2025/5/31.
*/
data class FlashState(
val isFlashing: Boolean = false,
val isCompleted: Boolean = false,
val progress: Float = 0f,
val currentStep: String = "",
val logs: List<String> = emptyList(),
val error: String = ""
)
class HorizonKernelState {
private val _state = MutableStateFlow(FlashState())
val state: StateFlow<FlashState> = _state.asStateFlow()
fun updateProgress(progress: Float) {
_state.update { it.copy(progress = progress) }
}
fun updateStep(step: String) {
_state.update { it.copy(currentStep = step) }
}
fun addLog(log: String) {
_state.update {
it.copy(logs = it.logs + log)
}
}
fun setError(error: String) {
_state.update { it.copy(error = error) }
}
fun startFlashing() {
_state.update {
it.copy(
isFlashing = true,
isCompleted = false,
progress = 0f,
currentStep = "under preparation...",
logs = emptyList(),
error = ""
)
}
}
fun completeFlashing() {
_state.update { it.copy(isCompleted = true, progress = 1f) }
}
fun reset() {
_state.value = FlashState()
}
}
class HorizonKernelWorker(
private val context: Context,
private val state: HorizonKernelState,
private val slot: String? = null,
private val kpmPatchEnabled: Boolean = false,
private val kpmUndoPatch: Boolean = false
) : Thread() {
var uri: Uri? = null
private lateinit var filePath: String
private lateinit var binaryPath: String
private lateinit var workDir: String
private var onFlashComplete: (() -> Unit)? = null
private var originalSlot: String? = null
private var downloaderJob: Job? = null
fun setOnFlashCompleteListener(listener: () -> Unit) {
onFlashComplete = listener
}
override fun run() {
state.startFlashing()
state.updateStep(context.getString(R.string.horizon_preparing))
filePath = "${context.filesDir.absolutePath}/${DocumentFile.fromSingleUri(context, uri!!)?.name}"
binaryPath = "${context.filesDir.absolutePath}/META-INF/com/google/android/update-binary"
workDir = "${context.filesDir.absolutePath}/work"
try {
state.updateStep(context.getString(R.string.horizon_cleaning_files))
state.updateProgress(0.1f)
cleanup()
if (!rootAvailable()) {
state.setError(context.getString(R.string.root_required))
return
}
state.updateStep(context.getString(R.string.horizon_copying_files))
state.updateProgress(0.2f)
copy()
if (!File(filePath).exists()) {
state.setError(context.getString(R.string.horizon_copy_failed))
return
}
state.updateStep(context.getString(R.string.horizon_extracting_tool))
state.updateProgress(0.4f)
getBinary()
// KPM修补
if (kpmPatchEnabled || kpmUndoPatch) {
state.updateStep(context.getString(R.string.kpm_preparing_tools))
state.updateProgress(0.5f)
prepareKpmToolsWithDownload()
state.updateStep(
if (kpmUndoPatch) context.getString(R.string.kpm_undoing_patch)
else context.getString(R.string.kpm_applying_patch)
)
state.updateProgress(0.55f)
performKpmPatch()
}
state.updateStep(context.getString(R.string.horizon_patching_script))
state.updateProgress(0.6f)
patch()
state.updateStep(context.getString(R.string.horizon_flashing))
state.updateProgress(0.7f)
val isAbDevice = isAbDevice()
if (isAbDevice && slot != null) {
state.updateStep(context.getString(R.string.horizon_getting_original_slot))
state.updateProgress(0.72f)
originalSlot = runCommandGetOutput("getprop ro.boot.slot_suffix")
state.updateStep(context.getString(R.string.horizon_setting_target_slot))
state.updateProgress(0.74f)
runCommand(true, "resetprop -n ro.boot.slot_suffix _$slot")
}
flash()
if (isAbDevice && !originalSlot.isNullOrEmpty()) {
state.updateStep(context.getString(R.string.horizon_restoring_original_slot))
state.updateProgress(0.8f)
runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot")
}
try {
install()
} catch (e: Exception) {
state.updateStep("ksud update skipped: ${e.message}")
}
state.updateStep(context.getString(R.string.horizon_flash_complete_status))
state.completeFlashing()
(context as? Activity)?.runOnUiThread {
onFlashComplete?.invoke()
}
} catch (e: Exception) {
state.setError(e.message ?: context.getString(R.string.horizon_unknown_error))
if (isAbDevice() && !originalSlot.isNullOrEmpty()) {
state.updateStep(context.getString(R.string.horizon_restoring_original_slot))
state.updateProgress(0.8f)
runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot")
}
} finally {
// 取消下载任务并清理
downloaderJob?.cancel()
cleanupDownloader()
}
}
private fun prepareKpmToolsWithDownload() {
try {
File(workDir).mkdirs()
val downloader = RemoteToolsDownloader(context, workDir)
val progressListener = object : RemoteToolsDownloader.DownloadProgressListener {
override fun onProgress(fileName: String, progress: Int, total: Int) {
val percentage = if (total > 0) (progress * 100) / total else 0
state.addLog("Downloading $fileName: $percentage% ($progress/$total bytes)")
}
override fun onLog(message: String) {
state.addLog(message)
}
override fun onError(fileName: String, error: String) {
state.addLog("Warning: $fileName - $error")
}
override fun onSuccess(fileName: String, isRemote: Boolean) {
val source = if (isRemote) "remote" else "local"
state.addLog("$fileName $source version prepared successfully")
}
}
val downloadJob = CoroutineScope(Dispatchers.IO).launch {
downloader.downloadToolsAsync(progressListener)
}
downloaderJob = downloadJob
runBlocking {
downloadJob.join()
}
val kptoolsPath = "$workDir/kptools"
val kpimgPath = "$workDir/kpimg"
if (!File(kptoolsPath).exists()) {
throw IOException("kptools file preparation failed")
}
if (!File(kpimgPath).exists()) {
throw IOException("kpimg file preparation failed")
}
runCommand(true, "chmod a+rx $kptoolsPath")
state.addLog("KPM tools preparation completed, starting patch operation")
} catch (_: CancellationException) {
state.addLog("KPM tools download cancelled")
throw IOException("Tool preparation process interrupted")
} catch (e: Exception) {
state.addLog("KPM tools preparation failed: ${e.message}")
state.addLog("Attempting to use legacy local file extraction...")
try {
prepareKpmToolsLegacy()
state.addLog("Successfully used local backup files")
} catch (legacyException: Exception) {
state.addLog("Local file extraction also failed: ${legacyException.message}")
throw IOException("Unable to prepare KPM tool files: ${e.message}")
}
}
}
private fun prepareKpmToolsLegacy() {
File(workDir).mkdirs()
val kptoolsPath = "$workDir/kptools"
val kpimgPath = "$workDir/kpimg"
AssetsUtil.exportFiles(context, "kptools", kptoolsPath)
if (!File(kptoolsPath).exists()) {
throw IOException("Local kptools file extraction failed")
}
AssetsUtil.exportFiles(context, "kpimg", kpimgPath)
if (!File(kpimgPath).exists()) {
throw IOException("Local kpimg file extraction failed")
}
runCommand(true, "chmod a+rx $kptoolsPath")
}
private fun cleanupDownloader() {
try {
val downloader = RemoteToolsDownloader(context, workDir)
downloader.cleanup()
} catch (_: Exception) {
}
}
/**
* 执行KPM修补操作
*/
private fun performKpmPatch() {
try {
// 创建临时解压目录
val extractDir = "$workDir/extracted"
File(extractDir).mkdirs()
// 解压压缩包到临时目录
val unzipResult = runCommand(true, "cd $extractDir && unzip -o \"$filePath\"")
if (unzipResult != 0) {
throw IOException(context.getString(R.string.kpm_extract_zip_failed))
}
// 查找Image文件
val findImageResult = runCommandGetOutput("find $extractDir -name '*Image*' -type f")
if (findImageResult.isBlank()) {
throw IOException(context.getString(R.string.kpm_image_file_not_found))
}
val imageFile = findImageResult.lines().first().trim()
val imageDir = File(imageFile).parent
val imageName = File(imageFile).name
state.addLog(context.getString(R.string.kpm_found_image_file, imageFile))
// 复制KPM工具到Image文件所在目录
runCommand(true, "cp $workDir/kptools $imageDir/")
runCommand(true, "cp $workDir/kpimg $imageDir/")
// 执行KPM修补命令
val patchCommand = if (kpmUndoPatch) {
"cd $imageDir && chmod a+rx kptools && ./kptools -u -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName"
} else {
"cd $imageDir && chmod a+rx kptools && ./kptools -p -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName"
}
val patchResult = runCommand(true, patchCommand)
if (patchResult != 0) {
throw IOException(
if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_failed)
else context.getString(R.string.kpm_patch_failed)
)
}
state.addLog(
if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_success)
else context.getString(R.string.kpm_patch_success)
)
// 清理KPM工具文件
runCommand(true, "rm -f $imageDir/kptools $imageDir/kpimg $imageDir/oImage")
// 重新打包ZIP文件
val originalFileName = File(filePath).name
val patchedFilePath = "$workDir/patched_$originalFileName"
repackZipFolder(extractDir, patchedFilePath)
// 替换原始文件
runCommand(true, "mv \"$patchedFilePath\" \"$filePath\"")
state.addLog(context.getString(R.string.kpm_file_repacked))
} catch (e: Exception) {
state.addLog(context.getString(R.string.kpm_patch_operation_failed, e.message))
throw e
} finally {
// 清理临时文件
runCommand(true, "rm -rf $workDir")
}
}
private fun repackZipFolder(sourceDir: String, zipFilePath: String) {
try {
val buffer = ByteArray(1024)
val sourceFolder = File(sourceDir)
FileOutputStream(zipFilePath).use { fos ->
ZipOutputStream(fos).use { zos ->
sourceFolder.walkTopDown().forEach { file ->
if (file.isFile) {
val relativePath = file.relativeTo(sourceFolder).path
val zipEntry = ZipEntry(relativePath)
zos.putNextEntry(zipEntry)
file.inputStream().use { fis ->
var length: Int
while (fis.read(buffer).also { length = it } > 0) {
zos.write(buffer, 0, length)
}
}
zos.closeEntry()
}
}
}
}
} catch (e: Exception) {
throw IOException("Failed to create zip file: ${e.message}", e)
}
}
// 检查设备是否为AB分区设备
private fun isAbDevice(): Boolean {
val abUpdate = runCommandGetOutput("getprop ro.build.ab_update")
if (!abUpdate.toBoolean()) return false
val slotSuffix = runCommandGetOutput("getprop ro.boot.slot_suffix")
return slotSuffix.isNotEmpty()
}
private fun cleanup() {
runCommand(false, "find ${context.filesDir.absolutePath} -type f ! -name '*.jpg' ! -name '*.png' -delete")
runCommand(false, "rm -rf $workDir")
}
private fun copy() {
uri?.let { safeUri ->
context.contentResolver.openInputStream(safeUri)?.use { input ->
FileOutputStream(File(filePath)).use { output ->
input.copyTo(output)
}
}
}
}
private fun getBinary() {
runCommand(false, "unzip \"$filePath\" \"*/update-binary\" -d ${context.filesDir.absolutePath}")
if (!File(binaryPath).exists()) {
throw IOException("Failed to extract update-binary")
}
}
@SuppressLint("StringFormatInvalid")
private fun patch() {
val kernelVersion = runCommandGetOutput("cat /proc/version")
val versionRegex = """\d+\.\d+\.\d+""".toRegex()
val version = kernelVersion.let { versionRegex.find(it) }?.value ?: ""
val toolName = if (version.isNotEmpty()) {
val parts = version.split('.')
if (parts.size >= 2) {
val major = parts[0].toIntOrNull() ?: 0
val minor = parts[1].toIntOrNull() ?: 0
if (major < 5 || (major == 5 && minor <= 10)) "5_10" else "5_15+"
} else {
"5_15+"
}
} else {
"5_15+"
}
val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}")
runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $toolPath \$AKHOME/tools;' $binaryPath")
}
private fun flash() {
val process = ProcessBuilder("su")
.redirectErrorStream(true)
.start()
try {
process.outputStream.bufferedWriter().use { writer ->
writer.write("export POSTINSTALL=${context.filesDir.absolutePath}\n")
// 写入槽位信息到临时文件
slot?.let { selectedSlot ->
writer.write("echo \"$selectedSlot\" > ${context.filesDir.absolutePath}/bootslot\n")
}
// 构建刷写命令
val flashCommand = buildString {
append("sh $binaryPath 3 1 \"$filePath\"")
if (slot != null) {
append(" \"$(cat ${context.filesDir.absolutePath}/bootslot)\"")
}
append(" && touch ${context.filesDir.absolutePath}/done\n")
}
writer.write(flashCommand)
writer.write("exit\n")
writer.flush()
}
process.inputStream.bufferedReader().use { reader ->
reader.lineSequence().forEach { line ->
if (line.startsWith("ui_print")) {
val logMessage = line.removePrefix("ui_print").trim()
state.addLog(logMessage)
when {
logMessage.contains("extracting", ignoreCase = true) -> {
state.updateProgress(0.75f)
}
logMessage.contains("installing", ignoreCase = true) -> {
state.updateProgress(0.85f)
}
logMessage.contains("complete", ignoreCase = true) -> {
state.updateProgress(0.95f)
}
}
}
}
}
} finally {
process.destroy()
}
if (!File("${context.filesDir.absolutePath}/done").exists()) {
throw IOException(context.getString(R.string.flash_failed_message))
}
}
private fun runCommand(su: Boolean, cmd: String): Int {
val shell = if (su) "su" else "sh"
val process = Runtime.getRuntime().exec(arrayOf(shell, "-c", cmd))
return try {
process.waitFor()
} finally {
process.destroy()
}
}
private fun runCommandGetOutput(cmd: String): String {
return Shell.cmd(cmd).exec().out.joinToString("\n").trim()
}
}

View file

@ -0,0 +1,757 @@
package zako.zako.zako.zakoui.screen.moreSettings
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
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.getValue
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.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.theme.component.ImageEditorDialog
import com.sukisu.ultra.ui.component.KsuIsValid
import com.sukisu.ultra.ui.screen.SwitchItem
import com.sukisu.ultra.ui.theme.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import zako.zako.zako.zakoui.screen.moreSettings.component.ColorCircle
import zako.zako.zako.zakoui.screen.moreSettings.component.LanguageSelectionDialog
import zako.zako.zako.zakoui.screen.moreSettings.component.MoreSettingsDialogs
import zako.zako.zako.zakoui.screen.moreSettings.component.SettingItem
import zako.zako.zako.zakoui.screen.moreSettings.component.SettingsCard
import zako.zako.zako.zakoui.screen.moreSettings.component.SettingsDivider
import zako.zako.zako.zakoui.screen.moreSettings.component.SwitchSettingItem
import zako.zako.zako.zakoui.screen.moreSettings.component.UidScannerSection
import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState
import kotlin.math.roundToInt
@SuppressLint("LocalContextConfigurationRead", "LocalContextResourcesRead", "ObsoleteSdkInt")
@OptIn(ExperimentalMaterial3Api::class)
@Destination<RootGraph>
@Composable
fun MoreSettingsScreen(
navigator: DestinationsNavigator
) {
// 顶部滚动行为
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
val systemIsDark = isSystemInDarkTheme()
// 创建设置状态管理器
val settingsState = remember { MoreSettingsState(context, prefs, systemIsDark) }
val settingsHandlers = remember { MoreSettingsHandlers(context, prefs, settingsState) }
// 图片选择器
val pickImageLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
settingsState.selectedImageUri = it
settingsState.showImageEditor = true
}
}
// 初始化设置
LaunchedEffect(Unit) {
settingsHandlers.initializeSettings()
}
// 显示图片编辑对话框
if (settingsState.showImageEditor && settingsState.selectedImageUri != null) {
ImageEditorDialog(
imageUri = settingsState.selectedImageUri!!,
onDismiss = {
settingsState.showImageEditor = false
settingsState.selectedImageUri = null
},
onConfirm = { transformedUri ->
settingsHandlers.handleCustomBackground(transformedUri)
settingsState.showImageEditor = false
settingsState.selectedImageUri = null
}
)
}
// 各种设置对话框
MoreSettingsDialogs(
state = settingsState,
handlers = settingsHandlers
)
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.more_settings),
style = MaterialTheme.typography.titleLarge
)
},
navigationIcon = {
IconButton(onClick = { navigator.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = CardConfig.cardAlpha),
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = CardConfig.cardAlpha)
),
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
scrollBehavior = scrollBehavior
)
},
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
// 外观设置
AppearanceSettings(
state = settingsState,
handlers = settingsHandlers,
pickImageLauncher = pickImageLauncher,
coroutineScope = coroutineScope
)
// 自定义设置
CustomizationSettings(
state = settingsState,
handlers = settingsHandlers
)
// 高级设置
KsuIsValid {
AdvancedSettings(
state = settingsState,
handlers = settingsHandlers
)
}
}
}
}
@Composable
private fun AppearanceSettings(
state: MoreSettingsState,
handlers: MoreSettingsHandlers,
pickImageLauncher: ActivityResultLauncher<String>,
coroutineScope: CoroutineScope
) {
SettingsCard(title = stringResource(R.string.appearance_settings)) {
// 语言设置
LanguageSetting(state = state)
// 主题模式
SettingItem(
icon = Icons.Default.DarkMode,
title = stringResource(R.string.theme_mode),
subtitle = state.themeOptions[state.themeMode],
onClick = { state.showThemeModeDialog = true }
)
// 动态颜色开关
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
SwitchSettingItem(
icon = Icons.Filled.ColorLens,
title = stringResource(R.string.dynamic_color_title),
summary = stringResource(R.string.dynamic_color_summary),
checked = state.useDynamicColor,
onChange = handlers::handleDynamicColorChange
)
}
// 主题色选择
AnimatedVisibility(
visible = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !state.useDynamicColor,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
ThemeColorSelection(state = state)
}
SettingsDivider()
// DPI 设置
DpiSettings(state = state, handlers = handlers)
SettingsDivider()
// 自定义背景设置
CustomBackgroundSettings(
state = state,
handlers = handlers,
pickImageLauncher = pickImageLauncher,
coroutineScope = coroutineScope
)
}
}
@Composable
private fun CustomizationSettings(
state: MoreSettingsState,
handlers: MoreSettingsHandlers
) {
SettingsCard(title = stringResource(R.string.custom_settings)) {
// 图标切换
SwitchSettingItem(
icon = Icons.Default.Android,
title = stringResource(R.string.icon_switch_title),
summary = stringResource(R.string.icon_switch_summary),
checked = state.useAltIcon,
onChange = handlers::handleIconChange
)
// 显示更多模块信息
SwitchSettingItem(
icon = Icons.Filled.Info,
title = stringResource(R.string.show_more_module_info),
summary = stringResource(R.string.show_more_module_info_summary),
checked = state.showMoreModuleInfo,
onChange = handlers::handleShowMoreModuleInfoChange
)
// 简洁模式开关
SwitchSettingItem(
icon = Icons.Filled.Brush,
title = stringResource(R.string.simple_mode),
summary = stringResource(R.string.simple_mode_summary),
checked = state.isSimpleMode,
onChange = handlers::handleSimpleModeChange
)
SwitchSettingItem(
icon = Icons.Filled.Brush,
title = stringResource(R.string.kernel_simple_kernel),
summary = stringResource(R.string.kernel_simple_kernel_summary),
checked = state.isKernelSimpleMode,
onChange = handlers::handleKernelSimpleModeChange
)
// 各种隐藏选项
HideOptionsSettings(state = state, handlers = handlers)
}
}
@Composable
private fun HideOptionsSettings(
state: MoreSettingsState,
handlers: MoreSettingsHandlers
) {
// 隐藏内核版本号
SwitchSettingItem(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.hide_kernel_kernelsu_version),
summary = stringResource(R.string.hide_kernel_kernelsu_version_summary),
checked = state.isHideVersion,
onChange = handlers::handleHideVersionChange
)
// 隐藏模块数量等信息
SwitchSettingItem(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.hide_other_info),
summary = stringResource(R.string.hide_other_info_summary),
checked = state.isHideOtherInfo,
onChange = handlers::handleHideOtherInfoChange
)
// SuSFS 状态信息
SwitchSettingItem(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.hide_susfs_status),
summary = stringResource(R.string.hide_susfs_status_summary),
checked = state.isHideSusfsStatus,
onChange = handlers::handleHideSusfsStatusChange
)
// Zygisk 实现状态信息
SwitchSettingItem(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.hide_zygisk_implement),
summary = stringResource(R.string.hide_zygisk_implement_summary),
checked = state.isHideZygiskImplement,
onChange = handlers::handleHideZygiskImplementChange
)
if (Natives.version >= Natives.MINIMAL_SUPPORTED_KPM) {
SwitchSettingItem(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.show_kpm_info),
summary = stringResource(R.string.show_kpm_info_summary),
checked = state.isShowKpmInfo,
onChange = handlers::handleShowKpmInfoChange
)
}
// 隐藏链接信息
SwitchSettingItem(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.hide_link_card),
summary = stringResource(R.string.hide_link_card_summary),
checked = state.isHideLinkCard,
onChange = handlers::handleHideLinkCardChange
)
// 隐藏标签行
SwitchSettingItem(
icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.hide_tag_card),
summary = stringResource(R.string.hide_tag_card_summary),
checked = state.isHideTagRow,
onChange = handlers::handleHideTagRowChange
)
}
@Composable
private fun AdvancedSettings(
state: MoreSettingsState,
handlers: MoreSettingsHandlers
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackBarHost = remember { SnackbarHostState() }
val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
SettingsCard(title = stringResource(R.string.advanced_settings)) {
// SELinux 开关
SwitchSettingItem(
icon = Icons.Filled.Security,
title = stringResource(R.string.selinux),
summary = if (state.selinuxEnabled)
stringResource(R.string.selinux_enabled) else
stringResource(R.string.selinux_disabled),
checked = state.selinuxEnabled,
onChange = handlers::handleSelinuxChange
)
var forceSignatureVerification by rememberSaveable {
mutableStateOf(prefs.getBoolean("force_signature_verification", false))
}
// 强制签名验证开关
SwitchItem(
icon = Icons.Filled.Security,
title = stringResource(R.string.module_signature_verification),
summary = stringResource(R.string.module_signature_verification_summary),
checked = forceSignatureVerification,
onCheckedChange = { enabled ->
prefs.edit { putBoolean("force_signature_verification", enabled) }
forceSignatureVerification = enabled
}
)
// UID 扫描开关
if (Natives.version >= Natives.MINIMAL_SUPPORTED_UID_SCANNER && Natives.version >= Natives.MINIMAL_NEW_IOCTL_KERNEL) {
UidScannerSection(prefs, snackBarHost, scope, context)
}
// 动态管理器设置
if (Natives.version >= Natives.MINIMAL_SUPPORTED_DYNAMIC_MANAGER && Natives.version >= Natives.MINIMAL_NEW_IOCTL_KERNEL) {
SettingItem(
icon = Icons.Filled.Security,
title = stringResource(R.string.dynamic_manager_title),
subtitle = if (state.isDynamicSignEnabled) {
stringResource(R.string.dynamic_manager_enabled_summary, state.dynamicSignSize)
} else {
stringResource(R.string.dynamic_manager_disabled)
},
onClick = { state.showDynamicSignDialog = true }
)
}
}
}
@Composable
private fun ThemeColorSelection(state: MoreSettingsState) {
SettingItem(
icon = Icons.Default.Palette,
title = stringResource(R.string.theme_color),
subtitle = when (ThemeConfig.currentTheme) {
is ThemeColors.Green -> stringResource(R.string.color_green)
is ThemeColors.Purple -> stringResource(R.string.color_purple)
is ThemeColors.Orange -> stringResource(R.string.color_orange)
is ThemeColors.Pink -> stringResource(R.string.color_pink)
is ThemeColors.Gray -> stringResource(R.string.color_gray)
is ThemeColors.Yellow -> stringResource(R.string.color_yellow)
else -> stringResource(R.string.color_default)
},
onClick = { state.showThemeColorDialog = true },
trailingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 8.dp)
) {
val theme = ThemeConfig.currentTheme
val isDark = isSystemInDarkTheme()
ColorCircle(
color = if (isDark) theme.primaryDark else theme.primaryLight,
isSelected = false,
modifier = Modifier.padding(horizontal = 2.dp)
)
ColorCircle(
color = if (isDark) theme.secondaryDark else theme.secondaryLight,
isSelected = false,
modifier = Modifier.padding(horizontal = 2.dp)
)
ColorCircle(
color = if (isDark) theme.tertiaryDark else theme.tertiaryLight,
isSelected = false,
modifier = Modifier.padding(horizontal = 2.dp)
)
}
}
)
}
@Composable
private fun DpiSettings(
state: MoreSettingsState,
handlers: MoreSettingsHandlers
) {
SettingItem(
icon = Icons.Default.FormatSize,
title = stringResource(R.string.app_dpi_title),
subtitle = stringResource(R.string.app_dpi_summary),
onClick = {},
trailingContent = {
Text(
text = handlers.getDpiFriendlyName(state.tempDpi),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
)
// DPI 滑动条和控制
DpiSliderControls(state = state, handlers = handlers)
}
@Composable
private fun DpiSliderControls(
state: MoreSettingsState,
handlers: MoreSettingsHandlers
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val sliderValue by animateFloatAsState(
targetValue = state.tempDpi.toFloat(),
label = "DPI Slider Animation"
)
Slider(
value = sliderValue,
onValueChange = { newValue ->
state.tempDpi = newValue.toInt()
state.isDpiCustom = !state.dpiPresets.containsValue(state.tempDpi)
},
valueRange = 160f..600f,
steps = 11,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
)
)
// DPI 预设按钮行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
) {
state.dpiPresets.forEach { (name, dpi) ->
val isSelected = state.tempDpi == dpi
val buttonColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(buttonColor)
.clickable {
state.tempDpi = dpi
state.isDpiCustom = false
}
.padding(vertical = 8.dp, horizontal = 4.dp),
contentAlignment = Alignment.Center
) {
Text(
text = name,
style = MaterialTheme.typography.labelMedium,
color = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Text(
text = if (state.isDpiCustom)
"${stringResource(R.string.dpi_size_custom)}: ${state.tempDpi}"
else
"${handlers.getDpiFriendlyName(state.tempDpi)}: ${state.tempDpi}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp)
)
Button(
onClick = { state.showDpiConfirmDialog = true },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
enabled = state.tempDpi != state.currentDpi
) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.dpi_apply_settings))
}
}
}
@Composable
private fun CustomBackgroundSettings(
state: MoreSettingsState,
handlers: MoreSettingsHandlers,
pickImageLauncher: ActivityResultLauncher<String>,
coroutineScope: CoroutineScope
) {
// 自定义背景开关
SwitchSettingItem(
icon = Icons.Filled.Wallpaper,
title = stringResource(id = R.string.settings_custom_background),
summary = stringResource(id = R.string.settings_custom_background_summary),
checked = state.isCustomBackgroundEnabled,
onChange = { isChecked ->
if (isChecked) {
pickImageLauncher.launch("image/*")
} else {
handlers.handleRemoveCustomBackground()
}
}
)
// 透明度和亮度调节
AnimatedVisibility(
visible = ThemeConfig.customBackgroundUri != null,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
BackgroundAdjustmentControls(
state = state,
handlers = handlers,
coroutineScope = coroutineScope
)
}
}
@Composable
private fun BackgroundAdjustmentControls(
state: MoreSettingsState,
handlers: MoreSettingsHandlers,
coroutineScope: CoroutineScope
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
// 透明度滑动条
AlphaSlider(state = state, handlers = handlers, coroutineScope = coroutineScope)
// 亮度调节滑动条
DimSlider(state = state, handlers = handlers, coroutineScope = coroutineScope)
}
}
@Composable
private fun AlphaSlider(
state: MoreSettingsState,
handlers: MoreSettingsHandlers,
coroutineScope: CoroutineScope
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp)
) {
Icon(
Icons.Filled.Opacity,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.settings_card_alpha),
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${(state.cardAlpha * 100).roundToInt()}%",
style = MaterialTheme.typography.labelMedium,
)
}
val alphaSliderValue by animateFloatAsState(
targetValue = state.cardAlpha,
label = "Alpha Slider Animation"
)
Slider(
value = alphaSliderValue,
onValueChange = { newValue ->
handlers.handleCardAlphaChange(newValue)
},
onValueChangeFinished = {
coroutineScope.launch(Dispatchers.IO) {
saveCardConfig(handlers.context)
}
},
valueRange = 0f..1f,
steps = 20,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
)
)
}
@Composable
private fun DimSlider(
state: MoreSettingsState,
handlers: MoreSettingsHandlers,
coroutineScope: CoroutineScope
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
) {
Icon(
Icons.Filled.LightMode,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.settings_card_dim),
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${(state.cardDim * 100).roundToInt()}%",
style = MaterialTheme.typography.labelMedium,
)
}
val dimSliderValue by animateFloatAsState(
targetValue = state.cardDim,
label = "Dim Slider Animation"
)
Slider(
value = dimSliderValue,
onValueChange = { newValue ->
handlers.handleCardDimChange(newValue)
},
onValueChangeFinished = {
coroutineScope.launch(Dispatchers.IO) {
saveCardConfig(handlers.context)
}
},
valueRange = 0f..1f,
steps = 20,
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
)
)
}
fun saveCardConfig(context: Context) {
CardConfig.save(context)
}
@Composable
private fun LanguageSetting(state: MoreSettingsState) {
val context = LocalContext.current
val language = stringResource(id = R.string.settings_language)
// Compute display name based on current app locale
val currentLanguageDisplay = remember(state.currentAppLocale) {
val locale = state.currentAppLocale
if (locale != null) {
locale.getDisplayName(locale)
} else {
context.getString(R.string.language_system_default)
}
}
SettingItem(
icon = Icons.Filled.Translate,
title = language,
subtitle = currentLanguageDisplay,
onClick = { state.showLanguageDialog = true }
)
// Language Selection Dialog
if (state.showLanguageDialog) {
LanguageSelectionDialog(
onLanguageSelected = { newLocale ->
// Update local state immediately
state.currentAppLocale = LocaleHelper.getCurrentAppLocale(context)
// Apply locale change immediately for Android < 13
LocaleHelper.restartActivity(context)
},
onDismiss = { state.showLanguageDialog = false }
)
}
}

View file

@ -0,0 +1,459 @@
package zako.zako.zako.zakoui.screen.moreSettings
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.res.Configuration
import android.net.Uri
import android.widget.Toast
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.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.material3.SnackbarHostState
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.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.ConfirmResult
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.screen.SettingItem
import com.sukisu.ultra.ui.screen.SwitchItem
import com.sukisu.ultra.ui.theme.*
import com.sukisu.ultra.ui.util.*
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState
import zako.zako.zako.zakoui.screen.moreSettings.util.toggleLauncherIcon
/**
* 更多设置处理器
*/
class MoreSettingsHandlers(
val context: Context,
private val prefs: SharedPreferences,
private val state: MoreSettingsState
) {
/**
* 初始化设置
*/
fun initializeSettings() {
// 加载设置
CardConfig.load(context)
state.cardAlpha = CardConfig.cardAlpha
state.cardDim = CardConfig.cardDim
state.isCustomBackgroundEnabled = ThemeConfig.customBackgroundUri != null
// 设置主题模式
state.themeMode = when (ThemeConfig.forceDarkMode) {
true -> 2
false -> 1
null -> 0
}
// 确保卡片样式跟随主题模式
when (state.themeMode) {
2 -> { // 深色
CardConfig.isUserDarkModeEnabled = true
CardConfig.isUserLightModeEnabled = false
}
1 -> { // 浅色
CardConfig.isUserDarkModeEnabled = false
CardConfig.isUserLightModeEnabled = true
}
0 -> { // 跟随系统
CardConfig.isUserDarkModeEnabled = false
CardConfig.isUserLightModeEnabled = false
}
}
// 如果启用了系统跟随且系统是深色模式,应用深色模式默认值
if (state.themeMode == 0 && state.systemIsDark) {
CardConfig.setThemeDefaults(true)
}
state.currentDpi = prefs.getInt("app_dpi", state.systemDpi)
state.tempDpi = state.currentDpi
CardConfig.save(context)
// 初始化 SELinux 状态
state.selinuxEnabled = Shell.cmd("getenforce").exec().out.firstOrNull() == "Enforcing"
// 初始化动态管理器配置
state.dynamicSignConfig = Natives.getDynamicManager()
state.dynamicSignConfig?.let { config ->
if (config.isValid()) {
state.isDynamicSignEnabled = true
state.dynamicSignSize = config.size.toString()
state.dynamicSignHash = config.hash
}
}
}
/**
* 处理主题模式变更
*/
fun handleThemeModeChange(index: Int) {
state.themeMode = index
val newThemeMode = when (index) {
0 -> null // 跟随系统
1 -> false // 浅色
2 -> true // 深色
else -> null
}
context.saveThemeMode(newThemeMode)
ThemeConfig.updateTheme(darkMode = newThemeMode)
when (index) {
2 -> { // 深色
ThemeConfig.updateTheme(darkMode = true)
CardConfig.updateThemePreference(darkMode = true, lightMode = false)
CardConfig.setThemeDefaults(true)
CardConfig.save(context)
}
1 -> { // 浅色
ThemeConfig.updateTheme(darkMode = false)
CardConfig.updateThemePreference(darkMode = false, lightMode = true)
CardConfig.setThemeDefaults(false)
CardConfig.save(context)
}
0 -> { // 跟随系统
ThemeConfig.updateTheme(darkMode = null)
CardConfig.updateThemePreference(darkMode = null, lightMode = null)
val isNightModeActive = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
CardConfig.setThemeDefaults(isNightModeActive)
CardConfig.save(context)
}
}
}
/**
* 处理主题色变更
*/
fun handleThemeColorChange(theme: ThemeColors) {
context.saveThemeColors(when (theme) {
ThemeColors.Green -> "green"
ThemeColors.Purple -> "purple"
ThemeColors.Orange -> "orange"
ThemeColors.Pink -> "pink"
ThemeColors.Gray -> "gray"
ThemeColors.Yellow -> "yellow"
else -> "default"
})
ThemeConfig.updateTheme(theme = theme)
}
/**
* 处理动态颜色变更
*/
fun handleDynamicColorChange(enabled: Boolean) {
state.useDynamicColor = enabled
context.saveDynamicColorState(enabled)
ThemeConfig.updateTheme(dynamicColor = enabled)
}
/**
* 获取DPI大小友好名称
*/
@Composable
fun getDpiFriendlyName(dpi: Int): String {
return when (dpi) {
240 -> stringResource(R.string.dpi_size_small)
320 -> stringResource(R.string.dpi_size_medium)
420 -> stringResource(R.string.dpi_size_large)
560 -> stringResource(R.string.dpi_size_extra_large)
else -> stringResource(R.string.dpi_size_custom)
}
}
/**
* 应用 DPI 设置
*/
fun handleDpiApply() {
if (state.tempDpi != state.currentDpi) {
prefs.edit {
putInt("app_dpi", state.tempDpi)
}
state.currentDpi = state.tempDpi
Toast.makeText(
context,
context.getString(R.string.dpi_applied_success, state.tempDpi),
Toast.LENGTH_SHORT
).show()
val restartIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
restartIntent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(restartIntent)
state.showDpiConfirmDialog = false
}
}
/**
* 处理自定义背景
*/
fun handleCustomBackground(transformedUri: Uri) {
context.saveAndApplyCustomBackground(transformedUri)
state.isCustomBackgroundEnabled = true
CardConfig.cardElevation = 0.dp
CardConfig.isCustomBackgroundEnabled = true
saveCardConfig(context)
Toast.makeText(
context,
context.getString(R.string.background_set_success),
Toast.LENGTH_SHORT
).show()
}
/**
* 处理移除自定义背景
*/
fun handleRemoveCustomBackground() {
context.saveCustomBackground(null)
state.isCustomBackgroundEnabled = false
CardConfig.cardAlpha = 1f
CardConfig.cardDim = 0f
CardConfig.isCustomAlphaSet = false
CardConfig.isCustomDimSet = false
CardConfig.isCustomBackgroundEnabled = false
saveCardConfig(context)
ThemeConfig.preventBackgroundRefresh = false
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
putBoolean("prevent_background_refresh", false)
}
Toast.makeText(
context,
context.getString(R.string.background_removed),
Toast.LENGTH_SHORT
).show()
}
/**
* 处理卡片透明度变更
*/
fun handleCardAlphaChange(newValue: Float) {
state.cardAlpha = newValue
CardConfig.cardAlpha = newValue
CardConfig.isCustomAlphaSet = true
prefs.edit {
putBoolean("is_custom_alpha_set", true)
putFloat("card_alpha", newValue)
}
}
/**
* 处理卡片亮度变更
*/
fun handleCardDimChange(newValue: Float) {
state.cardDim = newValue
CardConfig.cardDim = newValue
CardConfig.isCustomDimSet = true
prefs.edit {
putBoolean("is_custom_dim_set", true)
putFloat("card_dim", newValue)
}
}
/**
* 处理图标变更
*/
fun handleIconChange(newValue: Boolean) {
prefs.edit { putBoolean("use_alt_icon", newValue) }
state.useAltIcon = newValue
toggleLauncherIcon(context, newValue)
Toast.makeText(context, context.getString(R.string.icon_switched), Toast.LENGTH_SHORT).show()
}
/**
* 处理简洁模式变更
*/
fun handleSimpleModeChange(newValue: Boolean) {
prefs.edit { putBoolean("is_simple_mode", newValue) }
state.isSimpleMode = newValue
}
/**
* 处理内核简洁模式变更
*/
fun handleKernelSimpleModeChange(newValue: Boolean) {
prefs.edit { putBoolean("is_kernel_simple_mode", newValue) }
state.isKernelSimpleMode = newValue
}
/**
* 处理隐藏版本变更
*/
fun handleHideVersionChange(newValue: Boolean) {
prefs.edit { putBoolean("is_hide_version", newValue) }
state.isHideVersion = newValue
}
/**
* 处理隐藏其他信息变更
*/
fun handleHideOtherInfoChange(newValue: Boolean) {
prefs.edit { putBoolean("is_hide_other_info", newValue) }
state.isHideOtherInfo = newValue
}
/**
* 处理显示KPM信息变更
*/
fun handleShowKpmInfoChange(newValue: Boolean) {
prefs.edit { putBoolean("show_kpm_info", newValue) }
state.isShowKpmInfo = newValue
}
/**
* 处理隐藏SuSFS状态变更
*/
fun handleHideSusfsStatusChange(newValue: Boolean) {
prefs.edit { putBoolean("is_hide_susfs_status", newValue) }
state.isHideSusfsStatus = newValue
}
/**
* 处理隐藏Zygisk实现变更
*/
fun handleHideZygiskImplementChange(newValue: Boolean) {
prefs.edit { putBoolean("is_hide_zygisk_Implement", newValue) }
state.isHideZygiskImplement = newValue
}
/**
* 处理隐藏链接卡片变更
*/
fun handleHideLinkCardChange(newValue: Boolean) {
prefs.edit { putBoolean("is_hide_link_card", newValue) }
state.isHideLinkCard = newValue
}
/**
* 处理隐藏标签行变更
*/
fun handleHideTagRowChange(newValue: Boolean) {
prefs.edit { putBoolean("is_hide_tag_row", newValue) }
state.isHideTagRow = newValue
}
/**
* 处理显示更多模块信息变更
*/
fun handleShowMoreModuleInfoChange(newValue: Boolean) {
prefs.edit { putBoolean("show_more_module_info", newValue) }
state.showMoreModuleInfo = newValue
}
/**
* 处理SELinux变更
*/
fun handleSelinuxChange(enabled: Boolean) {
val command = if (enabled) "setenforce 1" else "setenforce 0"
Shell.getShell().newJob().add(command).exec().let { result ->
if (result.isSuccess) {
state.selinuxEnabled = enabled
val message = if (enabled)
context.getString(R.string.selinux_enabled_toast)
else
context.getString(R.string.selinux_disabled_toast)
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(
context,
context.getString(R.string.selinux_change_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
/**
* 处理动态管理器配置
*/
fun handleDynamicManagerConfig(enabled: Boolean, size: String, hash: String) {
if (enabled) {
val parsedSize = parseDynamicSignSize(size)
if (parsedSize != null && parsedSize > 0 && hash.length == 64) {
val success = Natives.setDynamicManager(parsedSize, hash)
if (success) {
state.dynamicSignConfig = Natives.DynamicManagerConfig(parsedSize, hash)
state.isDynamicSignEnabled = true
state.dynamicSignSize = size
state.dynamicSignHash = hash
Toast.makeText(
context,
context.getString(R.string.dynamic_manager_set_success),
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
context.getString(R.string.dynamic_manager_set_failed),
Toast.LENGTH_SHORT
).show()
}
} else {
Toast.makeText(
context,
context.getString(R.string.invalid_sign_config),
Toast.LENGTH_SHORT
).show()
}
} else {
val success = Natives.clearDynamicManager()
if (success) {
state.dynamicSignConfig = null
state.isDynamicSignEnabled = false
state.dynamicSignSize = ""
state.dynamicSignHash = ""
Toast.makeText(
context,
context.getString(R.string.dynamic_manager_disabled_success),
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
context.getString(R.string.dynamic_manager_clear_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
/**
* 解析动态签名大小
*/
private fun parseDynamicSignSize(input: String): Int? {
return try {
when {
input.startsWith("0x", true) -> input.substring(2).toInt(16)
else -> input.toInt()
}
} catch (_: NumberFormatException) {
null
}
}
}

View file

@ -0,0 +1,201 @@
package zako.zako.zako.zakoui.screen.moreSettings.component
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.sukisu.ultra.ui.theme.*
private val SETTINGS_GROUP_SPACING = 16.dp
@Composable
fun SettingsCard(
title: String,
icon: ImageVector? = null,
content: @Composable () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = SETTINGS_GROUP_SPACING),
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh),
elevation = getCardElevation(),
shape = MaterialTheme.shapes.medium
) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 16.dp)
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
}
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
}
content()
}
}
}
@Composable
fun SettingItem(
icon: ImageVector,
title: String,
subtitle: String? = null,
onClick: () -> Unit,
iconTint: Color = MaterialTheme.colorScheme.primary,
trailingContent: @Composable (() -> Unit)? = {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 5.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Visible
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Visible
)
}
}
trailingContent?.invoke()
}
}
@Composable
fun SwitchSettingItem(
icon: ImageVector,
title: String,
summary: String? = null,
checked: Boolean,
onChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onChange(!checked) }
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
lineHeight = 20.sp,
)
if (summary != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 16.sp,
)
}
}
Switch(
checked = checked,
onCheckedChange = onChange
)
}
}
@Composable
fun SettingsDivider() {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp)
)
}
@Composable
fun ColorCircle(
color: Color,
isSelected: Boolean,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(20.dp)
.clip(CircleShape)
.background(color)
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = CircleShape
)
} else {
Modifier
}
)
)
}

View file

@ -0,0 +1,620 @@
package zako.zako.zako.zakoui.screen.moreSettings.component
import android.content.Context
import android.content.SharedPreferences
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.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CleaningServices
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Scanner
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.maxkeppeker.sheets.core.models.base.Header
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
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 zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.ConfirmResult
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.screen.SwitchItem
import com.sukisu.ultra.ui.theme.*
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import zako.zako.zako.zakoui.screen.moreSettings.MoreSettingsHandlers
import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState
@Composable
fun MoreSettingsDialogs(
state: MoreSettingsState,
handlers: MoreSettingsHandlers
) {
// 主题模式选择对话框
if (state.showThemeModeDialog) {
SingleChoiceDialog(
title = stringResource(R.string.theme_mode),
options = state.themeOptions,
selectedIndex = state.themeMode,
onOptionSelected = { index ->
handlers.handleThemeModeChange(index)
},
onDismiss = { state.showThemeModeDialog = false }
)
}
// DPI 设置确认对话框
if (state.showDpiConfirmDialog) {
ConfirmDialog(
title = stringResource(R.string.dpi_confirm_title),
message = stringResource(R.string.dpi_confirm_message, state.currentDpi, state.tempDpi),
summaryText = stringResource(R.string.dpi_confirm_summary),
confirmText = stringResource(R.string.confirm),
dismissText = stringResource(R.string.cancel),
onConfirm = { handlers.handleDpiApply() },
onDismiss = {
state.showDpiConfirmDialog = false
state.tempDpi = state.currentDpi
}
)
}
// 主题色选择对话框
if (state.showThemeColorDialog) {
ThemeColorDialog(
onColorSelected = { theme ->
handlers.handleThemeColorChange(theme)
state.showThemeColorDialog = false
},
onDismiss = { state.showThemeColorDialog = false }
)
}
// 动态管理器配置对话框
if (state.showDynamicSignDialog) {
DynamicManagerDialog(
state = state,
onConfirm = { enabled, size, hash ->
handlers.handleDynamicManagerConfig(enabled, size, hash)
state.showDynamicSignDialog = false
},
onDismiss = { state.showDynamicSignDialog = false }
)
}
}
@Composable
fun SingleChoiceDialog(
title: String,
options: List<String>,
selectedIndex: Int,
onOptionSelected: (Int) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
options.forEachIndexed { index, option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onOptionSelected(index)
onDismiss()
}
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedIndex == index,
onClick = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(option)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
fun ConfirmDialog(
title: String,
message: String,
summaryText: String? = null,
confirmText: String = stringResource(R.string.confirm),
dismissText: String = stringResource(R.string.cancel),
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
Text(message)
if (summaryText != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
summaryText,
style = MaterialTheme.typography.bodySmall
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text(confirmText)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(dismissText)
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LanguageSelectionDialog(
onLanguageSelected: (String) -> Unit,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
// Check if should use system language settings
if (LocaleHelper.useSystemLanguageSettings) {
// Android 13+ - Jump to system settings
LocaleHelper.launchSystemLanguageSettings(context)
onDismiss()
} else {
// Android < 13 - Show app language selector
// Dynamically detect supported locales from resources
val supportedLocales = remember {
val locales = mutableListOf<java.util.Locale>()
// Add system default first
locales.add(java.util.Locale.ROOT) // This will represent "System Default"
// Dynamically detect available locales by checking resource directories
val resourceDirs = listOf(
"ar", "bg", "de", "fa", "fr", "hu", "in", "it",
"ja", "ko", "pl", "pt-rBR", "ru", "th", "tr",
"uk", "vi", "zh-rCN", "zh-rTW"
)
resourceDirs.forEach { dir ->
try {
val locale = when {
dir.contains("-r") -> {
val parts = dir.split("-r")
java.util.Locale.Builder()
.setLanguage(parts[0])
.setRegion(parts[1])
.build()
}
else -> java.util.Locale.Builder()
.setLanguage(dir)
.build()
}
// Test if this locale has translated resources
val config = android.content.res.Configuration()
config.setLocale(locale)
val localizedContext = context.createConfigurationContext(config)
// Try to get a translated string to verify the locale is supported
val testString = localizedContext.getString(R.string.settings_language)
val defaultString = context.getString(R.string.settings_language)
// If the string is different or it's English, it's supported
if (testString != defaultString || locale.language == "en") {
locales.add(locale)
}
} catch (_: Exception) {
// Skip unsupported locales
}
}
// Sort by display name
val sortedLocales = locales.drop(1).sortedBy { it.getDisplayName(it) }
mutableListOf<java.util.Locale>().apply {
add(locales.first()) // System default first
addAll(sortedLocales)
}
}
val allOptions = supportedLocales.map { locale ->
val tag = if (locale == java.util.Locale.ROOT) {
"system"
} else if (locale.country.isEmpty()) {
locale.language
} else {
"${locale.language}_${locale.country}"
}
val displayName = if (locale == java.util.Locale.ROOT) {
context.getString(R.string.language_system_default)
} else {
locale.getDisplayName(locale)
}
tag to displayName
}
val currentLocale = prefs.getString("app_locale", "system") ?: "system"
val options = allOptions.map { (tag, displayName) ->
ListOption(
titleText = displayName,
selected = currentLocale == tag
)
}
var selectedIndex by remember {
mutableIntStateOf(allOptions.indexOfFirst { (tag, _) -> currentLocale == tag })
}
ListDialog(
state = rememberUseCaseState(
visible = true,
onFinishedRequest = {
if (selectedIndex >= 0 && selectedIndex < allOptions.size) {
val newLocale = allOptions[selectedIndex].first
prefs.edit { putString("app_locale", newLocale) }
onLanguageSelected(newLocale)
}
onDismiss()
},
onCloseRequest = {
onDismiss()
}
),
header = Header.Default(
title = stringResource(R.string.settings_language),
),
selection = ListSelection.Single(
showRadioButtons = true,
options = options
) { index, _ ->
selectedIndex = index
}
)
}
}
@Composable
fun ThemeColorDialog(
onColorSelected: (ThemeColors) -> Unit,
onDismiss: () -> Unit
) {
val themeColorOptions = listOf(
stringResource(R.string.color_default) to ThemeColors.Default,
stringResource(R.string.color_green) to ThemeColors.Green,
stringResource(R.string.color_purple) to ThemeColors.Purple,
stringResource(R.string.color_orange) to ThemeColors.Orange,
stringResource(R.string.color_pink) to ThemeColors.Pink,
stringResource(R.string.color_gray) to ThemeColors.Gray,
stringResource(R.string.color_yellow) to ThemeColors.Yellow
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.choose_theme_color)) },
text = {
Column {
themeColorOptions.forEach { (name, theme) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onColorSelected(theme) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
val isDark = isSystemInDarkTheme()
Box(
modifier = Modifier.padding(end = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
ColorCircle(
color = if (isDark) theme.primaryDark else theme.primaryLight,
isSelected = false,
modifier = Modifier.padding(horizontal = 2.dp)
)
ColorCircle(
color = if (isDark) theme.secondaryDark else theme.secondaryLight,
isSelected = false,
modifier = Modifier.padding(horizontal = 2.dp)
)
ColorCircle(
color = if (isDark) theme.tertiaryDark else theme.tertiaryLight,
isSelected = false,
modifier = Modifier.padding(horizontal = 2.dp)
)
}
}
Text(name)
Spacer(modifier = Modifier.weight(1f))
// 当前选中的主题显示选中标记
if (ThemeConfig.currentTheme::class == theme::class) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
},
confirmButton = {
Button(
onClick = onDismiss
) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
fun DynamicManagerDialog(
state: MoreSettingsState,
onConfirm: (Boolean, String, String) -> Unit,
onDismiss: () -> Unit
) {
var localEnabled by remember { mutableStateOf(state.isDynamicSignEnabled) }
var localSize by remember { mutableStateOf(state.dynamicSignSize) }
var localHash by remember { mutableStateOf(state.dynamicSignHash) }
fun parseDynamicSignSize(input: String): Int? {
return try {
when {
input.startsWith("0x", true) -> input.substring(2).toInt(16)
else -> input.toInt()
}
} catch (_: NumberFormatException) {
null
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dynamic_manager_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
// 启用开关
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { localEnabled = !localEnabled }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Switch(
checked = localEnabled,
onCheckedChange = { localEnabled = it }
)
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.enable_dynamic_manager))
}
Spacer(modifier = Modifier.height(16.dp))
// 签名大小输入
OutlinedTextField(
value = localSize,
onValueChange = { input ->
val isValid = when {
input.isEmpty() -> true
input.matches(Regex("^\\d+$")) -> true
input.matches(Regex("^0[xX][0-9a-fA-F]*$")) -> true
else -> false
}
if (isValid) {
localSize = input
}
},
label = { Text(stringResource(R.string.signature_size)) },
enabled = localEnabled,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text
)
)
Spacer(modifier = Modifier.height(12.dp))
// 签名哈希输入
OutlinedTextField(
value = localHash,
onValueChange = { hash ->
if (hash.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) {
localHash = hash
}
},
label = { Text(stringResource(R.string.signature_hash)) },
enabled = localEnabled,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
supportingText = {
Text(stringResource(R.string.hash_must_be_64_chars))
},
isError = localEnabled && localHash.isNotEmpty() && localHash.length != 64
)
}
},
confirmButton = {
Button(
onClick = { onConfirm(localEnabled, localSize, localHash) },
enabled = if (localEnabled) {
parseDynamicSignSize(localSize)?.let { it > 0 } == true &&
localHash.length == 64
} else true
) {
Text(stringResource(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
fun UidScannerSection(
prefs: SharedPreferences,
snackBarHost: SnackbarHostState,
scope: CoroutineScope,
context: Context
) {
if (Natives.version < Natives.MINIMAL_SUPPORTED_UID_SCANNER) return
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)
}
}
SwitchItem(
icon = Icons.Filled.Scanner,
title = stringResource(R.string.uid_auto_scan_title),
summary = stringResource(R.string.uid_auto_scan_summary),
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) {
snackBarHost.showSnackbar(
context.getString(R.string.uid_scanner_setting_failed)
)
}
}
}
}
)
AnimatedVisibility(
visible = autoOn,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
SwitchItem(
icon = Icons.Filled.Groups,
title = stringResource(R.string.uid_multi_user_scan_title),
summary = stringResource(R.string.uid_multi_user_scan_summary),
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 {
snackBarHost.showSnackbar(
context.getString(R.string.uid_scanner_setting_failed)
)
}
}
}
}
)
}
AnimatedVisibility(
visible = autoOn,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
val confirmDialog = rememberConfirmDialog()
com.sukisu.ultra.ui.screen.SettingItem(
icon = Icons.Filled.CleaningServices,
title = stringResource(R.string.clean_runtime_environment),
summary = stringResource(R.string.clean_runtime_environment_summary),
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)
snackBarHost.showSnackbar(
context.getString(R.string.clean_runtime_environment_success)
)
} else {
snackBarHost.showSnackbar(
context.getString(R.string.clean_runtime_environment_failed)
)
}
}
}
}
)
}
}

View file

@ -0,0 +1,101 @@
package zako.zako.zako.zakoui.screen.moreSettings.state
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
import com.sukisu.ultra.Natives
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.theme.CardConfig
import com.sukisu.ultra.ui.theme.ThemeConfig
/**
* 更多设置状态管理
*/
@Stable
class MoreSettingsState(
val context: Context,
val prefs: SharedPreferences,
val systemIsDark: Boolean
) {
// 主题模式选择
var themeMode by mutableIntStateOf(
when (ThemeConfig.forceDarkMode) {
true -> 2 // 深色
false -> 1 // 浅色
null -> 0 // 跟随系统
}
)
// 动态颜色开关状态
var useDynamicColor by mutableStateOf(ThemeConfig.useDynamicColor)
// 语言设置
var showLanguageDialog by mutableStateOf(false)
var currentAppLocale by mutableStateOf(LocaleHelper.getCurrentAppLocale(context))
// 对话框显示状态
var showThemeModeDialog by mutableStateOf(false)
var showThemeColorDialog by mutableStateOf(false)
var showDpiConfirmDialog by mutableStateOf(false)
var showImageEditor by mutableStateOf(false)
// 动态管理器配置状态
var dynamicSignConfig by mutableStateOf<Natives.DynamicManagerConfig?>(null)
var isDynamicSignEnabled by mutableStateOf(false)
var dynamicSignSize by mutableStateOf("")
var dynamicSignHash by mutableStateOf("")
var showDynamicSignDialog by mutableStateOf(false)
// 各种设置开关状态
var isSimpleMode by mutableStateOf(prefs.getBoolean("is_simple_mode", false))
var isHideVersion by mutableStateOf(prefs.getBoolean("is_hide_version", false))
var isHideOtherInfo by mutableStateOf(prefs.getBoolean("is_hide_other_info", false))
var isShowKpmInfo by mutableStateOf(prefs.getBoolean("show_kpm_info", false))
var isHideZygiskImplement by mutableStateOf(prefs.getBoolean("is_hide_zygisk_Implement", false))
var isHideSusfsStatus by mutableStateOf(prefs.getBoolean("is_hide_susfs_status", false))
var isHideLinkCard by mutableStateOf(prefs.getBoolean("is_hide_link_card", false))
var isHideTagRow by mutableStateOf(prefs.getBoolean("is_hide_tag_row", false))
var isKernelSimpleMode by mutableStateOf(prefs.getBoolean("is_kernel_simple_mode", false))
var showMoreModuleInfo by mutableStateOf(prefs.getBoolean("show_more_module_info", false))
var useAltIcon by mutableStateOf(prefs.getBoolean("use_alt_icon", false))
// SELinux状态
var selinuxEnabled by mutableStateOf(false)
// 卡片配置状态
var cardAlpha by mutableFloatStateOf(CardConfig.cardAlpha)
var cardDim by mutableFloatStateOf(CardConfig.cardDim)
var isCustomBackgroundEnabled by mutableStateOf(ThemeConfig.customBackgroundUri != null)
// 图片选择状态
var selectedImageUri by mutableStateOf<Uri?>(null)
// DPI 设置
val systemDpi = context.resources.displayMetrics.densityDpi
var currentDpi by mutableIntStateOf(prefs.getInt("app_dpi", systemDpi))
var tempDpi by mutableIntStateOf(currentDpi)
var isDpiCustom by mutableStateOf(true)
// 主题模式选项
val themeOptions = listOf(
context.getString(R.string.theme_follow_system),
context.getString(R.string.theme_light),
context.getString(R.string.theme_dark)
)
// 预设 DPI 选项
val dpiPresets = mapOf(
context.getString(R.string.dpi_size_small) to 240,
context.getString(R.string.dpi_size_medium) to 320,
context.getString(R.string.dpi_size_large) to 420,
context.getString(R.string.dpi_size_extra_large) to 560
)
}

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