Repo created
This commit is contained in:
parent
0bf9b15769
commit
b7554a5383
363 changed files with 72328 additions and 0 deletions
2
manager/app/.gitignore
vendored
Normal file
2
manager/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/build
|
||||
/release/
|
||||
169
manager/app/build.gradle.kts
Normal file
169
manager/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
import com.android.build.gradle.tasks.PackageAndroidArtifact
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
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
|
||||
|
||||
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 {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
prefab = true
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
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("src/main/cpp/CMakeLists.txt")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
}
|
||||
46
manager/app/proguard-rules.pro
vendored
Normal file
46
manager/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
-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 { *; }
|
||||
73
manager/app/src/main/AndroidManifest.xml
Normal file
73
manager/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?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:supportsRtl="true"
|
||||
android:theme="@style/Theme.KernelSU"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
tools:targetApi="34">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.KernelSU">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".ui.MainActivityAlias"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_alt"
|
||||
android:roundIcon="@mipmap/ic_launcher_alt_round"
|
||||
android:targetActivity=".ui.MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
BIN
manager/app/src/main/assets/5_10-mkbootfs
Normal file
BIN
manager/app/src/main/assets/5_10-mkbootfs
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/5_15+-mkbootfs
Normal file
BIN
manager/app/src/main/assets/5_15+-mkbootfs
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/kpimg
Normal file
BIN
manager/app/src/main/assets/kpimg
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/kptools
Normal file
BIN
manager/app/src/main/assets/kptools
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/ksu_susfs_1.5.7
Normal file
BIN
manager/app/src/main/assets/ksu_susfs_1.5.7
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/ksu_susfs_1.5.8
Normal file
BIN
manager/app/src/main/assets/ksu_susfs_1.5.8
Normal file
Binary file not shown.
BIN
manager/app/src/main/assets/ksu_susfs_1.5.9
Normal file
BIN
manager/app/src/main/assets/ksu_susfs_1.5.9
Normal file
Binary file not shown.
27
manager/app/src/main/cpp/CMakeLists.txt
Normal file
27
manager/app/src/main/cpp/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# 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(zako
|
||||
SHARED
|
||||
jni.c
|
||||
ksu.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(zako ${log-lib} ${zakosign-lib})
|
||||
else()
|
||||
target_link_libraries(zako ${log-lib})
|
||||
endif()
|
||||
434
manager/app/src/main/cpp/jni.c
Normal file
434
manager/app/src/main/cpp/jni.c
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
#include "prelude.h"
|
||||
#include "ksu.h"
|
||||
|
||||
#include <jni.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <android/log.h>
|
||||
#include <string.h>
|
||||
|
||||
|
||||
NativeBridge(becomeManager, jboolean, jstring pkg) {
|
||||
const char* cpkg = GetEnvironment()->GetStringUTFChars(env, pkg, JNI_FALSE);
|
||||
bool result = become_manager(cpkg);
|
||||
|
||||
GetEnvironment()->ReleaseStringUTFChars(env, pkg, cpkg);
|
||||
return result;
|
||||
}
|
||||
|
||||
NativeBridgeNP(getVersion, jint) {
|
||||
return get_version();
|
||||
}
|
||||
|
||||
// get VERSION FULL
|
||||
NativeBridgeNP(getFullVersion, jstring) {
|
||||
char buff[255] = { 0 };
|
||||
get_full_version((char *) &buff);
|
||||
return GetEnvironment()->NewStringUTF(env, buff);
|
||||
}
|
||||
|
||||
NativeBridgeNP(getAllowList, jintArray) {
|
||||
int uids[1024];
|
||||
int size = 0;
|
||||
bool result = get_allow_list(uids, &size);
|
||||
|
||||
LogDebug("getAllowList: %d, size: %d", result, size);
|
||||
|
||||
if (result) {
|
||||
jintArray array = GetEnvironment()->NewIntArray(env, size);
|
||||
GetEnvironment()->SetIntArrayRegion(env, array, 0, size, uids);
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
return GetEnvironment()->NewIntArray(env, 0);
|
||||
}
|
||||
|
||||
NativeBridgeNP(isSafeMode, jboolean) {
|
||||
return is_safe_mode();
|
||||
}
|
||||
|
||||
NativeBridgeNP(isLkmMode, jboolean) {
|
||||
return is_lkm_mode();
|
||||
}
|
||||
|
||||
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(key, &profile);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Check if KPM is enabled
|
||||
NativeBridgeNP(isKPMEnabled, jboolean) {
|
||||
return is_KPM_enable();
|
||||
}
|
||||
|
||||
// Get HOOK type
|
||||
NativeBridgeNP(getHookType, jstring) {
|
||||
char hook_type[16];
|
||||
get_hook_type(hook_type, sizeof(hook_type));
|
||||
return GetEnvironment()->NewStringUTF(env, hook_type);
|
||||
}
|
||||
|
||||
// SuSFS Related Function Status
|
||||
NativeBridgeNP(getSusfsFeatureStatus, jobject) {
|
||||
struct susfs_feature_status status;
|
||||
bool result = get_susfs_feature_status(&status);
|
||||
|
||||
if (!result) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
jclass cls = GetEnvironment()->FindClass(env, "com/sukisu/ultra/Natives$SusfsFeatureStatus");
|
||||
jmethodID constructor = GetEnvironment()->GetMethodID(env, cls, "<init>", "()V");
|
||||
jobject obj = GetEnvironment()->NewObject(env, cls, constructor);
|
||||
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusSusPath, status.status_sus_path);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusSusMount, status.status_sus_mount);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusAutoDefaultMount, status.status_auto_default_mount);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusAutoBindMount, status.status_auto_bind_mount);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusSusKstat, status.status_sus_kstat);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusTryUmount, status.status_try_umount);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusAutoTryUmountBind, status.status_auto_try_umount_bind);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusSpoofUname, status.status_spoof_uname);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusEnableLog, status.status_enable_log);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusHideSymbols, status.status_hide_symbols);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusSpoofCmdline, status.status_spoof_cmdline);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusOpenRedirect, status.status_open_redirect);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusMagicMount, status.status_magic_mount);
|
||||
SET_BOOLEAN_FIELD(obj, cls, statusSusSu, status.status_sus_su);
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
252
manager/app/src/main/cpp/ksu.c
Normal file
252
manager/app/src/main/cpp/ksu.c
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
//
|
||||
// Created by weishu on 2022/12/9.
|
||||
//
|
||||
|
||||
#include <sys/prctl.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.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
|
||||
|
||||
#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_GET_SUSFS_FEATURE_STATUS 102
|
||||
#define CMD_DYNAMIC_MANAGER 103
|
||||
#define CMD_GET_MANAGERS 104
|
||||
|
||||
#define DYNAMIC_MANAGER_OP_SET 0
|
||||
#define DYNAMIC_MANAGER_OP_GET 1
|
||||
#define DYNAMIC_MANAGER_OP_CLEAR 2
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
bool become_manager(const char* pkg) {
|
||||
char param[128];
|
||||
uid_t uid = getuid();
|
||||
uint32_t userId = uid / 100000;
|
||||
if (userId == 0) {
|
||||
sprintf(param, "/data/data/%s", pkg);
|
||||
} else {
|
||||
snprintf(param, sizeof(param), "/data/user/%d/%s", userId, pkg);
|
||||
}
|
||||
|
||||
return ksuctl(CMD_BECOME_MANAGER, param, NULL);
|
||||
}
|
||||
|
||||
// cache the result to avoid unnecessary syscall
|
||||
static bool is_lkm;
|
||||
int get_version() {
|
||||
int32_t version = -1;
|
||||
int32_t flags = 0;
|
||||
ksuctl(CMD_GET_VERSION, &version, &flags);
|
||||
if (!is_lkm && (flags & 0x1)) {
|
||||
is_lkm = true;
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
void get_full_version(char* buff) {
|
||||
ksuctl(CMD_GET_VERSION_FULL, buff, NULL);
|
||||
}
|
||||
|
||||
bool get_allow_list(int *uids, int *size) {
|
||||
return ksuctl(CMD_GET_SU_LIST, uids, size);
|
||||
}
|
||||
|
||||
bool is_safe_mode() {
|
||||
return ksuctl(CMD_CHECK_SAFEMODE, NULL, NULL);
|
||||
}
|
||||
|
||||
bool is_lkm_mode() {
|
||||
// you should call get_version first!
|
||||
return is_lkm;
|
||||
}
|
||||
|
||||
bool uid_should_umount(int uid) {
|
||||
int should;
|
||||
return ksuctl(CMD_IS_UID_SHOULD_UMOUNT, (void*) ((size_t) uid), &should) && should;
|
||||
}
|
||||
|
||||
bool set_app_profile(const struct app_profile* profile) {
|
||||
return ksuctl(CMD_SET_APP_PROFILE, (void*) profile, NULL);
|
||||
}
|
||||
|
||||
bool get_app_profile(char* key, struct app_profile* profile) {
|
||||
return ksuctl(CMD_GET_APP_PROFILE, profile, NULL);
|
||||
}
|
||||
|
||||
bool set_su_enabled(bool enabled) {
|
||||
return ksuctl(CMD_ENABLE_SU, (void*) enabled, NULL);
|
||||
}
|
||||
|
||||
bool 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 is_KPM_enable() {
|
||||
int enabled = false;
|
||||
ksuctl(CMD_ENABLE_KPM, &enabled, NULL);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
bool 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);
|
||||
hook_type[size - 1] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get_susfs_feature_status(struct susfs_feature_status* status) {
|
||||
if (status == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ksuctl(CMD_GET_SUSFS_FEATURE_STATUS, status, NULL);
|
||||
}
|
||||
|
||||
bool 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 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 clear_dynamic_manager() {
|
||||
struct dynamic_manager_user_config config;
|
||||
config.operation = DYNAMIC_MANAGER_OP_CLEAR;
|
||||
return ksuctl(CMD_DYNAMIC_MANAGER, &config, NULL);
|
||||
}
|
||||
|
||||
bool get_managers_list(struct manager_list_info* info) {
|
||||
if (info == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ksuctl(CMD_GET_MANAGERS, info, NULL);
|
||||
}
|
||||
|
||||
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 fd = zako_sys_file_open(input);
|
||||
if (fd < 0) {
|
||||
LogDebug("verify_module_signature: failed to open file: %s", input);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t results = zako_file_verify_esig(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(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
|
||||
}
|
||||
140
manager/app/src/main/cpp/ksu.h
Normal file
140
manager/app/src/main/cpp/ksu.h
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
//
|
||||
// Created by weishu on 2022/12/9.
|
||||
//
|
||||
|
||||
#ifndef KERNELSU_KSU_H
|
||||
#define KERNELSU_KSU_H
|
||||
|
||||
#include "prelude.h"
|
||||
#include <linux/capability.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
bool become_manager(const char *);
|
||||
|
||||
void get_full_version(char* buff);
|
||||
|
||||
int get_version();
|
||||
|
||||
bool get_allow_list(int *uids, int *size);
|
||||
|
||||
bool uid_should_umount(int uid);
|
||||
|
||||
bool is_safe_mode();
|
||||
|
||||
bool is_lkm_mode();
|
||||
|
||||
#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
|
||||
|
||||
struct dynamic_manager_user_config {
|
||||
unsigned int operation;
|
||||
unsigned int size;
|
||||
char hash[65];
|
||||
};
|
||||
|
||||
// SUSFS Functional State Structures
|
||||
struct susfs_feature_status {
|
||||
bool status_sus_path;
|
||||
bool status_sus_mount;
|
||||
bool status_auto_default_mount;
|
||||
bool status_auto_bind_mount;
|
||||
bool status_sus_kstat;
|
||||
bool status_try_umount;
|
||||
bool status_auto_try_umount_bind;
|
||||
bool status_spoof_uname;
|
||||
bool status_enable_log;
|
||||
bool status_hide_symbols;
|
||||
bool status_spoof_cmdline;
|
||||
bool status_open_redirect;
|
||||
bool status_magic_mount;
|
||||
bool status_sus_su;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
bool get_app_profile(char* key, struct app_profile* profile);
|
||||
|
||||
bool set_su_enabled(bool enabled);
|
||||
|
||||
bool is_su_enabled();
|
||||
|
||||
bool is_KPM_enable();
|
||||
|
||||
bool get_hook_type(char* hook_type, size_t size);
|
||||
|
||||
bool get_susfs_feature_status(struct susfs_feature_status* status);
|
||||
|
||||
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);
|
||||
|
||||
#endif //KERNELSU_KSU_H
|
||||
70
manager/app/src/main/cpp/prelude.h
Normal file
70
manager/app/src/main/cpp/prelude.h
Normal 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
|
||||
BIN
manager/app/src/main/ic_launcher-playstore.png
Normal file
BIN
manager/app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
|
|
@ -0,0 +1,151 @@
|
|||
package com.sukisu.ultra
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityOptions
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
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 java.io.File
|
||||
import java.util.*
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
lateinit var ksuApp: KernelSUApplication
|
||||
|
||||
class KernelSUApplication : Application() {
|
||||
private var currentActivity: Activity? = null
|
||||
|
||||
private val activityLifecycleCallbacks = object : ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
currentActivity = activity
|
||||
}
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
currentActivity = activity
|
||||
}
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
currentActivity = activity
|
||||
}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
if (currentActivity == activity) {
|
||||
currentActivity = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
val prefs = base.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val languageCode = prefs.getString("app_language", "") ?: ""
|
||||
|
||||
var context = base
|
||||
if (languageCode.isNotEmpty()) {
|
||||
val locale = Locale.forLanguageTag(languageCode)
|
||||
Locale.setDefault(locale)
|
||||
|
||||
val config = Configuration(base.resources.configuration)
|
||||
config.setLocale(locale)
|
||||
|
||||
context = base.createConfigurationContext(config)
|
||||
}
|
||||
|
||||
super.attachBaseContext(context)
|
||||
}
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
override fun getResources(): Resources {
|
||||
val resources = super.getResources()
|
||||
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val languageCode = prefs.getString("app_language", "") ?: ""
|
||||
|
||||
if (languageCode.isNotEmpty()) {
|
||||
val locale = Locale.forLanguageTag(languageCode)
|
||||
val config = Configuration(resources.configuration)
|
||||
config.setLocale(locale)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
return createConfigurationContext(config).resources
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(config, resources.displayMetrics)
|
||||
}
|
||||
}
|
||||
|
||||
return resources
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ksuApp = this
|
||||
|
||||
// 注册Activity生命周期回调
|
||||
registerActivityLifecycleCallbacks(activityLifecycleCallbacks)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
applyLanguageSetting()
|
||||
}
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
private fun applyLanguageSetting() {
|
||||
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val languageCode = prefs.getString("app_language", "") ?: ""
|
||||
|
||||
if (languageCode.isNotEmpty()) {
|
||||
val locale = Locale.forLanguageTag(languageCode)
|
||||
Locale.setDefault(locale)
|
||||
|
||||
val resources = resources
|
||||
val config = Configuration(resources.configuration)
|
||||
config.setLocale(locale)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
createConfigurationContext(config)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(config, resources.displayMetrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加刷新当前Activity的方法
|
||||
fun refreshCurrentActivity() {
|
||||
currentActivity?.let { activity ->
|
||||
val intent = activity.intent
|
||||
activity.finish()
|
||||
|
||||
val options = ActivityOptions.makeCustomAnimation(
|
||||
activity, android.R.anim.fade_in, android.R.anim.fade_out
|
||||
)
|
||||
activity.startActivity(intent, options.toBundle())
|
||||
}
|
||||
}
|
||||
}
|
||||
32
manager/app/src/main/java/com/sukisu/ultra/Kernels.kt
Normal file
32
manager/app/src/main/java/com/sukisu/ultra/Kernels.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
253
manager/app/src/main/java/com/sukisu/ultra/Natives.kt
Normal file
253
manager/app/src/main/java/com/sukisu/ultra/Natives.kt
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
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.
|
||||
const val MINIMAL_SUPPORTED_KERNEL = 11071
|
||||
const val MINIMAL_SUPPORTED_KERNEL_FULL = "v3.1.5"
|
||||
|
||||
// 11640: Support query working mode, LKM or GKI
|
||||
// when MINIMAL_SUPPORTED_KERNEL > 11640, we can remove this constant.
|
||||
const val MINIMAL_SUPPORTED_KERNEL_LKM = 11648
|
||||
|
||||
// 12040: Support disable sucompat mode
|
||||
const val MINIMAL_SUPPORTED_SU_COMPAT = 12040
|
||||
const val KERNEL_SU_DOMAIN = "u:r:su:s0"
|
||||
|
||||
const val MINIMAL_SUPPORTED_KPM = 12800
|
||||
|
||||
const val MINIMAL_SUPPORTED_DYNAMIC_MANAGER = 13215
|
||||
|
||||
const val MINIMAL_SUPPORTED_UID_SCANNER = 13347
|
||||
|
||||
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("zako")
|
||||
}
|
||||
|
||||
// become root manager, return true if success.
|
||||
external fun becomeManager(pkg: String?): Boolean
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
*/
|
||||
external fun getSusfsFeatureStatus(): SusfsFeatureStatus?
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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 < MINIMAL_SUPPORTED_KERNEL) return true
|
||||
return isVersionLessThan(getFullVersion(), MINIMAL_SUPPORTED_KERNEL_FULL)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Parcelize
|
||||
@Keep
|
||||
data class SusfsFeatureStatus(
|
||||
val statusSusPath: Boolean = false,
|
||||
val statusSusMount: Boolean = false,
|
||||
val statusAutoDefaultMount: Boolean = false,
|
||||
val statusAutoBindMount: Boolean = false,
|
||||
val statusSusKstat: Boolean = false,
|
||||
val statusTryUmount: Boolean = false,
|
||||
val statusAutoTryUmountBind: Boolean = false,
|
||||
val statusSpoofUname: Boolean = false,
|
||||
val statusEnableLog: Boolean = false,
|
||||
val statusHideSymbols: Boolean = false,
|
||||
val statusSpoofCmdline: Boolean = false,
|
||||
val statusOpenRedirect: Boolean = false,
|
||||
val statusMagicMount: Boolean = false,
|
||||
val statusOverlayfsAutoKstat: Boolean = false,
|
||||
val statusSusSu: Boolean = false
|
||||
) : Parcelable
|
||||
|
||||
@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("")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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", "Don’t 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 caller’s 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 system’s range of legal group IDs"),
|
||||
CAP_SETPCAP(8, "SETPCAP", "If file capabilities are supported: grant or remove any capability in the caller’s 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)"),
|
||||
}
|
||||
130
manager/app/src/main/java/com/sukisu/ultra/profile/Groups.kt
Normal file
130
manager/app/src/main/java/com/sukisu/ultra/profile/Groups.kt
Normal 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"),
|
||||
}
|
||||
137
manager/app/src/main/java/com/sukisu/ultra/ui/KsuService.kt
Normal file
137
manager/app/src/main/java/com/sukisu/ultra/ui/KsuService.kt
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package com.sukisu.ultra.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
import rikka.parcelablelist.ParcelableListSlice
|
||||
import java.lang.reflect.Method
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/7/2.
|
||||
*/
|
||||
class KsuService : RootService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KsuService"
|
||||
private const val DESCRIPTOR = "com.sukisu.ultra.IKsuInterface"
|
||||
private const val TRANSACTION_GET_PACKAGES = IBinder.FIRST_CALL_TRANSACTION + 0
|
||||
}
|
||||
|
||||
interface IKsuInterface : IInterface {
|
||||
fun getPackages(flags: Int): ParcelableListSlice<PackageInfo>
|
||||
}
|
||||
|
||||
abstract class Stub : Binder(), IKsuInterface {
|
||||
init {
|
||||
attachInterface(this, DESCRIPTOR)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun asInterface(obj: IBinder?): IKsuInterface? {
|
||||
if (obj == null) return null
|
||||
val iin = obj.queryLocalInterface(DESCRIPTOR)
|
||||
return if (iin != null && iin is IKsuInterface) {
|
||||
iin
|
||||
} else {
|
||||
Proxy(obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun asBinder(): IBinder = this
|
||||
|
||||
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
|
||||
val descriptor = DESCRIPTOR
|
||||
when (code) {
|
||||
INTERFACE_TRANSACTION -> {
|
||||
reply?.writeString(descriptor)
|
||||
return true
|
||||
}
|
||||
TRANSACTION_GET_PACKAGES -> {
|
||||
data.enforceInterface(descriptor)
|
||||
val flagsArg = data.readInt()
|
||||
val result = getPackages(flagsArg)
|
||||
reply?.writeNoException()
|
||||
reply?.writeInt(1)
|
||||
result.writeToParcel(reply!!, Parcelable.PARCELABLE_WRITE_RETURN_VALUE)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onTransact(code, data, reply, flags)
|
||||
}
|
||||
|
||||
private class Proxy(private val mRemote: IBinder) : IKsuInterface {
|
||||
override fun getPackages(flags: Int): ParcelableListSlice<PackageInfo> {
|
||||
val data = Parcel.obtain()
|
||||
val reply = Parcel.obtain()
|
||||
return try {
|
||||
data.writeInterfaceToken(DESCRIPTOR)
|
||||
data.writeInt(flags)
|
||||
mRemote.transact(TRANSACTION_GET_PACKAGES, data, reply, 0)
|
||||
reply.readException()
|
||||
if (reply.readInt() != 0) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
ParcelableListSlice.CREATOR.createFromParcel(reply) as ParcelableListSlice<PackageInfo>
|
||||
} else {
|
||||
ParcelableListSlice(emptyList<PackageInfo>())
|
||||
}
|
||||
} finally {
|
||||
reply.recycle()
|
||||
data.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun asBinder(): IBinder = mRemote
|
||||
}
|
||||
}
|
||||
|
||||
inner class KsuInterfaceImpl : Stub() {
|
||||
override fun getPackages(flags: Int): ParcelableListSlice<PackageInfo> {
|
||||
val list = getInstalledPackagesAll(flags)
|
||||
Log.i(TAG, "getPackages: ${list.size}")
|
||||
return ParcelableListSlice(list)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return KsuInterfaceImpl()
|
||||
}
|
||||
|
||||
private fun getUserIds(): List<Int> {
|
||||
val result = mutableListOf<Int>()
|
||||
val um = getSystemService(USER_SERVICE) as UserManager
|
||||
val userProfiles = um.userProfiles
|
||||
for (userProfile in userProfiles) {
|
||||
result.add(userProfile.hashCode())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun getInstalledPackagesAll(flags: Int): ArrayList<PackageInfo> {
|
||||
val packages = ArrayList<PackageInfo>()
|
||||
for (userId in getUserIds()) {
|
||||
Log.i(TAG, "getInstalledPackagesAll: $userId")
|
||||
packages.addAll(getInstalledPackagesAsUser(flags, userId))
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
private fun getInstalledPackagesAsUser(flags: Int, userId: Int): List<PackageInfo> {
|
||||
return try {
|
||||
val pm = packageManager
|
||||
val getInstalledPackagesAsUser: Method = pm.javaClass.getDeclaredMethod(
|
||||
"getInstalledPackagesAsUser",
|
||||
Int::class.java,
|
||||
Int::class.java
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
getInstalledPackagesAsUser.invoke(pm, flags, userId) as List<PackageInfo>
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "err", e)
|
||||
ArrayList()
|
||||
}
|
||||
}
|
||||
}
|
||||
265
manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt
Normal file
265
manager/app/src/main/java/com/sukisu/ultra/ui/MainActivity.kt
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
package com.sukisu.ultra.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
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.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 io.sukisu.ultra.UltraToolInstall
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import zako.zako.zako.zakoui.activity.component.BottomBar
|
||||
import zako.zako.zako.zakoui.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 lateinit var themeChangeObserver: ThemeChangeContentObserver
|
||||
|
||||
// 添加标记避免重复初始化
|
||||
private var isInitialized = false
|
||||
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
val context = LocaleUtils.applyLocale(newBase)
|
||||
super.attachBaseContext(context)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
try {
|
||||
// 确保应用正确的语言设置
|
||||
LocaleUtils.applyLanguageSetting(this)
|
||||
|
||||
// 应用自定义 DPI
|
||||
DisplayUtils.applyCustomDpi(this)
|
||||
|
||||
// Enable edge to edge
|
||||
enableEdgeToEdge()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 使用标记控制初始化流程
|
||||
if (!isInitialized) {
|
||||
initializeViewModels()
|
||||
initializeData()
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
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 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()
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
homeViewModel.initializeData()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// 数据刷新协程
|
||||
DataRefreshUtils.startDataRefreshCoroutine(lifecycleScope)
|
||||
DataRefreshUtils.startSettingsMonitorCoroutine(lifecycleScope, this, settingsStateFlow)
|
||||
|
||||
// 初始化主题相关设置
|
||||
ThemeUtils.initializeThemeSettings(this, settingsStateFlow)
|
||||
|
||||
val isManager = Natives.becomeManager(packageName)
|
||||
if (isManager) {
|
||||
install()
|
||||
UltraToolInstall.tryToInstall()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
try {
|
||||
super.onResume()
|
||||
LocaleUtils.applyLanguageSetting(this)
|
||||
ThemeUtils.onActivityResume()
|
||||
|
||||
// 仅在需要时刷新数据
|
||||
if (isInitialized) {
|
||||
refreshData()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshData() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
superUserViewModel.fetchAppList()
|
||||
homeViewModel.initializeData()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
try {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
LocaleUtils.applyLanguageSetting(this)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,454 @@
|
|||
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.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
update = {
|
||||
Markwon.create(it.context).setMarkdown(it, content)
|
||||
it.setTextColor(contentColor.toArgb())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Size
|
||||
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.util.BackgroundTransformation
|
||||
import com.sukisu.ultra.ui.util.saveTransformedBackground
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun ImageEditorDialog(
|
||||
imageUri: Uri,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Uri) -> Unit
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var lastScale by remember { mutableFloatStateOf(1f) }
|
||||
var lastOffsetX by remember { mutableFloatStateOf(0f) }
|
||||
var lastOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
var imageSize by remember { mutableStateOf(Size.Zero) }
|
||||
var screenSize by remember { mutableStateOf(Size.Zero) }
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = scale,
|
||||
label = "ScaleAnimation"
|
||||
)
|
||||
val animatedOffsetX by animateFloatAsState(
|
||||
targetValue = offsetX,
|
||||
label = "OffsetXAnimation"
|
||||
)
|
||||
val animatedOffsetY by animateFloatAsState(
|
||||
targetValue = offsetY,
|
||||
label = "OffsetYAnimation"
|
||||
)
|
||||
val updateTransformation = remember {
|
||||
{ newScale: Float, newOffsetX: Float, newOffsetY: Float ->
|
||||
val scaleDiff = kotlin.math.abs(newScale - lastScale)
|
||||
val offsetXDiff = kotlin.math.abs(newOffsetX - lastOffsetX)
|
||||
val offsetYDiff = kotlin.math.abs(newOffsetY - lastOffsetY)
|
||||
if (scaleDiff > 0.01f || offsetXDiff > 1f || offsetYDiff > 1f) {
|
||||
scale = newScale
|
||||
offsetX = newOffsetX
|
||||
offsetY = newOffsetY
|
||||
lastScale = newScale
|
||||
lastOffsetX = newOffsetX
|
||||
lastOffsetY = newOffsetY
|
||||
}
|
||||
}
|
||||
}
|
||||
val scaleToFullScreen = remember {
|
||||
{
|
||||
if (imageSize.height > 0 && screenSize.height > 0) {
|
||||
val newScale = screenSize.height / imageSize.height
|
||||
updateTransformation(newScale, 0f, 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.9f))
|
||||
.onSizeChanged { size ->
|
||||
screenSize = Size(size.width.toFloat(), size.height.toFloat())
|
||||
}
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(imageUri)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = stringResource(R.string.settings_custom_background),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
scaleX = animatedScale,
|
||||
scaleY = animatedScale,
|
||||
translationX = animatedOffsetX,
|
||||
translationY = animatedOffsetY
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scope.launch {
|
||||
try {
|
||||
val newScale = (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) {
|
||||
(offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val newOffsetY = if (maxOffsetY > 0) {
|
||||
(offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
updateTransformation(newScale, newOffsetX, newOffsetY)
|
||||
} catch (_: Exception) {
|
||||
updateTransformation(lastScale, lastOffsetX, lastOffsetY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onSizeChanged { size ->
|
||||
imageSize = Size(size.width.toFloat(), size.height.toFloat())
|
||||
}
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.align(Alignment.TopCenter),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.cancel),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { scaleToFullScreen() },
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Fullscreen,
|
||||
contentDescription = stringResource(R.string.reprovision),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val transformation = BackgroundTransformation(scale, offsetX, offsetY)
|
||||
val savedUri = context.saveTransformedBackground(imageUri, transformation)
|
||||
savedUri?.let { onConfirm(it) }
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.confirm),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.padding(16.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.image_editor_hint),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.becomeManager(ksuApp.packageName)
|
||||
val ksuVersion = if (isManager) Natives.version else null
|
||||
|
||||
if (ksuVersion != null) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = "" }
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
package com.sukisu.ultra.ui.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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,837 @@
|
|||
package com.sukisu.ultra.ui.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.util.SuSFSManager
|
||||
import com.sukisu.ultra.ui.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挂载内容组件
|
||||
*/
|
||||
@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,
|
||||
onRunUmount: () -> 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))
|
||||
}
|
||||
|
||||
if (tryUmounts.isNotEmpty()) {
|
||||
Button(
|
||||
onClick = onRunUmount,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.susfs_run))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kstat配置内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun KstatConfigContent(
|
||||
kstatConfigs: Set<String>,
|
||||
addKstatPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddKstatStatically: () -> Unit,
|
||||
onAddKstat: () -> Unit,
|
||||
onRemoveKstatConfig: (String) -> Unit,
|
||||
onEditKstatConfig: ((String) -> Unit)? = null,
|
||||
onRemoveAddKstat: (String) -> Unit,
|
||||
onEditAddKstat: ((String) -> Unit)? = null,
|
||||
onUpdateKstat: (String) -> Unit,
|
||||
onUpdateKstatFullClone: (String) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_add_statically),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_add),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_update),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_update_full_clone),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kstatConfigs.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.static_kstat_config),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
items(kstatConfigs.toList()) { config ->
|
||||
KstatConfigItemCard(
|
||||
config = config,
|
||||
onDelete = { onRemoveKstatConfig(config) },
|
||||
onEdit = if (onEditKstatConfig != null) { { onEditKstatConfig(config) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (addKstatPaths.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_path_management),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
items(addKstatPaths.toList()) { path ->
|
||||
AddKstatPathItemCard(
|
||||
path = path,
|
||||
onDelete = { onRemoveAddKstat(path) },
|
||||
onEdit = if (onEditAddKstat != null) { { onEditAddKstat(path) } } else null,
|
||||
onUpdate = { onUpdateKstat(path) },
|
||||
onUpdateFullClone = { onUpdateKstatFullClone(path) },
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (kstatConfigs.isEmpty() && addKstatPaths.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.no_kstat_config_message)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddKstat,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onAddKstatStatically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径设置内容组件
|
||||
*/
|
||||
@SuppressLint("SdCardPath")
|
||||
@Composable
|
||||
fun PathSettingsContent(
|
||||
androidDataPath: String,
|
||||
onAndroidDataPathChange: (String) -> Unit,
|
||||
sdcardPath: String,
|
||||
onSdcardPathChange: (String) -> Unit,
|
||||
isLoading: Boolean,
|
||||
onSetAndroidDataPath: () -> Unit,
|
||||
onSetSdcardPath: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = androidDataPath,
|
||||
onValueChange = onAndroidDataPathChange,
|
||||
label = { Text(stringResource(R.string.susfs_android_data_path_label)) },
|
||||
placeholder = { Text("/sdcard/Android/data") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onSetAndroidDataPath,
|
||||
enabled = !isLoading && androidDataPath.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.susfs_set_android_data_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = sdcardPath,
|
||||
onValueChange = onSdcardPathChange,
|
||||
label = { Text(stringResource(R.string.susfs_sdcard_path_label)) },
|
||||
placeholder = { Text("/sdcard") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onSetSdcardPath,
|
||||
enabled = !isLoading && sdcardPath.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.susfs_set_sdcard_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用功能状态内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun EnabledFeaturesContent(
|
||||
enabledFeatures: List<SuSFSManager.EnabledFeature>,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_enabled_features_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledFeatures.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_features_found)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(enabledFeatures) { feature ->
|
||||
FeatureStatusCard(
|
||||
feature = feature,
|
||||
onRefresh = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
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(
|
||||
durationMillis = animationDurationMs,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "mainButtonRotation"
|
||||
)
|
||||
|
||||
// 主按钮缩放动画
|
||||
val mainButtonScale by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 1.1f else 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = animationDurationMs,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "mainButtonScale"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier.wrapContentSize(),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
// 子菜单按钮
|
||||
menuItems.forEachIndexed { index, menuItem ->
|
||||
val animatedOffsetY by animateFloatAsState(
|
||||
targetValue = if (isExpanded) {
|
||||
-(buttonSpacing.value * (index + 1))
|
||||
} else {
|
||||
0f
|
||||
},
|
||||
animationSpec = tween(
|
||||
durationMillis = animationDurationMs,
|
||||
delayMillis = if (isExpanded) {
|
||||
index * staggerDelayMs
|
||||
} else {
|
||||
(menuItems.size - index - 1) * staggerDelayMs
|
||||
},
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "fabOffset$index"
|
||||
)
|
||||
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 1f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = animationDurationMs,
|
||||
delayMillis = if (isExpanded) {
|
||||
index * staggerDelayMs + 100
|
||||
} else {
|
||||
(menuItems.size - index - 1) * staggerDelayMs
|
||||
},
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "fabScale$index"
|
||||
)
|
||||
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 1f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = animationDurationMs,
|
||||
delayMillis = if (isExpanded) {
|
||||
index * staggerDelayMs + 150
|
||||
} else {
|
||||
(menuItems.size - index - 1) * staggerDelayMs
|
||||
},
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "fabAlpha$index"
|
||||
)
|
||||
|
||||
// 子按钮容器(包含标签)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.offset(y = animatedOffsetY.dp)
|
||||
.scale(animatedScale)
|
||||
.alpha(animatedAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
// 标签
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded && animatedScale > 0.5f,
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it / 2 },
|
||||
animationSpec = tween(200)
|
||||
) + fadeIn(animationSpec = tween(200)),
|
||||
exit = slideOutHorizontally(
|
||||
targetOffsetX = { it / 2 },
|
||||
animationSpec = tween(150)
|
||||
) + fadeOut(animationSpec = tween(150))
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.inverseSurface,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(menuItem.labelRes),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 子按钮
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
menuItem.onClick()
|
||||
isExpanded = false
|
||||
},
|
||||
modifier = Modifier.size(smallButtonSize),
|
||||
containerColor = if (menuItem.color != Color.Unspecified) {
|
||||
menuItem.color
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
},
|
||||
contentColor = if (menuItem.color != Color.Unspecified) {
|
||||
if (menuItem.color == Color.Gray) Color.White
|
||||
else MaterialTheme.colorScheme.onSecondary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSecondary
|
||||
},
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 4.dp,
|
||||
pressedElevation = 6.dp
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = menuItem.icon,
|
||||
contentDescription = stringResource(menuItem.labelRes),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主按钮
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
onMainButtonClick?.invoke()
|
||||
isExpanded = !isExpanded
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(buttonSize)
|
||||
.scale(mainButtonScale),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 6.dp,
|
||||
pressedElevation = 8.dp,
|
||||
hoveredElevation = 8.dp
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isExpanded) mainButtonExpandedIcon else mainButtonIcon,
|
||||
contentDescription = stringResource(
|
||||
if (isExpanded) R.string.collapse_menu else R.string.expand_menu
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(if (mainButtonIcon == Icons.Filled.Add) rotationAngle else 0f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预设菜单项
|
||||
object FabMenuPresets {
|
||||
fun getScrollMenuItems(
|
||||
onScrollToTop: () -> Unit,
|
||||
onScrollToBottom: () -> Unit
|
||||
) = listOf(
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.KeyboardArrowDown,
|
||||
labelRes = R.string.scroll_to_bottom,
|
||||
onClick = onScrollToBottom
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.KeyboardArrowUp,
|
||||
labelRes = R.string.scroll_to_top,
|
||||
onClick = onScrollToTop
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun getBatchActionMenuItems(
|
||||
onCancel: () -> Unit,
|
||||
onDeny: () -> Unit,
|
||||
onAllow: () -> Unit,
|
||||
onUnmountModules: () -> Unit,
|
||||
onDisableUnmount: () -> Unit
|
||||
) = listOf(
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.Close,
|
||||
labelRes = R.string.cancel,
|
||||
color = Color.Gray,
|
||||
onClick = onCancel
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.Block,
|
||||
labelRes = R.string.deny_authorization,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
onClick = onDeny
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.Check,
|
||||
labelRes = R.string.grant_authorization,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
onClick = onAllow
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.FolderOff,
|
||||
labelRes = R.string.unmount_modules,
|
||||
onClick = onUnmountModules
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.Folder,
|
||||
labelRes = R.string.disable_unmount,
|
||||
onClick = onDisableUnmount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(MenuAnchorType.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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,578 @@
|
|||
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))
|
||||
}
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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("[H[J")) { // 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
697
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Flash.kt
Normal file
697
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Flash.kt
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
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 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.*
|
||||
|
||||
/**
|
||||
* @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
|
||||
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
|
||||
}
|
||||
hasUpdateCompleted = true
|
||||
}, onStdout = {
|
||||
tempText = "$it\n"
|
||||
if (tempText.startsWith("[H[J")) { // clear command
|
||||
text = tempText.substring(6)
|
||||
} else {
|
||||
text += tempText
|
||||
}
|
||||
logContent.append(it).append("\n")
|
||||
}, onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 安装但排除更新模块
|
||||
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))
|
||||
}
|
||||
}
|
||||
}, onStdout = {
|
||||
tempText = "$it\n"
|
||||
if (tempText.startsWith("[H[J")) { // clear command
|
||||
text = tempText.substring(6)
|
||||
} else {
|
||||
text += tempText
|
||||
}
|
||||
logContent.append(it).append("\n")
|
||||
}, onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
val onBack: () -> Unit = {
|
||||
val canGoBack = when (flashIt) {
|
||||
is FlashIt.FlashModuleUpdate -> currentFlashingStatus.value != FlashingStatus.FLASHING
|
||||
else -> currentFlashingStatus.value != FlashingStatus.FLASHING
|
||||
}
|
||||
|
||||
if (canGoBack) {
|
||||
if (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: android.content.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) : 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,
|
||||
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)
|
||||
}
|
||||
871
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt
Normal file
871
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
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.util.SuSFSManager
|
||||
import com.sukisu.ultra.ui.util.checkNewVersion
|
||||
import com.sukisu.ultra.ui.util.getSuSFS
|
||||
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.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/5/31.
|
||||
*/
|
||||
@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()
|
||||
|
||||
LaunchedEffect(key1 = navigator) {
|
||||
coroutineScope.launch {
|
||||
viewModel.refreshAllData(context)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadUserSettings(context)
|
||||
viewModel.initializeData()
|
||||
viewModel.checkForUpdates(context)
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigator = navigator
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
|
||||
)
|
||||
) { innerPadding ->
|
||||
val pullRefreshState = rememberPullRefreshState(
|
||||
refreshing = false,
|
||||
onRefresh = {
|
||||
coroutineScope.launch {
|
||||
viewModel.refreshAllData(context)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
) {
|
||||
StatusCard(
|
||||
systemStatus = viewModel.systemStatus,
|
||||
onClickInstall = {
|
||||
navigator.navigate(InstallScreenDestination)
|
||||
}
|
||||
)
|
||||
|
||||
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 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) {
|
||||
if (!viewModel.isHideLinkCard) {
|
||||
ContributionCard()
|
||||
DonateCard()
|
||||
LearnMoreCard()
|
||||
}
|
||||
}
|
||||
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
|
||||
) {
|
||||
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 = {
|
||||
// SuSFS 配置按钮
|
||||
if (getSuSFS() == "Supported" && 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) {
|
||||
// 根据showKpmInfo决定是否显示KPM信息
|
||||
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)
|
||||
|
||||
val isSUS_SU = systemInfo.suSFSFeatures == "CONFIG_KSU_SUSFS_SUS_SU"
|
||||
val isKprobesHook = Natives.getHookType() == "Kprobes"
|
||||
|
||||
when {
|
||||
isSUS_SU && isKprobesHook -> {
|
||||
append(" (${systemInfo.suSFSVariant})")
|
||||
if (systemInfo.susSUMode.isNotEmpty()) {
|
||||
append(" ${stringResource(R.string.sus_su_mode)} ${systemInfo.susSUMode}")
|
||||
}
|
||||
}
|
||||
|
||||
Natives.getHookType() == "Manual" -> {
|
||||
append(" (${stringResource(R.string.manual_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 = 20000,
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun WarningCardPreview() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
WarningCard(message = "Warning message")
|
||||
WarningCard(
|
||||
message = "Warning message ",
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
onClick = {})
|
||||
}
|
||||
}
|
||||
891
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt
Normal file
891
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Install.kt
Normal file
|
|
@ -0,0 +1,891 @@
|
|||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
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.AutoFixHigh
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
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.draw.shadow
|
||||
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.semantics.Role
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.KernelFlashScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.getKernelVersion
|
||||
import com.sukisu.ultra.ui.component.DialogHandle
|
||||
import com.sukisu.ultra.ui.component.SlotSelectionDialog
|
||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
||||
import com.sukisu.ultra.ui.component.rememberCustomDialog
|
||||
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.util.*
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/5/31.
|
||||
*/
|
||||
|
||||
enum class KpmPatchOption {
|
||||
FOLLOW_KERNEL,
|
||||
PATCH_KPM,
|
||||
UNDO_PATCH_KPM
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun InstallScreen(navigator: DestinationsNavigator) {
|
||||
var installMethod by remember { mutableStateOf<InstallMethod?>(null) }
|
||||
var lkmSelection by remember { mutableStateOf<LkmSelection>(LkmSelection.KmiNone) }
|
||||
var kpmPatchOption by remember { mutableStateOf(KpmPatchOption.FOLLOW_KERNEL) }
|
||||
val context = LocalContext.current
|
||||
var showRebootDialog by remember { mutableStateOf(false) }
|
||||
var showSlotSelectionDialog by remember { mutableStateOf(false) }
|
||||
var tempKernelUri by remember { mutableStateOf<Uri?>(null) }
|
||||
val kernelVersion = getKernelVersion()
|
||||
val isGKI = kernelVersion.isGKI()
|
||||
val isAbDevice = isAbDevice()
|
||||
val summary = stringResource(R.string.horizon_kernel_summary)
|
||||
|
||||
if (showRebootDialog) {
|
||||
RebootDialog(
|
||||
show = true,
|
||||
onDismiss = { showRebootDialog = false },
|
||||
onConfirm = {
|
||||
showRebootDialog = false
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec("su")
|
||||
process.outputStream.bufferedWriter().use { writer ->
|
||||
writer.write("svc power reboot\n")
|
||||
writer.write("exit\n")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(context, R.string.failed_reboot, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val onInstall = {
|
||||
installMethod?.let { method ->
|
||||
when (method) {
|
||||
is InstallMethod.HorizonKernel -> {
|
||||
method.uri?.let { uri ->
|
||||
navigator.navigate(
|
||||
KernelFlashScreenDestination(
|
||||
kernelUri = uri,
|
||||
selectedSlot = method.slot,
|
||||
kpmPatchEnabled = kpmPatchOption == KpmPatchOption.PATCH_KPM,
|
||||
kpmUndoPatch = kpmPatchOption == KpmPatchOption.UNDO_PATCH_KPM
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val flashIt = FlashIt.FlashBoot(
|
||||
boot = if (method is InstallMethod.SelectFile) method.uri else null,
|
||||
lkm = lkmSelection,
|
||||
ota = method is InstallMethod.DirectInstallToInactiveSlot
|
||||
)
|
||||
navigator.navigate(FlashScreenDestination(flashIt))
|
||||
}
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
// 槽位选择
|
||||
SlotSelectionDialog(
|
||||
show = showSlotSelectionDialog && isAbDevice,
|
||||
onDismiss = { showSlotSelectionDialog = false },
|
||||
onSlotSelected = { slot ->
|
||||
showSlotSelectionDialog = false
|
||||
val horizonMethod = InstallMethod.HorizonKernel(
|
||||
uri = tempKernelUri,
|
||||
slot = slot,
|
||||
summary = summary
|
||||
)
|
||||
installMethod = horizonMethod
|
||||
}
|
||||
)
|
||||
|
||||
val currentKmi by produceState(initialValue = "") {
|
||||
value = getCurrentKmi()
|
||||
}
|
||||
|
||||
val selectKmiDialog = rememberSelectKmiDialog { kmi ->
|
||||
kmi?.let {
|
||||
lkmSelection = LkmSelection.KmiString(it)
|
||||
onInstall()
|
||||
}
|
||||
}
|
||||
|
||||
val onClickNext = {
|
||||
if (isGKI && lkmSelection == LkmSelection.KmiNone && currentKmi.isBlank() && installMethod !is InstallMethod.HorizonKernel) {
|
||||
selectKmiDialog.show()
|
||||
} else {
|
||||
onInstall()
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
onBack = { navigator.popBackStack() },
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
|
||||
)
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 12.dp)
|
||||
) {
|
||||
SelectInstallMethod(
|
||||
isGKI = isGKI,
|
||||
onSelected = { method ->
|
||||
if (method is InstallMethod.HorizonKernel && method.uri != null) {
|
||||
if (isAbDevice) {
|
||||
tempKernelUri = method.uri
|
||||
showSlotSelectionDialog = true
|
||||
} else {
|
||||
installMethod = method
|
||||
}
|
||||
} else {
|
||||
installMethod = method
|
||||
}
|
||||
},
|
||||
kpmPatchOption = kpmPatchOption,
|
||||
onKpmPatchOptionChanged = { kpmPatchOption = it },
|
||||
selectedMethod = installMethod
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
(lkmSelection as? LkmSelection.LkmUri)?.let {
|
||||
ElevatedCard(
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = getCardElevation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.shadow(
|
||||
elevation = cardElevation,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.selected_lkm,
|
||||
it.uri.lastPathSegment ?: "(file)"
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
(installMethod as? InstallMethod.HorizonKernel)?.let { method ->
|
||||
if (method.slot != null) {
|
||||
ElevatedCard(
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = getCardElevation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.shadow(
|
||||
elevation = cardElevation,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.selected_slot,
|
||||
if (method.slot == "a") stringResource(id = R.string.slot_a)
|
||||
else stringResource(id = R.string.slot_b)
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// KPM 状态显示卡片
|
||||
if (kpmPatchOption != KpmPatchOption.FOLLOW_KERNEL) {
|
||||
ElevatedCard(
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = getCardElevation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.shadow(
|
||||
elevation = cardElevation,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = when (kpmPatchOption) {
|
||||
KpmPatchOption.PATCH_KPM -> stringResource(R.string.kpm_patch_enabled)
|
||||
KpmPatchOption.UNDO_PATCH_KPM -> stringResource(R.string.kpm_undo_patch_enabled)
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = when (kpmPatchOption) {
|
||||
KpmPatchOption.PATCH_KPM -> MaterialTheme.colorScheme.primary
|
||||
KpmPatchOption.UNDO_PATCH_KPM -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = installMethod != null,
|
||||
onClick = onClickNext,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.install_next),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RebootDialog(
|
||||
show: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
if (show) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(id = R.string.reboot_complete_title)) },
|
||||
text = { Text(stringResource(id = R.string.reboot_complete_msg)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(id = R.string.yes))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(id = R.string.no))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class InstallMethod {
|
||||
data class SelectFile(
|
||||
val uri: Uri? = null,
|
||||
@param:StringRes override val label: Int = R.string.select_file,
|
||||
override val summary: String?
|
||||
) : InstallMethod()
|
||||
|
||||
data object DirectInstall : InstallMethod() {
|
||||
override val label: Int
|
||||
get() = R.string.direct_install
|
||||
}
|
||||
|
||||
data object DirectInstallToInactiveSlot : InstallMethod() {
|
||||
override val label: Int
|
||||
get() = R.string.install_inactive_slot
|
||||
}
|
||||
|
||||
data class HorizonKernel(
|
||||
val uri: Uri? = null,
|
||||
val slot: String? = null,
|
||||
@param:StringRes override val label: Int = R.string.horizon_kernel,
|
||||
override val summary: String? = null
|
||||
) : InstallMethod()
|
||||
|
||||
abstract val label: Int
|
||||
open val summary: String? = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectInstallMethod(
|
||||
isGKI: Boolean = false,
|
||||
onSelected: (InstallMethod) -> Unit = {},
|
||||
kpmPatchOption: KpmPatchOption = KpmPatchOption.FOLLOW_KERNEL,
|
||||
onKpmPatchOptionChanged: (KpmPatchOption) -> Unit = {},
|
||||
selectedMethod: InstallMethod? = null
|
||||
) {
|
||||
val rootAvailable = rootAvailable()
|
||||
val isAbDevice = isAbDevice()
|
||||
val horizonKernelSummary = stringResource(R.string.horizon_kernel_summary)
|
||||
val selectFileTip = stringResource(
|
||||
id = R.string.select_file_tip,
|
||||
if (isInitBoot()) {
|
||||
"init_boot / vendor_boot ${stringResource(R.string.select_file_tip_vendor)}"
|
||||
} else {
|
||||
"boot"
|
||||
}
|
||||
)
|
||||
|
||||
val radioOptions = mutableListOf<InstallMethod>(
|
||||
InstallMethod.SelectFile(summary = selectFileTip)
|
||||
)
|
||||
|
||||
if (rootAvailable) {
|
||||
radioOptions.add(InstallMethod.DirectInstall)
|
||||
if (isAbDevice) {
|
||||
radioOptions.add(InstallMethod.DirectInstallToInactiveSlot)
|
||||
}
|
||||
radioOptions.add(InstallMethod.HorizonKernel(summary = horizonKernelSummary))
|
||||
}
|
||||
|
||||
var selectedOption by remember { mutableStateOf<InstallMethod?>(null) }
|
||||
var currentSelectingMethod by remember { mutableStateOf<InstallMethod?>(null) }
|
||||
|
||||
val selectImageLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
it.data?.data?.let { uri ->
|
||||
val option = when (currentSelectingMethod) {
|
||||
is InstallMethod.SelectFile -> InstallMethod.SelectFile(
|
||||
uri,
|
||||
summary = selectFileTip
|
||||
)
|
||||
|
||||
is InstallMethod.HorizonKernel -> InstallMethod.HorizonKernel(
|
||||
uri,
|
||||
summary = horizonKernelSummary
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
option?.let { opt ->
|
||||
selectedOption = opt
|
||||
onSelected(opt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val confirmDialog = rememberConfirmDialog(
|
||||
onConfirm = {
|
||||
selectedOption = InstallMethod.DirectInstallToInactiveSlot
|
||||
onSelected(InstallMethod.DirectInstallToInactiveSlot)
|
||||
},
|
||||
onDismiss = null
|
||||
)
|
||||
|
||||
val dialogTitle = stringResource(id = android.R.string.dialog_alert_title)
|
||||
val dialogContent = stringResource(id = R.string.install_inactive_slot_warning)
|
||||
|
||||
val onClick = { option: InstallMethod ->
|
||||
currentSelectingMethod = option
|
||||
when (option) {
|
||||
is InstallMethod.SelectFile, is InstallMethod.HorizonKernel -> {
|
||||
selectImageLauncher.launch(Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "application/*"
|
||||
putExtra(
|
||||
Intent.EXTRA_MIME_TYPES,
|
||||
arrayOf("application/octet-stream", "application/zip")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
is InstallMethod.DirectInstall -> {
|
||||
selectedOption = option
|
||||
onSelected(option)
|
||||
}
|
||||
|
||||
is InstallMethod.DirectInstallToInactiveSlot -> {
|
||||
confirmDialog.showConfirm(dialogTitle, dialogContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lkmExpanded by remember { mutableStateOf(false) }
|
||||
var gkiExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
// LKM 安装/修补
|
||||
if (isGKI) {
|
||||
ElevatedCard(
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = getCardElevation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.AutoFixHigh,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.Lkm_install_methods),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
lkmExpanded = !lkmExpanded
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = lkmExpanded,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
)
|
||||
) {
|
||||
radioOptions.take(3).forEach { option ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
color = if (option.javaClass == selectedOption?.javaClass)
|
||||
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = cardAlpha)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = cardAlpha),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = option.javaClass == selectedOption?.javaClass,
|
||||
onClick = { onClick(option) },
|
||||
role = Role.RadioButton,
|
||||
indication = LocalIndication.current,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
.padding(vertical = 8.dp, horizontal = 12.dp)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = option.javaClass == selectedOption?.javaClass,
|
||||
onClick = null,
|
||||
interactionSource = interactionSource,
|
||||
colors = RadioButtonDefaults.colors(
|
||||
selectedColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = option.label),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
option.summary?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// anykernel3 刷写
|
||||
if (rootAvailable) {
|
||||
ElevatedCard(
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = getCardElevation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.FileUpload,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
stringResource(R.string.GKI_install_methods),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
gkiExpanded = !gkiExpanded
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = gkiExpanded,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
)
|
||||
) {
|
||||
radioOptions.filterIsInstance<InstallMethod.HorizonKernel>().forEach { option ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
color = if (option.javaClass == selectedOption?.javaClass)
|
||||
MaterialTheme.colorScheme.secondaryContainer.copy(alpha = cardAlpha)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = cardAlpha),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = option.javaClass == selectedOption?.javaClass,
|
||||
onClick = { onClick(option) },
|
||||
role = Role.RadioButton,
|
||||
indication = LocalIndication.current,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
.padding(vertical = 8.dp, horizontal = 12.dp)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = option.javaClass == selectedOption?.javaClass,
|
||||
onClick = null,
|
||||
interactionSource = interactionSource,
|
||||
colors = RadioButtonDefaults.colors(
|
||||
selectedColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = option.label),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
option.summary?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KPM修补
|
||||
if (selectedMethod is InstallMethod.HorizonKernel && selectedMethod.uri != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.kpm_patch_options),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.kpm_patch_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
KpmPatchOptionGroup(
|
||||
selectedOption = kpmPatchOption,
|
||||
onOptionChanged = onKpmPatchOptionChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KpmPatchOptionGroup(
|
||||
selectedOption: KpmPatchOption,
|
||||
onOptionChanged: (KpmPatchOption) -> Unit
|
||||
) {
|
||||
val options = listOf(
|
||||
KpmPatchOption.FOLLOW_KERNEL to stringResource(R.string.kpm_follow_kernel_file),
|
||||
KpmPatchOption.PATCH_KPM to stringResource(R.string.enable_kpm_patch),
|
||||
KpmPatchOption.UNDO_PATCH_KPM to stringResource(R.string.enable_kpm_undo_patch)
|
||||
)
|
||||
|
||||
val descriptions = mapOf(
|
||||
KpmPatchOption.FOLLOW_KERNEL to stringResource(R.string.kpm_follow_kernel_description),
|
||||
KpmPatchOption.PATCH_KPM to stringResource(R.string.kpm_patch_switch_description),
|
||||
KpmPatchOption.UNDO_PATCH_KPM to stringResource(R.string.kpm_undo_patch_switch_description)
|
||||
)
|
||||
|
||||
Column {
|
||||
options.forEach { (option, label) ->
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
color = if (option == selectedOption)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = cardAlpha)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceContainer.copy(alpha = cardAlpha),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = option == selectedOption,
|
||||
onClick = { onOptionChanged(option) },
|
||||
role = Role.RadioButton,
|
||||
indication = LocalIndication.current,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = option == selectedOption,
|
||||
onClick = null,
|
||||
interactionSource = interactionSource,
|
||||
colors = RadioButtonDefaults.colors(
|
||||
selectedColor = when (option) {
|
||||
KpmPatchOption.FOLLOW_KERNEL -> MaterialTheme.colorScheme.primary
|
||||
KpmPatchOption.PATCH_KPM -> MaterialTheme.colorScheme.primary
|
||||
KpmPatchOption.UNDO_PATCH_KPM -> MaterialTheme.colorScheme.tertiary
|
||||
},
|
||||
unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (option == selectedOption)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
descriptions[option]?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (option == selectedOption)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun rememberSelectKmiDialog(onSelected: (String?) -> Unit): DialogHandle {
|
||||
return rememberCustomDialog { dismiss ->
|
||||
val supportedKmi by produceState(initialValue = emptyList()) {
|
||||
value = getSupportedKmis()
|
||||
}
|
||||
val options = supportedKmi.map { value ->
|
||||
ListOption(
|
||||
titleText = value
|
||||
)
|
||||
}
|
||||
|
||||
var selection by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
ListDialog(state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
||||
onSelected(selection)
|
||||
}, onCloseRequest = {
|
||||
dismiss()
|
||||
}), header = Header.Default(
|
||||
title = stringResource(R.string.select_kmi),
|
||||
), selection = ListSelection.Single(
|
||||
showRadioButtons = true,
|
||||
options = options,
|
||||
) { _, option ->
|
||||
selection = option.titleText
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
onBack: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
||||
colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
colorScheme.background
|
||||
}
|
||||
val cardAlpha = cardAlpha
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.install),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(
|
||||
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
|
||||
),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SelectInstallPreview() {
|
||||
InstallScreen(EmptyDestinationsNavigator)
|
||||
}
|
||||
742
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt
Normal file
742
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Kpm.kt
Normal 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)
|
||||
}
|
||||
1353
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt
Normal file
1353
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt
Normal file
File diff suppressed because it is too large
Load diff
941
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt
Normal file
941
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt
Normal file
|
|
@ -0,0 +1,941 @@
|
|||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Undo
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.edit
|
||||
import com.maxkeppeker.sheets.core.models.base.IconSource
|
||||
import com.maxkeppeler.sheets.list.models.ListOption
|
||||
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.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.MoreSettingsScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.BuildConfig
|
||||
import com.sukisu.ultra.Natives
|
||||
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.LocalSnackbarHost
|
||||
import com.sukisu.ultra.ui.util.getBugreportFile
|
||||
import com.sukisu.ultra.ui.util.getRootShell
|
||||
import com.sukisu.ultra.ui.util.setUidAutoScan
|
||||
import com.sukisu.ultra.ui.util.setUidMultiUserScan
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/5/31.
|
||||
*/
|
||||
private val SPACING_SMALL = 3.dp
|
||||
private val SPACING_MEDIUM = 8.dp
|
||||
private val SPACING_LARGE = 16.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun SettingScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val context = LocalContext.current
|
||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
var selectedEngine by rememberSaveable {
|
||||
mutableStateOf(
|
||||
prefs.getString("webui_engine", "default") ?: "default"
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
// containerColor = MaterialTheme.colorScheme.surfaceBright,
|
||||
topBar = {
|
||||
TopBar(scrollBehavior = scrollBehavior)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackBarHost) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { paddingValues ->
|
||||
val aboutDialog = rememberCustomDialog {
|
||||
AboutDialog(it)
|
||||
}
|
||||
val loadingDialog = rememberLoadingDialog()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val exportBugreportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/gzip")
|
||||
) { uri: Uri? ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch(Dispatchers.IO) {
|
||||
loadingDialog.show()
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
getBugreportFile(context).inputStream().use {
|
||||
it.copyTo(output)
|
||||
}
|
||||
}
|
||||
loadingDialog.hide()
|
||||
snackBarHost.showSnackbar(context.getString(R.string.log_saved))
|
||||
}
|
||||
}
|
||||
|
||||
// 配置卡片
|
||||
KsuIsValid {
|
||||
SettingsGroupCard(
|
||||
title = stringResource(R.string.configuration),
|
||||
content = {
|
||||
// 配置文件模板入口
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Fence,
|
||||
title = stringResource(R.string.settings_profile_template),
|
||||
summary = stringResource(R.string.settings_profile_template_summary),
|
||||
onClick = {
|
||||
navigator.navigate(AppProfileTemplateScreenDestination)
|
||||
}
|
||||
)
|
||||
|
||||
// 卸载模块开关
|
||||
var umountChecked by rememberSaveable {
|
||||
mutableStateOf(Natives.isDefaultUmountModules())
|
||||
}
|
||||
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.FolderDelete,
|
||||
title = stringResource(R.string.settings_umount_modules_default),
|
||||
summary = stringResource(R.string.settings_umount_modules_default_summary),
|
||||
checked = umountChecked,
|
||||
onCheckedChange = { enabled ->
|
||||
if (Natives.setDefaultUmountModules(enabled)) {
|
||||
umountChecked = enabled
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// SU 禁用开关
|
||||
if (Natives.version >= Natives.MINIMAL_SUPPORTED_SU_COMPAT) {
|
||||
var isSuDisabled by rememberSaveable {
|
||||
mutableStateOf(!Natives.isSuEnabled())
|
||||
}
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.RemoveModerator,
|
||||
title = stringResource(R.string.settings_disable_su),
|
||||
summary = stringResource(R.string.settings_disable_su_summary),
|
||||
checked = isSuDisabled,
|
||||
onCheckedChange = { enabled ->
|
||||
val shouldEnable = !enabled
|
||||
if (Natives.setSuEnabled(shouldEnable)) {
|
||||
isSuDisabled = enabled
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// 强制签名验证开关
|
||||
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
|
||||
}
|
||||
)
|
||||
if (Natives.version >= Natives.MINIMAL_SUPPORTED_UID_SCANNER) {
|
||||
var uidAutoScanEnabled by rememberSaveable {
|
||||
mutableStateOf(prefs.getBoolean("uid_auto_scan", false))
|
||||
}
|
||||
|
||||
var uidMultiUserScanEnabled by rememberSaveable {
|
||||
mutableStateOf(prefs.getBoolean("uid_multi_user_scan", false))
|
||||
}
|
||||
// 用户态扫描应用列表开关
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.Scanner,
|
||||
title = stringResource(R.string.uid_auto_scan_title),
|
||||
summary = stringResource(R.string.uid_auto_scan_summary),
|
||||
checked = uidAutoScanEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch {
|
||||
try {
|
||||
if (setUidAutoScan(enabled)) {
|
||||
uidAutoScanEnabled = enabled
|
||||
prefs.edit { putBoolean("uid_auto_scan", enabled) }
|
||||
|
||||
if (!enabled) {
|
||||
uidMultiUserScanEnabled = false
|
||||
prefs.edit { putBoolean("uid_multi_user_scan", false) }
|
||||
}
|
||||
} else {
|
||||
snackBarHost.showSnackbar(context.getString(R.string.uid_scanner_setting_failed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(
|
||||
R.string.uid_scanner_setting_error,
|
||||
e.message ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 多用户应用扫描开关 - 仅在启用用户态扫描时显示
|
||||
AnimatedVisibility(
|
||||
visible = uidAutoScanEnabled,
|
||||
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 = uidMultiUserScanEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch {
|
||||
try {
|
||||
if (setUidMultiUserScan(enabled)) {
|
||||
uidMultiUserScanEnabled = enabled
|
||||
prefs.edit { putBoolean("uid_multi_user_scan", enabled) }
|
||||
} else {
|
||||
snackBarHost.showSnackbar(context.getString(R.string.uid_scanner_setting_failed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(
|
||||
R.string.uid_scanner_setting_error,
|
||||
e.message ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// 清理运行环境
|
||||
AnimatedVisibility(
|
||||
visible = uidAutoScanEnabled,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
val confirmDialog = rememberConfirmDialog()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.CleaningServices,
|
||||
title = stringResource(R.string.clean_runtime_environment),
|
||||
summary = stringResource(R.string.clean_runtime_environment_summary),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val result = confirmDialog.awaitConfirm(
|
||||
title = context.getString(R.string.clean_runtime_environment),
|
||||
content = context.getString(R.string.clean_runtime_environment_confirm)
|
||||
)
|
||||
if (result == ConfirmResult.Confirmed) {
|
||||
val cleanResult = cleanRuntimeEnvironment()
|
||||
if (cleanResult) {
|
||||
uidAutoScanEnabled = false
|
||||
prefs.edit { putBoolean("uid_auto_scan", false) }
|
||||
|
||||
uidMultiUserScanEnabled = false
|
||||
prefs.edit { putBoolean("uid_multi_user_scan", false) }
|
||||
|
||||
snackBarHost.showSnackbar(context.getString(R.string.clean_runtime_environment_success))
|
||||
} else {
|
||||
snackBarHost.showSnackbar(context.getString(R.string.clean_runtime_environment_failed))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 应用设置卡片
|
||||
SettingsGroupCard(
|
||||
title = stringResource(R.string.app_settings),
|
||||
content = {
|
||||
// 更新检查开关
|
||||
var checkUpdate by rememberSaveable {
|
||||
mutableStateOf(prefs.getBoolean("check_update", true))
|
||||
}
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.Update,
|
||||
title = stringResource(R.string.settings_check_update),
|
||||
summary = stringResource(R.string.settings_check_update_summary),
|
||||
checked = checkUpdate,
|
||||
onCheckedChange = { enabled ->
|
||||
prefs.edit { putBoolean("check_update", enabled) }
|
||||
checkUpdate = enabled
|
||||
}
|
||||
)
|
||||
|
||||
// WebUI引擎选择
|
||||
KsuIsValid {
|
||||
WebUIEngineSelector(
|
||||
selectedEngine = selectedEngine,
|
||||
onEngineSelected = { engine ->
|
||||
selectedEngine = engine
|
||||
prefs.edit { putString("webui_engine", engine) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Web调试和Web X Eruda 开关
|
||||
var enableWebDebugging by rememberSaveable {
|
||||
mutableStateOf(prefs.getBoolean("enable_web_debugging", false))
|
||||
}
|
||||
var useWebUIXEruda by rememberSaveable {
|
||||
mutableStateOf(prefs.getBoolean("use_webuix_eruda", false))
|
||||
}
|
||||
|
||||
KsuIsValid {
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.DeveloperMode,
|
||||
title = stringResource(R.string.enable_web_debugging),
|
||||
summary = stringResource(R.string.enable_web_debugging_summary),
|
||||
checked = enableWebDebugging,
|
||||
onCheckedChange = { enabled ->
|
||||
prefs.edit { putBoolean("enable_web_debugging", enabled) }
|
||||
enableWebDebugging = enabled
|
||||
}
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = enableWebDebugging && selectedEngine == "wx",
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.FormatListNumbered,
|
||||
title = stringResource(R.string.use_webuix_eruda),
|
||||
summary = stringResource(R.string.use_webuix_eruda_summary),
|
||||
checked = useWebUIXEruda,
|
||||
onCheckedChange = { enabled ->
|
||||
prefs.edit { putBoolean("use_webuix_eruda", enabled) }
|
||||
useWebUIXEruda = enabled
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 更多设置
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Settings,
|
||||
title = stringResource(R.string.more_settings),
|
||||
summary = stringResource(R.string.more_settings),
|
||||
onClick = {
|
||||
navigator.navigate(MoreSettingsScreenDestination)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// 工具卡片
|
||||
SettingsGroupCard(
|
||||
title = stringResource(R.string.tools),
|
||||
content = {
|
||||
var showBottomsheet by remember { mutableStateOf(false) }
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.BugReport,
|
||||
title = stringResource(R.string.send_log),
|
||||
onClick = {
|
||||
showBottomsheet = true
|
||||
}
|
||||
)
|
||||
|
||||
if (showBottomsheet) {
|
||||
LogBottomSheet(
|
||||
onDismiss = { showBottomsheet = false },
|
||||
onSaveLog = {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm")
|
||||
val current = LocalDateTime.now().format(formatter)
|
||||
exportBugreportLauncher.launch("KernelSU_bugreport_${current}.tar.gz")
|
||||
showBottomsheet = false
|
||||
},
|
||||
onShareLog = {
|
||||
scope.launch {
|
||||
val bugreport = loadingDialog.withLoading {
|
||||
withContext(Dispatchers.IO) {
|
||||
getBugreportFile(context)
|
||||
}
|
||||
}
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
bugreport
|
||||
)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
setDataAndType(uri, "application/gzip")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
context.getString(R.string.send_log)
|
||||
)
|
||||
)
|
||||
|
||||
showBottomsheet = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val lkmMode = Natives.version >= Natives.MINIMAL_SUPPORTED_KERNEL_LKM && Natives.isLkmMode
|
||||
if (lkmMode) {
|
||||
UninstallItem(navigator) {
|
||||
loadingDialog.withLoading(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 关于卡片
|
||||
SettingsGroupCard(
|
||||
title = stringResource(R.string.about),
|
||||
content = {
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Info,
|
||||
title = stringResource(R.string.about),
|
||||
onClick = {
|
||||
aboutDialog.show()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_LARGE))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsGroupCard(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
elevation = getCardElevation()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = SPACING_MEDIUM)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebUIEngineSelector(
|
||||
selectedEngine: String,
|
||||
onEngineSelected: (String) -> Unit
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val engineOptions = listOf(
|
||||
"default" to stringResource(R.string.engine_auto_select),
|
||||
"wx" to stringResource(R.string.engine_force_webuix),
|
||||
"ksu" to stringResource(R.string.engine_force_ksu)
|
||||
)
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.WebAsset,
|
||||
title = stringResource(R.string.use_webuix),
|
||||
summary = engineOptions.find { it.first == selectedEngine }?.second
|
||||
?: stringResource(R.string.engine_auto_select),
|
||||
onClick = { showDialog = true }
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDialog = false },
|
||||
title = { Text(stringResource(R.string.use_webuix)) },
|
||||
text = {
|
||||
Column {
|
||||
engineOptions.forEach { (value, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onEngineSelected(value)
|
||||
showDialog = false
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedEngine == value,
|
||||
onClick = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Text(text = label)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showDialog = false }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LogBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onSaveLog: () -> Unit,
|
||||
onShareLog: () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(SPACING_LARGE),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
LogActionButton(
|
||||
icon = Icons.Filled.Save,
|
||||
text = stringResource(R.string.save_log),
|
||||
onClick = onSaveLog
|
||||
)
|
||||
|
||||
LogActionButton(
|
||||
icon = Icons.Filled.Share,
|
||||
text = stringResource(R.string.send_log),
|
||||
onClick = onShareLog
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(SPACING_LARGE))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogActionButton(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(SPACING_MEDIUM)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = SPACING_LARGE, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(end = SPACING_LARGE)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (summary != null) {
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwitchItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onCheckedChange(!checked) }
|
||||
.padding(horizontal = SPACING_LARGE, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(end = SPACING_LARGE)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
if (summary != null) {
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UninstallItem(
|
||||
navigator: DestinationsNavigator,
|
||||
withLoading: suspend (suspend () -> Unit) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val uninstallConfirmDialog = rememberConfirmDialog()
|
||||
val showTodo = {
|
||||
Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val uninstallDialog = rememberUninstallDialog { uninstallType ->
|
||||
scope.launch {
|
||||
val result = uninstallConfirmDialog.awaitConfirm(
|
||||
title = context.getString(uninstallType.title),
|
||||
content = context.getString(uninstallType.message)
|
||||
)
|
||||
if (result == ConfirmResult.Confirmed) {
|
||||
withLoading {
|
||||
when (uninstallType) {
|
||||
UninstallType.TEMPORARY -> showTodo()
|
||||
UninstallType.PERMANENT -> navigator.navigate(
|
||||
FlashScreenDestination(FlashIt.FlashUninstall)
|
||||
)
|
||||
UninstallType.RESTORE_STOCK_IMAGE -> navigator.navigate(
|
||||
FlashScreenDestination(FlashIt.FlashRestore)
|
||||
)
|
||||
UninstallType.NONE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Delete,
|
||||
title = stringResource(id = R.string.settings_uninstall),
|
||||
onClick = {
|
||||
uninstallDialog.show()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
enum class UninstallType(val title: Int, val message: Int, val icon: ImageVector) {
|
||||
TEMPORARY(
|
||||
R.string.settings_uninstall_temporary,
|
||||
R.string.settings_uninstall_temporary_message,
|
||||
Icons.Filled.Delete
|
||||
),
|
||||
PERMANENT(
|
||||
R.string.settings_uninstall_permanent,
|
||||
R.string.settings_uninstall_permanent_message,
|
||||
Icons.Filled.DeleteForever
|
||||
),
|
||||
RESTORE_STOCK_IMAGE(
|
||||
R.string.settings_restore_stock_image,
|
||||
R.string.settings_restore_stock_image_message,
|
||||
Icons.AutoMirrored.Filled.Undo
|
||||
),
|
||||
NONE(0, 0, Icons.Filled.Delete)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun rememberUninstallDialog(onSelected: (UninstallType) -> Unit): DialogHandle {
|
||||
return rememberCustomDialog { dismiss ->
|
||||
val options = listOf(
|
||||
// UninstallType.TEMPORARY,
|
||||
UninstallType.PERMANENT,
|
||||
UninstallType.RESTORE_STOCK_IMAGE
|
||||
)
|
||||
val listOptions = options.map {
|
||||
ListOption(
|
||||
titleText = stringResource(it.title),
|
||||
subtitleText = if (it.message != 0) stringResource(it.message) else null,
|
||||
icon = IconSource(it.icon)
|
||||
)
|
||||
}
|
||||
|
||||
var selectedOption by remember { mutableStateOf<UninstallType?>(null) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
dismiss()
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_uninstall),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = selectedOption == option
|
||||
val backgroundColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
Color.Transparent
|
||||
val contentColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(backgroundColor)
|
||||
.clickable {
|
||||
selectedOption = option
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = option.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = listOptions[index].titleText,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
listOptions[index].subtitleText?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected)
|
||||
contentColor.copy(alpha = 0.8f)
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RadioButtonChecked,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
selectedOption?.let { onSelected(it) }
|
||||
dismiss()
|
||||
},
|
||||
enabled = selectedOption != null,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(android.R.string.ok)
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
dismiss()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 4.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
||||
colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
colorScheme.background
|
||||
}
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.settings),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
2056
manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuSFSConfig.kt
Normal file
2056
manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuSFSConfig.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,976 @@
|
|||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
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.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
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.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.launch
|
||||
import java.io.File
|
||||
|
||||
// 应用优先级枚举
|
||||
enum class AppPriority(val value: Int) {
|
||||
ROOT(1), // root权限应用
|
||||
CUSTOM(2), // 自定义应用
|
||||
DEFAULT(3) // 默认应用
|
||||
}
|
||||
|
||||
// 菜单项数据类
|
||||
data class BottomSheetMenuItem(
|
||||
val icon: ImageVector,
|
||||
val titleRes: Int,
|
||||
val onClick: () -> Unit
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取应用的优先级
|
||||
*/
|
||||
private fun getAppPriority(app: SuperUserViewModel.AppInfo): AppPriority {
|
||||
return when {
|
||||
app.allowSu -> AppPriority.ROOT
|
||||
app.hasCustomProfile -> AppPriority.CUSTOM
|
||||
else -> AppPriority.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多选模式的主按钮图标
|
||||
*/
|
||||
private fun getMultiSelectMainIcon(isExpanded: Boolean): ImageVector {
|
||||
return if (isExpanded) {
|
||||
Icons.Filled.Close
|
||||
} else {
|
||||
Icons.Filled.GridView
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单选模式的主按钮图标
|
||||
*/
|
||||
private fun getSingleSelectMainIcon(isExpanded: Boolean): ImageVector {
|
||||
return if (isExpanded) {
|
||||
Icons.Filled.Close
|
||||
} else {
|
||||
Icons.Filled.Add
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/6/8
|
||||
*/
|
||||
@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() }
|
||||
|
||||
// 使用ViewModel中的状态,这些状态现在都会从SharedPreferences中加载并自动保存
|
||||
val selectedCategory = viewModel.selectedCategory
|
||||
val currentSortType = viewModel.currentSortType
|
||||
|
||||
// BottomSheet状态
|
||||
val bottomSheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// 添加备份和还原启动器
|
||||
val backupLauncher = ModuleModify.rememberAllowlistBackupLauncher(context, snackBarHostState)
|
||||
val restoreLauncher = ModuleModify.rememberAllowlistRestoreLauncher(context, snackBarHostState)
|
||||
|
||||
LaunchedEffect(key1 = navigator) {
|
||||
viewModel.search = ""
|
||||
if (viewModel.appList.isEmpty()) {
|
||||
// viewModel.fetchAppList()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel.search) {
|
||||
if (viewModel.search.isEmpty()) {
|
||||
// 取消自动滚动到顶部的行为
|
||||
// listState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听选中应用的变化,如果在多选模式下没有选中任何应用,则自动退出多选模式
|
||||
LaunchedEffect(viewModel.selectedApps, viewModel.showBatchActions) {
|
||||
if (viewModel.showBatchActions && viewModel.selectedApps.isEmpty()) {
|
||||
viewModel.showBatchActions = false
|
||||
}
|
||||
}
|
||||
|
||||
// 应用分类和排序逻辑
|
||||
val filteredAndSortedApps = remember(
|
||||
viewModel.appList,
|
||||
selectedCategory,
|
||||
currentSortType,
|
||||
viewModel.search,
|
||||
viewModel.showSystemApps
|
||||
) {
|
||||
var apps = viewModel.appList
|
||||
|
||||
// 按分类筛选
|
||||
apps = when (selectedCategory) {
|
||||
AppCategory.ALL -> apps
|
||||
AppCategory.ROOT -> apps.filter { it.allowSu }
|
||||
AppCategory.CUSTOM -> apps.filter { !it.allowSu && it.hasCustomProfile }
|
||||
AppCategory.DEFAULT -> apps.filter { !it.allowSu && !it.hasCustomProfile }
|
||||
}
|
||||
|
||||
// 优先级排序 + 二次排序
|
||||
apps = apps.sortedWith { app1, app2 ->
|
||||
val priority1 = getAppPriority(app1)
|
||||
val priority2 = getAppPriority(app2)
|
||||
|
||||
// 首先按优先级排序
|
||||
val priorityComparison = priority1.value.compareTo(priority2.value)
|
||||
|
||||
if (priorityComparison != 0) {
|
||||
priorityComparison
|
||||
} else {
|
||||
// 在相同优先级内按指定排序方式排序
|
||||
when (currentSortType) {
|
||||
SortType.NAME_ASC -> app1.label.lowercase().compareTo(app2.label.lowercase())
|
||||
SortType.NAME_DESC -> app2.label.lowercase().compareTo(app1.label.lowercase())
|
||||
SortType.INSTALL_TIME_NEW -> app2.packageInfo.firstInstallTime.compareTo(app1.packageInfo.firstInstallTime)
|
||||
SortType.INSTALL_TIME_OLD -> app1.packageInfo.firstInstallTime.compareTo(app2.packageInfo.firstInstallTime)
|
||||
SortType.SIZE_DESC -> {
|
||||
val size1: Long = app1.packageInfo.applicationInfo?.let {
|
||||
try {
|
||||
File(context.packageManager.getApplicationInfo(it.packageName, 0).sourceDir).length()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
} ?: 0L
|
||||
val size2: Long = app2.packageInfo.applicationInfo?.let {
|
||||
try {
|
||||
File(context.packageManager.getApplicationInfo(it.packageName, 0).sourceDir).length()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
} ?: 0L
|
||||
size2.compareTo(size1)
|
||||
}
|
||||
SortType.SIZE_ASC -> {
|
||||
val size1: Long = app1.packageInfo.applicationInfo?.let {
|
||||
try {
|
||||
File(context.packageManager.getApplicationInfo(it.packageName, 0).sourceDir).length()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
} ?: 0L
|
||||
val size2: Long = app2.packageInfo.applicationInfo?.let {
|
||||
try {
|
||||
File(context.packageManager.getApplicationInfo(it.packageName, 0).sourceDir).length()
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
} ?: 0L
|
||||
size1.compareTo(size2)
|
||||
}
|
||||
SortType.USAGE_FREQ -> app1.label.lowercase().compareTo(app2.label.lowercase()) // 默认按名称排序
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apps
|
||||
}
|
||||
|
||||
// 计算应用数量
|
||||
val appCounts = remember(viewModel.appList, viewModel.showSystemApps) {
|
||||
mapOf(
|
||||
AppCategory.ALL to viewModel.appList.size,
|
||||
AppCategory.ROOT to viewModel.appList.count { it.allowSu },
|
||||
AppCategory.CUSTOM to viewModel.appList.count { !it.allowSu && it.hasCustomProfile },
|
||||
AppCategory.DEFAULT to viewModel.appList.count { !it.allowSu && !it.hasCustomProfile }
|
||||
)
|
||||
}
|
||||
|
||||
// BottomSheet菜单项
|
||||
val bottomSheetMenuItems = remember(viewModel.showSystemApps) {
|
||||
listOf(
|
||||
BottomSheetMenuItem(
|
||||
icon = Icons.Filled.Refresh,
|
||||
titleRes = R.string.refresh,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.fetchAppList()
|
||||
bottomSheetState.hide()
|
||||
showBottomSheet = false
|
||||
}
|
||||
}
|
||||
),
|
||||
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()
|
||||
showBottomSheet = false
|
||||
}
|
||||
}
|
||||
),
|
||||
BottomSheetMenuItem(
|
||||
icon = Icons.Filled.Save,
|
||||
titleRes = R.string.backup_allowlist,
|
||||
onClick = {
|
||||
backupLauncher.launch(ModuleModify.createAllowlistBackupIntent())
|
||||
scope.launch {
|
||||
bottomSheetState.hide()
|
||||
showBottomSheet = false
|
||||
}
|
||||
}
|
||||
),
|
||||
BottomSheetMenuItem(
|
||||
icon = Icons.Filled.RestoreFromTrash,
|
||||
titleRes = R.string.restore_allowlist,
|
||||
onClick = {
|
||||
restoreLauncher.launch(ModuleModify.createAllowlistRestoreIntent())
|
||||
scope.launch {
|
||||
bottomSheetState.hide()
|
||||
showBottomSheet = false
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 记录FAB展开状态用于图标动画
|
||||
var isFabExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SearchAppBar(
|
||||
title = {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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 = {
|
||||
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 = filteredAndSortedApps.size - 1
|
||||
if (lastIndex >= 0) {
|
||||
listState.animateScrollToItem(lastIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
buttonSpacing = 72.dp,
|
||||
animationDurationMs = 300,
|
||||
staggerDelayMs = 50,
|
||||
// 根据模式选择不同的图标
|
||||
mainButtonIcon = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) {
|
||||
getMultiSelectMainIcon(isFabExpanded)
|
||||
} else {
|
||||
getSingleSelectMainIcon(isFabExpanded)
|
||||
},
|
||||
mainButtonExpandedIcon = Icons.Filled.Close
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
onRefresh = {
|
||||
scope.launch { viewModel.fetchAppList() }
|
||||
},
|
||||
isRefreshing = viewModel.isRefreshing
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
) {
|
||||
items(filteredAndSortedApps, key = { it.packageName + it.uid }) { app ->
|
||||
AppItem(
|
||||
app = app,
|
||||
isSelected = viewModel.selectedApps.contains(app.packageName),
|
||||
onToggleSelection = { viewModel.toggleAppSelection(app.packageName) },
|
||||
onClick = {
|
||||
if (viewModel.showBatchActions) {
|
||||
viewModel.toggleAppSelection(app.packageName)
|
||||
} else {
|
||||
navigator.navigate(AppProfileScreenDestination(app))
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
if (!viewModel.showBatchActions) {
|
||||
viewModel.toggleBatchMode()
|
||||
viewModel.toggleAppSelection(app.packageName)
|
||||
}
|
||||
},
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
// 当没有应用显示时显示加载动画或空状态
|
||||
if (filteredAndSortedApps.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 根据加载状态显示不同内容
|
||||
if ((viewModel.isRefreshing || viewModel.appList.isEmpty()) && viewModel.search.isEmpty()) {
|
||||
LoadingAnimation(
|
||||
isLoading = true
|
||||
)
|
||||
} else {
|
||||
EmptyState(
|
||||
selectedCategory = selectedCategory,
|
||||
isSearchEmpty = viewModel.search.isNotEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BottomSheet
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
showBottomSheet = false
|
||||
},
|
||||
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 = currentSortType,
|
||||
onSortTypeChanged = { newSortType ->
|
||||
viewModel.updateCurrentSortType(newSortType)
|
||||
scope.launch {
|
||||
bottomSheetState.hide()
|
||||
showBottomSheet = false
|
||||
}
|
||||
},
|
||||
selectedCategory = selectedCategory,
|
||||
onCategorySelected = { newCategory ->
|
||||
viewModel.updateSelectedCategory(newCategory)
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
bottomSheetState.hide()
|
||||
showBottomSheet = false
|
||||
}
|
||||
},
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AppItem(
|
||||
app: SuperUserViewModel.AppInfo,
|
||||
isSelected: Boolean,
|
||||
onToggleSelection: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
viewModel: SuperUserViewModel
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = { onLongClick() },
|
||||
onTap = { onClick() }
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(app.label) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(app.packageName)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (app.allowSu) {
|
||||
LabelItem(
|
||||
text = "ROOT",
|
||||
)
|
||||
} else {
|
||||
if (Natives.uidShouldUmount(app.uid)) {
|
||||
LabelItem(
|
||||
text = "UMOUNT",
|
||||
style = LabelItemDefaults.style.copy(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (app.hasCustomProfile) {
|
||||
LabelItem(
|
||||
text = "CUSTOM",
|
||||
style = LabelItemDefaults.style.copy(
|
||||
containerColor = MaterialTheme.colorScheme.onTertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
)
|
||||
} else if (!app.allowSu) {
|
||||
LabelItem(
|
||||
text = "DEFAULT",
|
||||
style = LabelItemDefaults.style.copy(
|
||||
containerColor = Color.Gray
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(app.packageInfo)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = app.label,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.width(48.dp)
|
||||
.height(48.dp)
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (viewModel.showBatchActions) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载动画组件
|
||||
*/
|
||||
@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,
|
||||
)
|
||||
}
|
||||
}
|
||||
256
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt
Normal file
256
manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.widget.Toast
|
||||
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.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.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.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
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
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
|
||||
|
||||
object CardConfig {
|
||||
// 卡片透明度
|
||||
var cardAlpha by mutableFloatStateOf(1f)
|
||||
// 卡片亮度
|
||||
var cardDim by mutableFloatStateOf(0f)
|
||||
// 卡片阴影
|
||||
var cardElevation by mutableStateOf(0.dp)
|
||||
var isShadowEnabled by mutableStateOf(true)
|
||||
var isCustomAlphaSet by mutableStateOf(false)
|
||||
var isCustomDimSet by mutableStateOf(false)
|
||||
var isUserDarkModeEnabled by mutableStateOf(false)
|
||||
var isUserLightModeEnabled by mutableStateOf(false)
|
||||
var isCustomBackgroundEnabled by mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* 保存卡片配置到SharedPreferences
|
||||
*/
|
||||
fun save(context: Context) {
|
||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
prefs.edit().apply {
|
||||
putFloat("card_alpha", cardAlpha)
|
||||
putFloat("card_dim", cardDim)
|
||||
putBoolean("custom_background_enabled", isCustomBackgroundEnabled)
|
||||
putBoolean("is_shadow_enabled", isShadowEnabled)
|
||||
putBoolean("is_custom_alpha_set", isCustomAlphaSet)
|
||||
putBoolean("is_custom_dim_set", isCustomDimSet)
|
||||
putBoolean("is_user_dark_mode_enabled", isUserDarkModeEnabled)
|
||||
putBoolean("is_user_light_mode_enabled", isUserLightModeEnabled)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从SharedPreferences加载卡片配置
|
||||
*/
|
||||
fun load(context: Context) {
|
||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
cardAlpha = prefs.getFloat("card_alpha", 1f)
|
||||
cardDim = prefs.getFloat("card_dim", 0f)
|
||||
isCustomBackgroundEnabled = prefs.getBoolean("custom_background_enabled", false)
|
||||
isShadowEnabled = prefs.getBoolean("is_shadow_enabled", true)
|
||||
isCustomAlphaSet = prefs.getBoolean("is_custom_alpha_set", false)
|
||||
isCustomDimSet = prefs.getBoolean("is_custom_dim_set", false)
|
||||
isUserDarkModeEnabled = prefs.getBoolean("is_user_dark_mode_enabled", false)
|
||||
isUserLightModeEnabled = prefs.getBoolean("is_user_light_mode_enabled", false)
|
||||
updateShadowEnabled(isShadowEnabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新阴影启用状态
|
||||
*/
|
||||
fun updateShadowEnabled(enabled: Boolean) {
|
||||
isShadowEnabled = enabled
|
||||
cardElevation = 0.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置主题模式默认值
|
||||
*/
|
||||
fun setThemeDefaults(isDarkMode: Boolean) {
|
||||
if (!isCustomAlphaSet) {
|
||||
cardAlpha = 1f
|
||||
}
|
||||
if (!isCustomDimSet) {
|
||||
cardDim = if (isDarkMode) 0.5f else 0f
|
||||
}
|
||||
updateShadowEnabled(isShadowEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取卡片颜色配置
|
||||
*/
|
||||
@Composable
|
||||
fun getCardColors(originalColor: Color) = CardDefaults.cardColors(
|
||||
containerColor = originalColor.copy(alpha = CardConfig.cardAlpha),
|
||||
contentColor = determineContentColor(originalColor)
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取卡片阴影配置
|
||||
*/
|
||||
@Composable
|
||||
fun getCardElevation() = CardDefaults.cardElevation(
|
||||
defaultElevation = CardConfig.cardElevation,
|
||||
pressedElevation = CardConfig.cardElevation,
|
||||
focusedElevation = CardConfig.cardElevation,
|
||||
hoveredElevation = CardConfig.cardElevation,
|
||||
draggedElevation = CardConfig.cardElevation,
|
||||
disabledElevation = CardConfig.cardElevation
|
||||
)
|
||||
|
||||
/**
|
||||
* 根据背景颜色、主题模式和用户设置确定内容颜色
|
||||
*/
|
||||
@Composable
|
||||
private fun determineContentColor(originalColor: Color): Color {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
if (ThemeConfig.isThemeChanging) {
|
||||
return if (isDarkTheme) Color.White else Color.Black
|
||||
}
|
||||
|
||||
return when {
|
||||
CardConfig.isUserLightModeEnabled -> Color.Black
|
||||
!isDarkTheme && originalColor.luminance() > 0.5f -> Color.Black
|
||||
isDarkTheme -> Color.White
|
||||
else -> if (originalColor.luminance() > 0.5f) Color.Black else Color.White
|
||||
}
|
||||
}
|
||||
615
manager/app/src/main/java/com/sukisu/ultra/ui/theme/Color.kt
Normal file
615
manager/app/src/main/java/com/sukisu/ultra/ui/theme/Color.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
606
manager/app/src/main/java/com/sukisu/ultra/ui/theme/Theme.kt
Normal file
606
manager/app/src/main/java/com/sukisu/ultra/ui/theme/Theme.kt
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
package com.sukisu.ultra.ui.theme
|
||||
|
||||
import android.content.ContentResolver
|
||||
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.animateFloat
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
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.unit.dp
|
||||
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.util.BackgroundTransformation
|
||||
import com.sukisu.ultra.ui.util.saveTransformedBackground
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* 主题配置对象,管理应用的主题相关状态
|
||||
*/
|
||||
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 needsResetOnThemeChange by mutableStateOf(false)
|
||||
var isThemeChanging by mutableStateOf(false)
|
||||
var preventBackgroundRefresh by mutableStateOf(false)
|
||||
|
||||
private var lastDarkModeState: Boolean? = null
|
||||
fun detectThemeChange(currentDarkMode: Boolean): Boolean {
|
||||
val isChanged = lastDarkModeState != null && lastDarkModeState != currentDarkMode
|
||||
lastDarkModeState = currentDarkMode
|
||||
return isChanged
|
||||
}
|
||||
|
||||
fun resetBackgroundState() {
|
||||
if (!preventBackgroundRefresh) {
|
||||
backgroundImageLoaded = false
|
||||
}
|
||||
isThemeChanging = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用主题
|
||||
*/
|
||||
@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()
|
||||
|
||||
// 检测系统主题变化并保存状态
|
||||
val themeChanged = ThemeConfig.detectThemeChange(systemIsDark)
|
||||
LaunchedEffect(systemIsDark, themeChanged) {
|
||||
if (ThemeConfig.forceDarkMode == null && themeChanged) {
|
||||
Log.d("ThemeSystem", "系统主题变化检测: 从 ${!systemIsDark} 变为 $systemIsDark")
|
||||
ThemeConfig.resetBackgroundState()
|
||||
|
||||
if (!ThemeConfig.preventBackgroundRefresh) {
|
||||
context.loadCustomBackground()
|
||||
}
|
||||
|
||||
CardConfig.apply {
|
||||
load(context)
|
||||
if (!isCustomAlphaSet) {
|
||||
cardAlpha = if (systemIsDark) 0.50f else 1f
|
||||
}
|
||||
if (!isCustomDimSet) {
|
||||
cardDim = if (systemIsDark) 0.5f else 0f
|
||||
}
|
||||
save(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SystemBarStyle(
|
||||
darkMode = darkTheme
|
||||
)
|
||||
|
||||
// 初始加载配置
|
||||
LaunchedEffect(Unit) {
|
||||
context.loadThemeMode()
|
||||
context.loadThemeColors()
|
||||
context.loadDynamicColorState()
|
||||
CardConfig.load(context)
|
||||
|
||||
if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) {
|
||||
context.loadCustomBackground()
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
}
|
||||
|
||||
ThemeConfig.preventBackgroundRefresh = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("prevent_background_refresh", true)
|
||||
}
|
||||
|
||||
// 创建颜色方案
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) createDynamicDarkColorScheme(context) else createDynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> createDarkColorScheme()
|
||||
else -> createLightColorScheme()
|
||||
}
|
||||
|
||||
// 根据暗色模式和自定义背景调整卡片配置
|
||||
val isDarkModeWithCustomBackground = darkTheme && ThemeConfig.customBackgroundUri != null
|
||||
if (darkTheme && !dynamicColor) {
|
||||
CardConfig.setThemeDefaults(true)
|
||||
} else if (!darkTheme && !dynamicColor) {
|
||||
CardConfig.setThemeDefaults(false)
|
||||
}
|
||||
CardConfig.updateShadowEnabled(!isDarkModeWithCustomBackground)
|
||||
|
||||
val backgroundUri = rememberSaveable { mutableStateOf(ThemeConfig.customBackgroundUri) }
|
||||
|
||||
LaunchedEffect(ThemeConfig.customBackgroundUri) {
|
||||
backgroundUri.value = ThemeConfig.customBackgroundUri
|
||||
}
|
||||
|
||||
val bgImagePainter = backgroundUri.value?.let {
|
||||
rememberAsyncImagePainter(
|
||||
model = it,
|
||||
onError = { err ->
|
||||
Log.e("ThemeSystem", "背景图加载失败: ${err.result.throwable.message}")
|
||||
ThemeConfig.customBackgroundUri = null
|
||||
context.saveCustomBackground(null)
|
||||
},
|
||||
onSuccess = {
|
||||
Log.d("ThemeSystem", "背景图加载成功")
|
||||
ThemeConfig.backgroundImageLoaded = true
|
||||
ThemeConfig.isThemeChanging = false
|
||||
|
||||
ThemeConfig.preventBackgroundRefresh = true
|
||||
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.edit { putBoolean("prevent_background_refresh", true) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val transition = updateTransition(
|
||||
targetState = ThemeConfig.backgroundImageLoaded,
|
||||
label = "bgTransition"
|
||||
)
|
||||
val bgAlpha by transition.animateFloat(
|
||||
label = "bgAlpha",
|
||||
transitionSpec = {
|
||||
spring(
|
||||
dampingRatio = 0.8f,
|
||||
stiffness = 300f
|
||||
)
|
||||
}
|
||||
) { loaded -> if (loaded) 1f else 0f }
|
||||
|
||||
DisposableEffect(systemIsDark) {
|
||||
onDispose {
|
||||
if (ThemeConfig.isThemeChanging) {
|
||||
ThemeConfig.isThemeChanging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算适用的暗化值
|
||||
val dimFactor = CardConfig.cardDim
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(-2f)
|
||||
.background(if (darkTheme) if (CardConfig.isCustomBackgroundEnabled) { colorScheme.surfaceContainerLow } else { colorScheme.background }
|
||||
else if (CardConfig.isCustomBackgroundEnabled) { colorScheme.surfaceContainerLow } else { colorScheme.background })
|
||||
)
|
||||
|
||||
// 自定义背景层
|
||||
backgroundUri.value?.let {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(-1f)
|
||||
.alpha(bgAlpha)
|
||||
) {
|
||||
// 背景图片
|
||||
bgImagePainter?.let { painter ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.paint(
|
||||
painter = painter,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
.graphicsLayer {
|
||||
alpha = (painter.state as? AsyncImagePainter.State.Success)?.let { 1f } ?: 0f
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 亮度调节层 (根据cardDim调整)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (darkTheme) Color.Black.copy(alpha = 0.6f + dimFactor * 0.3f)
|
||||
else Color.White.copy(alpha = 0.1f + dimFactor * 0.2f)
|
||||
)
|
||||
)
|
||||
|
||||
// 边缘渐变遮罩
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
if (darkTheme) Color.Black.copy(alpha = 0.5f + dimFactor * 0.2f)
|
||||
else Color.Black.copy(alpha = 0.2f + dimFactor * 0.1f)
|
||||
),
|
||||
radius = 1200f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 内容层
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(1f)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建动态深色颜色方案
|
||||
*/
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* 复制图片到应用内部存储并提升持久性
|
||||
*/
|
||||
private fun Context.copyImageToInternalStorage(uri: Uri): Uri? {
|
||||
return try {
|
||||
val contentResolver: ContentResolver = contentResolver
|
||||
val inputStream: InputStream = contentResolver.openInputStream(uri) ?: return null
|
||||
|
||||
val fileName = "custom_background.jpg"
|
||||
val file = File(filesDir, fileName)
|
||||
|
||||
val backupFile = File(filesDir, "${fileName}.backup")
|
||||
val outputStream = FileOutputStream(backupFile)
|
||||
val buffer = ByteArray(4 * 1024)
|
||||
var read: Int
|
||||
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
outputStream.write(buffer, 0, read)
|
||||
}
|
||||
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
inputStream.close()
|
||||
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
}
|
||||
backupFile.renameTo(file)
|
||||
|
||||
Uri.fromFile(file)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageCopy", "复制图片失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存并应用自定义背景
|
||||
*/
|
||||
fun Context.saveAndApplyCustomBackground(uri: Uri, transformation: BackgroundTransformation? = null) {
|
||||
val finalUri = if (transformation != null) {
|
||||
saveTransformedBackground(uri, transformation)
|
||||
} else {
|
||||
copyImageToInternalStorage(uri)
|
||||
}
|
||||
|
||||
// 保存到配置文件
|
||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.edit {
|
||||
putString("custom_background", finalUri?.toString())
|
||||
// 设置阻止刷新标志为false,允许新设置的背景加载一次
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
}
|
||||
|
||||
ThemeConfig.customBackgroundUri = finalUri
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
ThemeConfig.preventBackgroundRefresh = false
|
||||
CardConfig.cardElevation = 0.dp
|
||||
CardConfig.isCustomBackgroundEnabled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存自定义背景
|
||||
*/
|
||||
fun Context.saveCustomBackground(uri: Uri?) {
|
||||
val newUri = uri?.let { copyImageToInternalStorage(it) }
|
||||
|
||||
// 保存到配置文件
|
||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.edit {
|
||||
putString("custom_background", newUri?.toString())
|
||||
if (uri == null) {
|
||||
// 如果清除背景,也重置阻止刷新标志
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
} else {
|
||||
// 设置阻止刷新标志为false,允许新设置的背景加载一次
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
}
|
||||
}
|
||||
|
||||
ThemeConfig.customBackgroundUri = newUri
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
ThemeConfig.preventBackgroundRefresh = false
|
||||
|
||||
if (uri != null) {
|
||||
CardConfig.cardElevation = 0.dp
|
||||
CardConfig.isCustomBackgroundEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载自定义背景
|
||||
*/
|
||||
fun Context.loadCustomBackground() {
|
||||
val uriString = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getString("custom_background", null)
|
||||
|
||||
val newUri = uriString?.toUri()
|
||||
val preventRefresh = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("prevent_background_refresh", false)
|
||||
|
||||
ThemeConfig.preventBackgroundRefresh = preventRefresh
|
||||
|
||||
if (!preventRefresh || ThemeConfig.customBackgroundUri?.toString() != newUri?.toString()) {
|
||||
Log.d("ThemeSystem", "加载自定义背景: $uriString, 阻止刷新: $preventRefresh")
|
||||
ThemeConfig.customBackgroundUri = newUri
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存主题模式
|
||||
*/
|
||||
fun Context.saveThemeMode(forceDark: Boolean?) {
|
||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.edit {
|
||||
putString(
|
||||
"theme_mode", when (forceDark) {
|
||||
true -> "dark"
|
||||
false -> "light"
|
||||
null -> "system"
|
||||
}
|
||||
)
|
||||
}
|
||||
ThemeConfig.forceDarkMode = forceDark
|
||||
ThemeConfig.needsResetOnThemeChange = forceDark == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载主题模式
|
||||
*/
|
||||
fun Context.loadThemeMode() {
|
||||
val mode = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getString("theme_mode", "system")
|
||||
|
||||
ThemeConfig.forceDarkMode = when(mode) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> null
|
||||
}
|
||||
ThemeConfig.needsResetOnThemeChange = ThemeConfig.forceDarkMode == null
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存主题颜色
|
||||
*/
|
||||
fun Context.saveThemeColors(themeName: String) {
|
||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.edit {
|
||||
putString("theme_colors", themeName)
|
||||
}
|
||||
|
||||
ThemeConfig.currentTheme = ThemeColors.fromName(themeName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载主题颜色
|
||||
*/
|
||||
fun Context.loadThemeColors() {
|
||||
val themeName = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getString("theme_colors", "default")
|
||||
|
||||
ThemeConfig.currentTheme = ThemeColors.fromName(themeName ?: "default")
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存动态颜色状态
|
||||
*/
|
||||
fun Context.saveDynamicColorState(enabled: Boolean) {
|
||||
getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.edit {
|
||||
putBoolean("use_dynamic_color", enabled)
|
||||
}
|
||||
ThemeConfig.useDynamicColor = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载动态颜色状态
|
||||
*/
|
||||
fun Context.loadDynamicColorState() {
|
||||
val enabled = getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("use_dynamic_color", true)
|
||||
|
||||
ThemeConfig.useDynamicColor = enabled
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemBarStyle(
|
||||
darkMode: Boolean,
|
||||
statusBarScrim: Color = Color.Transparent,
|
||||
navigationBarScrim: Color = Color.Transparent,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as ComponentActivity
|
||||
|
||||
SideEffect {
|
||||
activity.enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
statusBarScrim.toArgb(),
|
||||
statusBarScrim.toArgb(),
|
||||
) { darkMode },
|
||||
navigationBarStyle = when {
|
||||
darkMode -> SystemBarStyle.dark(
|
||||
navigationBarScrim.toArgb()
|
||||
)
|
||||
|
||||
else -> SystemBarStyle.light(
|
||||
navigationBarScrim.toArgb(),
|
||||
navigationBarScrim.toArgb(),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
108
manager/app/src/main/java/com/sukisu/ultra/ui/theme/Type.kt
Normal file
108
manager/app/src/main/java/com/sukisu/ultra/ui/theme/Type.kt
Normal 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
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package com.sukisu.ultra.ui.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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
319
manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt
Normal file
319
manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt
Normal 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 download,ID: $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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,572 @@
|
|||
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.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* An object to convert Chinese character to its corresponding pinyin string. For characters with
|
||||
* multiple possible pinyin string, only one is selected according to collator. Polyphone is not
|
||||
* supported in this implementation. This class is implemented to achieve the best runtime
|
||||
* performance and minimum runtime resources with tolerable sacrifice of accuracy. This
|
||||
* implementation highly depends on zh_CN ICU collation data and must be always synchronized with
|
||||
* ICU.
|
||||
* <p>
|
||||
* Currently this file is aligned to zh.txt in ICU 4.6
|
||||
*/
|
||||
@SuppressWarnings("SizeReplaceableByIsEmpty")
|
||||
public record HanziToPinyin(boolean mHasChinaCollator) {
|
||||
private static final String TAG = "HanziToPinyin";
|
||||
|
||||
// Turn on this flag when we want to check internal data structure.
|
||||
private static final boolean DEBUG = false;
|
||||
|
||||
/**
|
||||
* Unihans array.
|
||||
* <p>
|
||||
* Each unihans is the first one within same pinyin when collator is zh_CN.
|
||||
*/
|
||||
public static final char[] UNIHANS = {
|
||||
'阿', '哎', '安', '肮', '凹', '八',
|
||||
'挀', '扳', '邦', '勹', '陂', '奔',
|
||||
'伻', '屄', '边', '灬', '憋', '汃',
|
||||
'冫', '癶', '峬', '嚓', '偲', '参',
|
||||
'仓', '撡', '冊', '嵾', '曽', '曾',
|
||||
'層', '叉', '芆', '辿', '伥', '抄',
|
||||
'车', '抻', '沈', '沉', '阷', '吃',
|
||||
'充', '抽', '出', '欻', '揣', '巛',
|
||||
'刅', '吹', '旾', '逴', '呲', '匆',
|
||||
'凑', '粗', '汆', '崔', '邨', '搓',
|
||||
'咑', '呆', '丹', '当', '刀', '嘚',
|
||||
'扥', '灯', '氐', '嗲', '甸', '刁',
|
||||
'爹', '丁', '丟', '东', '吺', '厾',
|
||||
'耑', '襨', '吨', '多', '妸', '诶',
|
||||
'奀', '鞥', '儿', '发', '帆', '匚',
|
||||
'飞', '分', '丰', '覅', '仏', '紑',
|
||||
'伕', '旮', '侅', '甘', '冈', '皋',
|
||||
'戈', '给', '根', '刯', '工', '勾',
|
||||
'估', '瓜', '乖', '关', '光', '归',
|
||||
'丨', '呙', '哈', '咍', '佄', '夯',
|
||||
'茠', '诃', '黒', '拫', '亨', '噷',
|
||||
'叿', '齁', '乯', '花', '怀', '犿',
|
||||
'巟', '灰', '昏', '吙', '丌', '加',
|
||||
'戋', '江', '艽', '阶', '巾', '坕',
|
||||
'冂', '丩', '凥', '姢', '噘', '军',
|
||||
'咔', '开', '刊', '忼', '尻', '匼',
|
||||
'肎', '劥', '空', '抠', '扝', '夸',
|
||||
'蒯', '宽', '匡', '亏', '坤', '扩',
|
||||
'垃', '来', '兰', '啷', '捞', '肋',
|
||||
'勒', '崚', '刕', '俩', '奁', '良',
|
||||
'撩', '列', '拎', '刢', '溜', '囖',
|
||||
'龙', '瞜', '噜', '娈', '畧', '抡',
|
||||
'罗', '呣', '妈', '埋', '嫚', '牤',
|
||||
'猫', '么', '呅', '门', '甿', '咪',
|
||||
'宀', '喵', '乜', '民', '名', '谬',
|
||||
'摸', '哞', '毪', '嗯', '拏', '腉',
|
||||
'囡', '囔', '孬', '疒', '娞', '恁',
|
||||
'能', '妮', '拈', '嬢', '鸟', '捏',
|
||||
'囜', '宁', '妞', '农', '羺', '奴',
|
||||
'奻', '疟', '黁', '郍', '喔', '讴',
|
||||
'妑', '拍', '眅', '乓', '抛', '呸',
|
||||
'喷', '匉', '丕', '囨', '剽', '氕',
|
||||
'姘', '乒', '钋', '剖', '仆', '七',
|
||||
'掐', '千', '呛', '悄', '癿', '亲',
|
||||
'狅', '芎', '丘', '区', '峑', '缺',
|
||||
'夋', '呥', '穣', '娆', '惹', '人',
|
||||
'扔', '日', '茸', '厹', '邚', '挼',
|
||||
'堧', '婑', '瞤', '捼', '仨', '毢',
|
||||
'三', '桒', '掻', '閪', '森', '僧',
|
||||
'杀', '筛', '山', '伤', '弰', '奢',
|
||||
'申', '莘', '敒', '升', '尸', '収',
|
||||
'书', '刷', '衰', '闩', '双', '谁',
|
||||
'吮', '说', '厶', '忪', '捜', '苏',
|
||||
'狻', '夊', '孙', '唆', '他', '囼',
|
||||
'坍', '汤', '夲', '忑', '熥', '剔',
|
||||
'天', '旫', '帖', '厅', '囲', '偷',
|
||||
'凸', '湍', '推', '吞', '乇', '穵',
|
||||
'歪', '弯', '尣', '危', '昷', '翁',
|
||||
'挝', '乌', '夕', '虲', '仚', '乡',
|
||||
'灱', '些', '心', '星', '凶', '休',
|
||||
'吁', '吅', '削', '坃', '丫', '恹',
|
||||
'央', '幺', '倻', '一', '囙', '应',
|
||||
'哟', '佣', '优', '扜', '囦', '曰',
|
||||
'晕', '筠', '筼', '帀', '災', '兂',
|
||||
'匨', '傮', '则', '贼', '怎', '増',
|
||||
'扎', '捚', '沾', '张', '长', '長',
|
||||
'佋', '蜇', '贞', '争', '之', '峙',
|
||||
'庢', '中', '州', '朱', '抓', '拽',
|
||||
'专', '妆', '隹', '宒', '卓', '乲',
|
||||
'宗', '邹', '租', '钻', '厜', '尊',
|
||||
'昨', '兙', '鿃', '鿄'};
|
||||
|
||||
/**
|
||||
* Pinyin array.
|
||||
* <p>
|
||||
* Each pinyin is corresponding to unihans of same
|
||||
* offset in the unihans array.
|
||||
*/
|
||||
public static final byte[][] PINYINS = {
|
||||
{65, 0, 0, 0, 0, 0}, {65, 73, 0, 0, 0, 0},
|
||||
{65, 78, 0, 0, 0, 0}, {65, 78, 71, 0, 0, 0},
|
||||
{65, 79, 0, 0, 0, 0}, {66, 65, 0, 0, 0, 0},
|
||||
{66, 65, 73, 0, 0, 0}, {66, 65, 78, 0, 0, 0},
|
||||
{66, 65, 78, 71, 0, 0}, {66, 65, 79, 0, 0, 0},
|
||||
{66, 69, 73, 0, 0, 0}, {66, 69, 78, 0, 0, 0},
|
||||
{66, 69, 78, 71, 0, 0}, {66, 73, 0, 0, 0, 0},
|
||||
{66, 73, 65, 78, 0, 0}, {66, 73, 65, 79, 0, 0},
|
||||
{66, 73, 69, 0, 0, 0}, {66, 73, 78, 0, 0, 0},
|
||||
{66, 73, 78, 71, 0, 0}, {66, 79, 0, 0, 0, 0},
|
||||
{66, 85, 0, 0, 0, 0}, {67, 65, 0, 0, 0, 0},
|
||||
{67, 65, 73, 0, 0, 0}, {67, 65, 78, 0, 0, 0},
|
||||
{67, 65, 78, 71, 0, 0}, {67, 65, 79, 0, 0, 0},
|
||||
{67, 69, 0, 0, 0, 0}, {67, 69, 78, 0, 0, 0},
|
||||
{67, 69, 78, 71, 0, 0}, {90, 69, 78, 71, 0, 0},
|
||||
{67, 69, 78, 71, 0, 0}, {67, 72, 65, 0, 0, 0},
|
||||
{67, 72, 65, 73, 0, 0}, {67, 72, 65, 78, 0, 0},
|
||||
{67, 72, 65, 78, 71, 0}, {67, 72, 65, 79, 0, 0},
|
||||
{67, 72, 69, 0, 0, 0}, {67, 72, 69, 78, 0, 0},
|
||||
{83, 72, 69, 78, 0, 0}, {67, 72, 69, 78, 0, 0},
|
||||
{67, 72, 69, 78, 71, 0}, {67, 72, 73, 0, 0, 0},
|
||||
{67, 72, 79, 78, 71, 0}, {67, 72, 79, 85, 0, 0},
|
||||
{67, 72, 85, 0, 0, 0}, {67, 72, 85, 65, 0, 0},
|
||||
{67, 72, 85, 65, 73, 0}, {67, 72, 85, 65, 78, 0},
|
||||
{67, 72, 85, 65, 78, 71}, {67, 72, 85, 73, 0, 0},
|
||||
{67, 72, 85, 78, 0, 0}, {67, 72, 85, 79, 0, 0},
|
||||
{67, 73, 0, 0, 0, 0}, {67, 79, 78, 71, 0, 0},
|
||||
{67, 79, 85, 0, 0, 0}, {67, 85, 0, 0, 0, 0},
|
||||
{67, 85, 65, 78, 0, 0}, {67, 85, 73, 0, 0, 0},
|
||||
{67, 85, 78, 0, 0, 0}, {67, 85, 79, 0, 0, 0},
|
||||
{68, 65, 0, 0, 0, 0}, {68, 65, 73, 0, 0, 0},
|
||||
{68, 65, 78, 0, 0, 0}, {68, 65, 78, 71, 0, 0},
|
||||
{68, 65, 79, 0, 0, 0}, {68, 69, 0, 0, 0, 0},
|
||||
{68, 69, 78, 0, 0, 0}, {68, 69, 78, 71, 0, 0},
|
||||
{68, 73, 0, 0, 0, 0}, {68, 73, 65, 0, 0, 0},
|
||||
{68, 73, 65, 78, 0, 0}, {68, 73, 65, 79, 0, 0},
|
||||
{68, 73, 69, 0, 0, 0}, {68, 73, 78, 71, 0, 0},
|
||||
{68, 73, 85, 0, 0, 0}, {68, 79, 78, 71, 0, 0},
|
||||
{68, 79, 85, 0, 0, 0}, {68, 85, 0, 0, 0, 0},
|
||||
{68, 85, 65, 78, 0, 0}, {68, 85, 73, 0, 0, 0},
|
||||
{68, 85, 78, 0, 0, 0}, {68, 85, 79, 0, 0, 0},
|
||||
{69, 0, 0, 0, 0, 0}, {69, 73, 0, 0, 0, 0},
|
||||
{69, 78, 0, 0, 0, 0}, {69, 78, 71, 0, 0, 0},
|
||||
{69, 82, 0, 0, 0, 0}, {70, 65, 0, 0, 0, 0},
|
||||
{70, 65, 78, 0, 0, 0}, {70, 65, 78, 71, 0, 0},
|
||||
{70, 69, 73, 0, 0, 0}, {70, 69, 78, 0, 0, 0},
|
||||
{70, 69, 78, 71, 0, 0}, {70, 73, 65, 79, 0, 0},
|
||||
{70, 79, 0, 0, 0, 0}, {70, 79, 85, 0, 0, 0},
|
||||
{70, 85, 0, 0, 0, 0}, {71, 65, 0, 0, 0, 0},
|
||||
{71, 65, 73, 0, 0, 0}, {71, 65, 78, 0, 0, 0},
|
||||
{71, 65, 78, 71, 0, 0}, {71, 65, 79, 0, 0, 0},
|
||||
{71, 69, 0, 0, 0, 0}, {71, 69, 73, 0, 0, 0},
|
||||
{71, 69, 78, 0, 0, 0}, {71, 69, 78, 71, 0, 0},
|
||||
{71, 79, 78, 71, 0, 0}, {71, 79, 85, 0, 0, 0},
|
||||
{71, 85, 0, 0, 0, 0}, {71, 85, 65, 0, 0, 0},
|
||||
{71, 85, 65, 73, 0, 0}, {71, 85, 65, 78, 0, 0},
|
||||
{71, 85, 65, 78, 71, 0}, {71, 85, 73, 0, 0, 0},
|
||||
{71, 85, 78, 0, 0, 0}, {71, 85, 79, 0, 0, 0},
|
||||
{72, 65, 0, 0, 0, 0}, {72, 65, 73, 0, 0, 0},
|
||||
{72, 65, 78, 0, 0, 0}, {72, 65, 78, 71, 0, 0},
|
||||
{72, 65, 79, 0, 0, 0}, {72, 69, 0, 0, 0, 0},
|
||||
{72, 69, 73, 0, 0, 0}, {72, 69, 78, 0, 0, 0},
|
||||
{72, 69, 78, 71, 0, 0}, {72, 77, 0, 0, 0, 0},
|
||||
{72, 79, 78, 71, 0, 0}, {72, 79, 85, 0, 0, 0},
|
||||
{72, 85, 0, 0, 0, 0}, {72, 85, 65, 0, 0, 0},
|
||||
{72, 85, 65, 73, 0, 0}, {72, 85, 65, 78, 0, 0},
|
||||
{72, 85, 65, 78, 71, 0}, {72, 85, 73, 0, 0, 0},
|
||||
{72, 85, 78, 0, 0, 0}, {72, 85, 79, 0, 0, 0},
|
||||
{74, 73, 0, 0, 0, 0}, {74, 73, 65, 0, 0, 0},
|
||||
{74, 73, 65, 78, 0, 0}, {74, 73, 65, 78, 71, 0},
|
||||
{74, 73, 65, 79, 0, 0}, {74, 73, 69, 0, 0, 0},
|
||||
{74, 73, 78, 0, 0, 0}, {74, 73, 78, 71, 0, 0},
|
||||
{74, 73, 79, 78, 71, 0}, {74, 73, 85, 0, 0, 0},
|
||||
{74, 85, 0, 0, 0, 0}, {74, 85, 65, 78, 0, 0},
|
||||
{74, 85, 69, 0, 0, 0}, {74, 85, 78, 0, 0, 0},
|
||||
{75, 65, 0, 0, 0, 0}, {75, 65, 73, 0, 0, 0},
|
||||
{75, 65, 78, 0, 0, 0}, {75, 65, 78, 71, 0, 0},
|
||||
{75, 65, 79, 0, 0, 0}, {75, 69, 0, 0, 0, 0},
|
||||
{75, 69, 78, 0, 0, 0}, {75, 69, 78, 71, 0, 0},
|
||||
{75, 79, 78, 71, 0, 0}, {75, 79, 85, 0, 0, 0},
|
||||
{75, 85, 0, 0, 0, 0}, {75, 85, 65, 0, 0, 0},
|
||||
{75, 85, 65, 73, 0, 0}, {75, 85, 65, 78, 0, 0},
|
||||
{75, 85, 65, 78, 71, 0}, {75, 85, 73, 0, 0, 0},
|
||||
{75, 85, 78, 0, 0, 0}, {75, 85, 79, 0, 0, 0},
|
||||
{76, 65, 0, 0, 0, 0}, {76, 65, 73, 0, 0, 0},
|
||||
{76, 65, 78, 0, 0, 0}, {76, 65, 78, 71, 0, 0},
|
||||
{76, 65, 79, 0, 0, 0}, {76, 69, 0, 0, 0, 0},
|
||||
{76, 69, 73, 0, 0, 0}, {76, 69, 78, 71, 0, 0},
|
||||
{76, 73, 0, 0, 0, 0}, {76, 73, 65, 0, 0, 0},
|
||||
{76, 73, 65, 78, 0, 0}, {76, 73, 65, 78, 71, 0},
|
||||
{76, 73, 65, 79, 0, 0}, {76, 73, 69, 0, 0, 0},
|
||||
{76, 73, 78, 0, 0, 0}, {76, 73, 78, 71, 0, 0},
|
||||
{76, 73, 85, 0, 0, 0}, {76, 79, 0, 0, 0, 0},
|
||||
{76, 79, 78, 71, 0, 0}, {76, 79, 85, 0, 0, 0},
|
||||
{76, 85, 0, 0, 0, 0}, {76, 85, 65, 78, 0, 0},
|
||||
{76, 85, 69, 0, 0, 0}, {76, 85, 78, 0, 0, 0},
|
||||
{76, 85, 79, 0, 0, 0}, {77, 0, 0, 0, 0, 0},
|
||||
{77, 65, 0, 0, 0, 0}, {77, 65, 73, 0, 0, 0},
|
||||
{77, 65, 78, 0, 0, 0}, {77, 65, 78, 71, 0, 0},
|
||||
{77, 65, 79, 0, 0, 0}, {77, 69, 0, 0, 0, 0},
|
||||
{77, 69, 73, 0, 0, 0}, {77, 69, 78, 0, 0, 0},
|
||||
{77, 69, 78, 71, 0, 0}, {77, 73, 0, 0, 0, 0},
|
||||
{77, 73, 65, 78, 0, 0}, {77, 73, 65, 79, 0, 0},
|
||||
{77, 73, 69, 0, 0, 0}, {77, 73, 78, 0, 0, 0},
|
||||
{77, 73, 78, 71, 0, 0}, {77, 73, 85, 0, 0, 0},
|
||||
{77, 79, 0, 0, 0, 0}, {77, 79, 85, 0, 0, 0},
|
||||
{77, 85, 0, 0, 0, 0}, {78, 0, 0, 0, 0, 0},
|
||||
{78, 65, 0, 0, 0, 0}, {78, 65, 73, 0, 0, 0},
|
||||
{78, 65, 78, 0, 0, 0}, {78, 65, 78, 71, 0, 0},
|
||||
{78, 65, 79, 0, 0, 0}, {78, 69, 0, 0, 0, 0},
|
||||
{78, 69, 73, 0, 0, 0}, {78, 69, 78, 0, 0, 0},
|
||||
{78, 69, 78, 71, 0, 0}, {78, 73, 0, 0, 0, 0},
|
||||
{78, 73, 65, 78, 0, 0}, {78, 73, 65, 78, 71, 0},
|
||||
{78, 73, 65, 79, 0, 0}, {78, 73, 69, 0, 0, 0},
|
||||
{78, 73, 78, 0, 0, 0}, {78, 73, 78, 71, 0, 0},
|
||||
{78, 73, 85, 0, 0, 0}, {78, 79, 78, 71, 0, 0},
|
||||
{78, 79, 85, 0, 0, 0}, {78, 85, 0, 0, 0, 0},
|
||||
{78, 85, 65, 78, 0, 0}, {78, 85, 69, 0, 0, 0},
|
||||
{78, 85, 78, 0, 0, 0}, {78, 85, 79, 0, 0, 0},
|
||||
{79, 0, 0, 0, 0, 0}, {79, 85, 0, 0, 0, 0},
|
||||
{80, 65, 0, 0, 0, 0}, {80, 65, 73, 0, 0, 0},
|
||||
{80, 65, 78, 0, 0, 0}, {80, 65, 78, 71, 0, 0},
|
||||
{80, 65, 79, 0, 0, 0}, {80, 69, 73, 0, 0, 0},
|
||||
{80, 69, 78, 0, 0, 0}, {80, 69, 78, 71, 0, 0},
|
||||
{80, 73, 0, 0, 0, 0}, {80, 73, 65, 78, 0, 0},
|
||||
{80, 73, 65, 79, 0, 0}, {80, 73, 69, 0, 0, 0},
|
||||
{80, 73, 78, 0, 0, 0}, {80, 73, 78, 71, 0, 0},
|
||||
{80, 79, 0, 0, 0, 0}, {80, 79, 85, 0, 0, 0},
|
||||
{80, 85, 0, 0, 0, 0}, {81, 73, 0, 0, 0, 0},
|
||||
{81, 73, 65, 0, 0, 0}, {81, 73, 65, 78, 0, 0},
|
||||
{81, 73, 65, 78, 71, 0}, {81, 73, 65, 79, 0, 0},
|
||||
{81, 73, 69, 0, 0, 0}, {81, 73, 78, 0, 0, 0},
|
||||
{81, 73, 78, 71, 0, 0}, {81, 73, 79, 78, 71, 0},
|
||||
{81, 73, 85, 0, 0, 0}, {81, 85, 0, 0, 0, 0},
|
||||
{81, 85, 65, 78, 0, 0}, {81, 85, 69, 0, 0, 0},
|
||||
{81, 85, 78, 0, 0, 0}, {82, 65, 78, 0, 0, 0},
|
||||
{82, 65, 78, 71, 0, 0}, {82, 65, 79, 0, 0, 0},
|
||||
{82, 69, 0, 0, 0, 0}, {82, 69, 78, 0, 0, 0},
|
||||
{82, 69, 78, 71, 0, 0}, {82, 73, 0, 0, 0, 0},
|
||||
{82, 79, 78, 71, 0, 0}, {82, 79, 85, 0, 0, 0},
|
||||
{82, 85, 0, 0, 0, 0}, {82, 85, 65, 0, 0, 0},
|
||||
{82, 85, 65, 78, 0, 0}, {82, 85, 73, 0, 0, 0},
|
||||
{82, 85, 78, 0, 0, 0}, {82, 85, 79, 0, 0, 0},
|
||||
{83, 65, 0, 0, 0, 0}, {83, 65, 73, 0, 0, 0},
|
||||
{83, 65, 78, 0, 0, 0}, {83, 65, 78, 71, 0, 0},
|
||||
{83, 65, 79, 0, 0, 0}, {83, 69, 0, 0, 0, 0},
|
||||
{83, 69, 78, 0, 0, 0}, {83, 69, 78, 71, 0, 0},
|
||||
{83, 72, 65, 0, 0, 0}, {83, 72, 65, 73, 0, 0},
|
||||
{83, 72, 65, 78, 0, 0}, {83, 72, 65, 78, 71, 0},
|
||||
{83, 72, 65, 79, 0, 0}, {83, 72, 69, 0, 0, 0},
|
||||
{83, 72, 69, 78, 0, 0}, {88, 73, 78, 0, 0, 0},
|
||||
{83, 72, 69, 78, 0, 0}, {83, 72, 69, 78, 71, 0},
|
||||
{83, 72, 73, 0, 0, 0}, {83, 72, 79, 85, 0, 0},
|
||||
{83, 72, 85, 0, 0, 0}, {83, 72, 85, 65, 0, 0},
|
||||
{83, 72, 85, 65, 73, 0}, {83, 72, 85, 65, 78, 0},
|
||||
{83, 72, 85, 65, 78, 71}, {83, 72, 85, 73, 0, 0},
|
||||
{83, 72, 85, 78, 0, 0}, {83, 72, 85, 79, 0, 0},
|
||||
{83, 73, 0, 0, 0, 0}, {83, 79, 78, 71, 0, 0},
|
||||
{83, 79, 85, 0, 0, 0}, {83, 85, 0, 0, 0, 0},
|
||||
{83, 85, 65, 78, 0, 0}, {83, 85, 73, 0, 0, 0},
|
||||
{83, 85, 78, 0, 0, 0}, {83, 85, 79, 0, 0, 0},
|
||||
{84, 65, 0, 0, 0, 0}, {84, 65, 73, 0, 0, 0},
|
||||
{84, 65, 78, 0, 0, 0}, {84, 65, 78, 71, 0, 0},
|
||||
{84, 65, 79, 0, 0, 0}, {84, 69, 0, 0, 0, 0},
|
||||
{84, 69, 78, 71, 0, 0}, {84, 73, 0, 0, 0, 0},
|
||||
{84, 73, 65, 78, 0, 0}, {84, 73, 65, 79, 0, 0},
|
||||
{84, 73, 69, 0, 0, 0}, {84, 73, 78, 71, 0, 0},
|
||||
{84, 79, 78, 71, 0, 0}, {84, 79, 85, 0, 0, 0},
|
||||
{84, 85, 0, 0, 0, 0}, {84, 85, 65, 78, 0, 0},
|
||||
{84, 85, 73, 0, 0, 0}, {84, 85, 78, 0, 0, 0},
|
||||
{84, 85, 79, 0, 0, 0}, {87, 65, 0, 0, 0, 0},
|
||||
{87, 65, 73, 0, 0, 0}, {87, 65, 78, 0, 0, 0},
|
||||
{87, 65, 78, 71, 0, 0}, {87, 69, 73, 0, 0, 0},
|
||||
{87, 69, 78, 0, 0, 0}, {87, 69, 78, 71, 0, 0},
|
||||
{87, 79, 0, 0, 0, 0}, {87, 85, 0, 0, 0, 0},
|
||||
{88, 73, 0, 0, 0, 0}, {88, 73, 65, 0, 0, 0},
|
||||
{88, 73, 65, 78, 0, 0}, {88, 73, 65, 78, 71, 0},
|
||||
{88, 73, 65, 79, 0, 0}, {88, 73, 69, 0, 0, 0},
|
||||
{88, 73, 78, 0, 0, 0}, {88, 73, 78, 71, 0, 0},
|
||||
{88, 73, 79, 78, 71, 0}, {88, 73, 85, 0, 0, 0},
|
||||
{88, 85, 0, 0, 0, 0}, {88, 85, 65, 78, 0, 0},
|
||||
{88, 85, 69, 0, 0, 0}, {88, 85, 78, 0, 0, 0},
|
||||
{89, 65, 0, 0, 0, 0}, {89, 65, 78, 0, 0, 0},
|
||||
{89, 65, 78, 71, 0, 0}, {89, 65, 79, 0, 0, 0},
|
||||
{89, 69, 0, 0, 0, 0}, {89, 73, 0, 0, 0, 0},
|
||||
{89, 73, 78, 0, 0, 0}, {89, 73, 78, 71, 0, 0},
|
||||
{89, 79, 0, 0, 0, 0}, {89, 79, 78, 71, 0, 0},
|
||||
{89, 79, 85, 0, 0, 0}, {89, 85, 0, 0, 0, 0},
|
||||
{89, 85, 65, 78, 0, 0}, {89, 85, 69, 0, 0, 0},
|
||||
{89, 85, 78, 0, 0, 0}, {74, 85, 78, 0, 0, 0},
|
||||
{89, 85, 78, 0, 0, 0}, {90, 65, 0, 0, 0, 0},
|
||||
{90, 65, 73, 0, 0, 0}, {90, 65, 78, 0, 0, 0},
|
||||
{90, 65, 78, 71, 0, 0}, {90, 65, 79, 0, 0, 0},
|
||||
{90, 69, 0, 0, 0, 0}, {90, 69, 73, 0, 0, 0},
|
||||
{90, 69, 78, 0, 0, 0}, {90, 69, 78, 71, 0, 0},
|
||||
{90, 72, 65, 0, 0, 0}, {90, 72, 65, 73, 0, 0},
|
||||
{90, 72, 65, 78, 0, 0}, {90, 72, 65, 78, 71, 0},
|
||||
{67, 72, 65, 78, 71, 0}, {90, 72, 65, 78, 71, 0},
|
||||
{90, 72, 65, 79, 0, 0}, {90, 72, 69, 0, 0, 0},
|
||||
{90, 72, 69, 78, 0, 0}, {90, 72, 69, 78, 71, 0},
|
||||
{90, 72, 73, 0, 0, 0}, {83, 72, 73, 0, 0, 0},
|
||||
{90, 72, 73, 0, 0, 0}, {90, 72, 79, 78, 71, 0},
|
||||
{90, 72, 79, 85, 0, 0}, {90, 72, 85, 0, 0, 0},
|
||||
{90, 72, 85, 65, 0, 0}, {90, 72, 85, 65, 73, 0},
|
||||
{90, 72, 85, 65, 78, 0}, {90, 72, 85, 65, 78, 71},
|
||||
{90, 72, 85, 73, 0, 0}, {90, 72, 85, 78, 0, 0},
|
||||
{90, 72, 85, 79, 0, 0}, {90, 73, 0, 0, 0, 0},
|
||||
{90, 79, 78, 71, 0, 0}, {90, 79, 85, 0, 0, 0},
|
||||
{90, 85, 0, 0, 0, 0}, {90, 85, 65, 78, 0, 0},
|
||||
{90, 85, 73, 0, 0, 0}, {90, 85, 78, 0, 0, 0},
|
||||
{90, 85, 79, 0, 0, 0}, {0, 0, 0, 0, 0, 0},
|
||||
{83, 72, 65, 78, 0, 0}, {0, 0, 0, 0, 0, 0}};
|
||||
|
||||
/**
|
||||
* First and last Chinese character with known Pinyin according to zh collation
|
||||
*/
|
||||
private static final String FIRST_PINYIN_UNIHAN = "阿";
|
||||
private static final String LAST_PINYIN_UNIHAN = "鿿";
|
||||
|
||||
private static final Collator COLLATOR = Collator.getInstance(Locale.CHINA);
|
||||
|
||||
private static HanziToPinyin sInstance;
|
||||
|
||||
public static class Token {
|
||||
/**
|
||||
* Separator between target string for each source char
|
||||
*/
|
||||
public static final String SEPARATOR = " ";
|
||||
|
||||
public static final int LATIN = 1;
|
||||
public static final int PINYIN = 2;
|
||||
public static final int UNKNOWN = 3;
|
||||
|
||||
public Token() {
|
||||
}
|
||||
|
||||
public Token(int type, String source, String target) {
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of this token, ASCII, PINYIN or UNKNOWN.
|
||||
*/
|
||||
public int type;
|
||||
/**
|
||||
* Original string before translation.
|
||||
*/
|
||||
public String source;
|
||||
/**
|
||||
* Translated string of source. For Han, target is corresponding Pinyin. Otherwise target is
|
||||
* original string in source.
|
||||
*/
|
||||
public String target;
|
||||
}
|
||||
|
||||
public static HanziToPinyin getInstance() {
|
||||
synchronized (HanziToPinyin.class) {
|
||||
if (sInstance != null) {
|
||||
return sInstance;
|
||||
}
|
||||
// Check if zh_CN collation data is available
|
||||
final Locale[] locale = Collator.getAvailableLocales();
|
||||
for (Locale value : locale) {
|
||||
if (value.equals(Locale.CHINA) || value.getLanguage().contains("zh")) {
|
||||
// Do self validation just once.
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Self validation. Result: " + doSelfValidation());
|
||||
}
|
||||
sInstance = new HanziToPinyin(true);
|
||||
return sInstance;
|
||||
}
|
||||
}
|
||||
if (sInstance == null) {//这个判断是用于处理国产ROM的兼容性问题
|
||||
if (Locale.CHINA.equals(Locale.getDefault())) {
|
||||
sInstance = new HanziToPinyin(true);
|
||||
return sInstance;
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "There is no Chinese collator, HanziToPinyin is disabled");
|
||||
sInstance = new HanziToPinyin(false);
|
||||
return sInstance;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if our internal table has some wrong value.
|
||||
*
|
||||
* @return true when the table looks correct.
|
||||
*/
|
||||
private static boolean doSelfValidation() {
|
||||
char lastChar = UNIHANS[0];
|
||||
String lastString = Character.toString(lastChar);
|
||||
for (char c : UNIHANS) {
|
||||
if (lastChar == c) {
|
||||
continue;
|
||||
}
|
||||
final String curString = Character.toString(c);
|
||||
int 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;
|
||||
}
|
||||
|
||||
private Token getToken(char character) {
|
||||
Token token = new Token();
|
||||
final String letter = Character.toString(character);
|
||||
token.source = letter;
|
||||
int offset = -1;
|
||||
int cmp;
|
||||
if (character < 256) {
|
||||
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.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
token.type = Token.PINYIN;
|
||||
if (offset < 0) {
|
||||
int begin = 0;
|
||||
int end = UNIHANS.length - 1;
|
||||
while (begin <= end) {
|
||||
offset = (begin + end) / 2;
|
||||
final String unihan = Character.toString(UNIHANS[offset]);
|
||||
cmp = COLLATOR.compare(letter, unihan);
|
||||
if (cmp == 0) {
|
||||
break;
|
||||
} else if (cmp > 0) {
|
||||
begin = offset + 1;
|
||||
} else {
|
||||
end = offset - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cmp < 0) {
|
||||
offset--;
|
||||
}
|
||||
StringBuilder pinyin = new StringBuilder();
|
||||
for (int j = 0; j < PINYINS[offset].length && PINYINS[offset][j] != 0; j++) {
|
||||
pinyin.append((char) PINYINS[offset][j]);
|
||||
}
|
||||
token.target = pinyin.toString();
|
||||
if (TextUtils.isEmpty(token.target)) {
|
||||
token.type = Token.UNKNOWN;
|
||||
token.target = token.source;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the input to a array of tokens. The sequence of ASCII or Unknown characters without
|
||||
* space will be put into a Token, One Hanzi character which has pinyin will be treated as a
|
||||
* Token. If these is no China collator, the empty token array is returned.
|
||||
*/
|
||||
public ArrayList<Token> get(final String input) {
|
||||
ArrayList<Token> tokens = new ArrayList<>();
|
||||
if (!mHasChinaCollator || TextUtils.isEmpty(input)) {
|
||||
// return empty tokens.
|
||||
return tokens;
|
||||
}
|
||||
final int inputLength = input.length();
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
int tokenType = Token.LATIN;
|
||||
// Go through the input, create a new token when
|
||||
// a. Token type changed
|
||||
// b. Get the Pinyin of current charater.
|
||||
// c. current character is space.
|
||||
for (int i = 0; i < inputLength; i++) {
|
||||
final char character = input.charAt(i);
|
||||
if (character == ' ') {
|
||||
if (sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
} else if (character < 256) {
|
||||
if (tokenType != Token.LATIN && sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
tokenType = Token.LATIN;
|
||||
sb.append(character);
|
||||
} else {
|
||||
Token t = getToken(character);
|
||||
if (t.type == Token.PINYIN) {
|
||||
if (sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
tokens.add(t);
|
||||
tokenType = Token.PINYIN;
|
||||
} else {
|
||||
if (tokenType != t.type && sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
tokenType = t.type;
|
||||
sb.append(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
addToken(sb, tokens, tokenType);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private void addToken(
|
||||
final StringBuilder sb, final ArrayList<Token> tokens, final int tokenType) {
|
||||
String str = sb.toString();
|
||||
tokens.add(new Token(tokenType, str, str));
|
||||
sb.setLength(0);
|
||||
}
|
||||
|
||||
public String toPinyinString(String string) {
|
||||
if (string == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
ArrayList<Token> tokens = get(string);
|
||||
for (Token token : tokens) {
|
||||
sb.append(token.target);
|
||||
}
|
||||
return sb.toString().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
616
manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt
Normal file
616
manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
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 + "libzakozako.so"
|
||||
}
|
||||
|
||||
object KsuCli {
|
||||
val 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")
|
||||
}
|
||||
}
|
||||
|
||||
fun install() {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libzakoboot.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
|
||||
}
|
||||
|
||||
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, "libzakoboot.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, "libzakoboot.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,
|
||||
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, "libzakoboot.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"
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
fun isAbDevice(): Boolean {
|
||||
val shell = getRootShell()
|
||||
return ShellUtils.fastCmd(shell, "getprop ro.build.ab_update").trim().toBoolean()
|
||||
}
|
||||
|
||||
fun isInitBoot(): Boolean {
|
||||
return !Os.uname().release.contains("android12-")
|
||||
}
|
||||
|
||||
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-kmi"
|
||||
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
|
||||
}
|
||||
|
||||
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 + "libzakozakozako.so"
|
||||
}
|
||||
|
||||
fun getSuSFS(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} support")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getSuSFSVersion(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} version")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getSuSFSVariant(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} variant")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getSuSFSFeatures(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} features")
|
||||
return result
|
||||
}
|
||||
|
||||
fun susfsSUS_SU_0(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} sus_su 0")
|
||||
return result
|
||||
}
|
||||
|
||||
fun susfsSUS_SU_2(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} sus_su 2")
|
||||
return result
|
||||
}
|
||||
|
||||
fun susfsSUS_SU_Mode(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} sus_su mode")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getKpmmgrPath(): String {
|
||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libkpmmgr.so"
|
||||
}
|
||||
|
||||
|
||||
fun loadKpmModule(path: String, args: String? = null): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKpmmgrPath()} load $path ${args ?: ""}"
|
||||
return ShellUtils.fastCmd(shell, cmd)
|
||||
}
|
||||
|
||||
fun unloadKpmModule(name: String): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKpmmgrPath()} unload $name"
|
||||
return ShellUtils.fastCmd(shell, cmd)
|
||||
}
|
||||
|
||||
fun getKpmModuleCount(): Int {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKpmmgrPath()} 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 = "${getKpmmgrPath()} 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 = "${getKpmmgrPath()} 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 = """${getKpmmgrPath()} control $name "${args ?: ""}""""
|
||||
val result = runCmd(shell, cmd)
|
||||
return result.trim().toIntOrNull() ?: -1
|
||||
}
|
||||
|
||||
fun getKpmVersion(): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKpmmgrPath()} version"
|
||||
val result = ShellUtils.fastCmd(shell, cmd)
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
fun getZygiskImplement(): String {
|
||||
val shell = getRootShell()
|
||||
val zygiskPath = "/data/adb/modules/zygisksu"
|
||||
val rezygiskPath = "/data/adb/modules/rezygisk"
|
||||
val result = if (ShellUtils.fastCmdResult(shell, "test -f $zygiskPath/module.prop && test ! -f $zygiskPath/disable")) {
|
||||
ShellUtils.fastCmd(shell, "grep '^name=' $zygiskPath/module.prop | cut -d'=' -f2")
|
||||
} else if (ShellUtils.fastCmdResult(shell, "test -f $rezygiskPath/module.prop && test ! -f $rezygiskPath/disable")) {
|
||||
ShellUtils.fastCmd(shell, "grep '^name=' $rezygiskPath/module.prop | cut -d'=' -f2")
|
||||
} else {
|
||||
"None"
|
||||
}
|
||||
Log.i(TAG, "Zygisk implement: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getUidScannerDaemonPath(): String {
|
||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libuid_scanner.so"
|
||||
}
|
||||
|
||||
fun ensureUidScannerExecutable(): Boolean {
|
||||
val shell = getRootShell()
|
||||
val uidScannerPath = getUidScannerDaemonPath()
|
||||
val targetPath = "/data/adb/uid_scanner"
|
||||
|
||||
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
|
||||
}
|
||||
private const val targetPath = "/data/adb/uid_scanner"
|
||||
|
||||
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)
|
||||
return result
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
111
manager/app/src/main/java/com/sukisu/ultra/ui/util/LogEvent.kt
Normal file
111
manager/app/src/main/java/com/sukisu/ultra/ui/util/LogEvent.kt
Normal 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
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
package com.sukisu.ultra.ui.util
|
||||
|
||||
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.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.sukisu.ultra.R
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
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: kotlinx.coroutines.CoroutineScope = rememberCoroutineScope()
|
||||
) = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
scope.launch {
|
||||
backupModules(context, snackBarHost, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberModuleRestoreLauncher(
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState,
|
||||
scope: kotlinx.coroutines.CoroutineScope = rememberCoroutineScope()
|
||||
): androidx.activity.result.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 == android.app.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: kotlinx.coroutines.CoroutineScope = rememberCoroutineScope()
|
||||
) = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
scope.launch {
|
||||
backupAllowlist(context, snackBarHost, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberAllowlistRestoreLauncher(
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState,
|
||||
scope: kotlinx.coroutines.CoroutineScope = rememberCoroutineScope()
|
||||
): androidx.activity.result.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 == android.app.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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package com.sukisu.ultra.ui.util
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
package com.sukisu.ultra.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.sukisu.ultra.Natives
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.sukisu.ultra.ui.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import com.sukisu.ultra.ui.MainActivity
|
||||
|
||||
/**
|
||||
* 重启应用程序
|
||||
**/
|
||||
|
||||
fun Context.restartApp(
|
||||
activityClass: Class<out Activity>,
|
||||
finishCurrent: Boolean = true,
|
||||
clearTask: Boolean = true,
|
||||
newTask: Boolean = true
|
||||
) {
|
||||
val intent = Intent(this, activityClass)
|
||||
if (clearTask) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
if (newTask) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
startActivity(intent)
|
||||
|
||||
if (finishCurrent && this is Activity) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新启动器图标
|
||||
*/
|
||||
fun toggleLauncherIcon(context: Context, useAlt: Boolean) {
|
||||
val pm = context.packageManager
|
||||
val main = ComponentName(context, MainActivity::class.java.name)
|
||||
val alt = ComponentName(context, "${MainActivity::class.java.name}Alias")
|
||||
if (useAlt) {
|
||||
pm.setComponentEnabledSetting(main, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
pm.setComponentEnabledSetting(alt, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
} else {
|
||||
pm.setComponentEnabledSetting(alt, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
pm.setComponentEnabledSetting(main, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
1447
manager/app/src/main/java/com/sukisu/ultra/ui/util/SuSFSManager.kt
Normal file
1447
manager/app/src/main/java/com/sukisu/ultra/ui/util/SuSFSManager.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,540 @@
|
|||
package com.sukisu.ultra.ui.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendLine("echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = ""
|
||||
)
|
||||
|
|
@ -0,0 +1,598 @@
|
|||
package com.sukisu.ultra.ui.viewmodel
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.dergoogler.mmrl.platform.Platform.Companion.context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonSyntaxException
|
||||
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.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "HomeViewModel"
|
||||
private const val PREFS_NAME = "home_cache"
|
||||
private const val KEY_SYSTEM_STATUS = "system_status"
|
||||
private const val KEY_SYSTEM_INFO = "system_info"
|
||||
private const val KEY_VERSION_INFO = "version_info"
|
||||
private const val KEY_LAST_UPDATE = "last_update_time"
|
||||
private const val KEY_ERROR_COUNT = "error_count"
|
||||
private const val MAX_ERROR_COUNT = 2
|
||||
}
|
||||
|
||||
// 系统状态
|
||||
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 susSUMode: 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 = ""
|
||||
)
|
||||
|
||||
private val gson = Gson()
|
||||
private val prefs by lazy { ksuApp.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
|
||||
|
||||
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
|
||||
|
||||
private fun clearAllCache() {
|
||||
try {
|
||||
prefs.edit { clear() }
|
||||
Log.i(TAG, "All cache cleared successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing cache", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetToDefaults() {
|
||||
systemStatus = SystemStatus()
|
||||
systemInfo = SystemInfo()
|
||||
latestVersionInfo = LatestVersionInfo()
|
||||
isSimpleMode = false
|
||||
isKernelSimpleMode = false
|
||||
isHideVersion = false
|
||||
isHideOtherInfo = false
|
||||
isHideSusfsStatus = false
|
||||
isHideZygiskImplement = false
|
||||
isHideLinkCard = false
|
||||
showKpmInfo = false
|
||||
}
|
||||
|
||||
private fun handleError(error: Exception, operation: String) {
|
||||
Log.e(TAG, "Error in $operation", error)
|
||||
|
||||
val errorCount = prefs.getInt(KEY_ERROR_COUNT, 0)
|
||||
val newErrorCount = errorCount + 1
|
||||
|
||||
if (newErrorCount >= MAX_ERROR_COUNT) {
|
||||
Log.w(TAG, "Too many errors ($newErrorCount), clearing cache and resetting")
|
||||
clearAllCache()
|
||||
resetToDefaults()
|
||||
} else {
|
||||
prefs.edit {
|
||||
putInt(KEY_ERROR_COUNT, newErrorCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.orSafe(default: String = ""): String {
|
||||
return if (this.isNullOrBlank()) default else this
|
||||
}
|
||||
|
||||
private fun <T, R> Pair<T?, R?>?.orSafe(default: Pair<T, R>): Pair<T, R> {
|
||||
return if (this?.first == null || this.second == null) default else Pair(this.first!!, this.second!!)
|
||||
}
|
||||
|
||||
fun loadUserSettings(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
handleError(e, "loadUserSettings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun initializeData() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
loadCachedData()
|
||||
// 成功加载后重置错误计数
|
||||
prefs.edit {
|
||||
putInt(KEY_ERROR_COUNT, 0)
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
handleError(e, "initializeData")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCachedData() {
|
||||
try {
|
||||
prefs.getString(KEY_SYSTEM_STATUS, null)?.let { statusJson ->
|
||||
try {
|
||||
val cachedStatus = gson.fromJson(statusJson, SystemStatus::class.java)
|
||||
if (cachedStatus != null) {
|
||||
systemStatus = cachedStatus
|
||||
}
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.w(TAG, "Invalid system status JSON, using defaults", e)
|
||||
}
|
||||
}
|
||||
|
||||
prefs.getString(KEY_SYSTEM_INFO, null)?.let { infoJson ->
|
||||
try {
|
||||
val cachedInfo = gson.fromJson(infoJson, SystemInfo::class.java)
|
||||
if (cachedInfo != null) {
|
||||
systemInfo = cachedInfo
|
||||
}
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.w(TAG, "Invalid system info JSON, using defaults", e)
|
||||
}
|
||||
}
|
||||
|
||||
prefs.getString(KEY_VERSION_INFO, null)?.let { versionJson ->
|
||||
try {
|
||||
val cachedVersion = gson.fromJson(versionJson, LatestVersionInfo::class.java)
|
||||
if (cachedVersion != null) {
|
||||
latestVersionInfo = cachedVersion
|
||||
}
|
||||
} catch (e: JsonSyntaxException) {
|
||||
Log.w(TAG, "Invalid version info JSON, using defaults", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading cached data", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchAndSaveData() {
|
||||
try {
|
||||
fetchSystemStatus()
|
||||
fetchSystemInfo()
|
||||
withContext(Dispatchers.IO) {
|
||||
prefs.edit {
|
||||
putString(KEY_SYSTEM_STATUS, gson.toJson(systemStatus))
|
||||
putString(KEY_SYSTEM_INFO, gson.toJson(systemInfo))
|
||||
putString(KEY_VERSION_INFO, gson.toJson(latestVersionInfo))
|
||||
putLong(KEY_LAST_UPDATE, System.currentTimeMillis())
|
||||
putInt(KEY_ERROR_COUNT, 0)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
handleError(e, "fetchAndSaveData")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForUpdates(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val settingsPrefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val checkUpdate = settingsPrefs.getBoolean("check_update", true)
|
||||
|
||||
if (checkUpdate) {
|
||||
val newVersionInfo = checkNewVersion()
|
||||
latestVersionInfo = newVersionInfo
|
||||
prefs.edit {
|
||||
putString(KEY_VERSION_INFO, gson.toJson(newVersionInfo))
|
||||
putLong(KEY_LAST_UPDATE, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
handleError(e, "checkForUpdates")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAllData(context: Context) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
fetchAndSaveData()
|
||||
checkForUpdates(context)
|
||||
} catch (e: Exception) {
|
||||
handleError(e, "refreshAllData")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchSystemStatus() {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val kernelVersion = getKernelVersion()
|
||||
val isManager = try {
|
||||
Natives.becomeManager(ksuApp.packageName.orSafe("com.sukisu.ultra"))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to become manager", e)
|
||||
false
|
||||
}
|
||||
|
||||
val ksuVersion = if (isManager) {
|
||||
try {
|
||||
Natives.version
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get KSU version", e)
|
||||
null
|
||||
}
|
||||
} else null
|
||||
|
||||
val fullVersion = try {
|
||||
Natives.getFullVersion().orSafe("Unknown")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get full version", e)
|
||||
"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 (e: Exception) {
|
||||
Log.w(TAG, "Failed to process full version", e)
|
||||
fullVersion
|
||||
}
|
||||
} else {
|
||||
fullVersion
|
||||
}
|
||||
|
||||
val lkmMode = ksuVersion?.let {
|
||||
try {
|
||||
if (it >= Natives.MINIMAL_SUPPORTED_KERNEL_LKM && kernelVersion.isGKI()) {
|
||||
Natives.isLkmMode
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get LKM mode", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val isRootAvailable = try {
|
||||
rootAvailable()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to check root availability", e)
|
||||
false
|
||||
}
|
||||
|
||||
val isKpmConfigured = try {
|
||||
Natives.isKPMEnabled()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to check KPM status", e)
|
||||
false
|
||||
}
|
||||
|
||||
val requireNewKernel = try {
|
||||
isManager && Natives.requireNewKernel()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to check kernel requirement", e)
|
||||
false
|
||||
}
|
||||
|
||||
systemStatus = SystemStatus(
|
||||
isManager = isManager,
|
||||
ksuVersion = ksuVersion,
|
||||
ksuFullVersion = ksuFullVersion,
|
||||
lkmMode = lkmMode,
|
||||
kernelVersion = kernelVersion,
|
||||
isRootAvailable = isRootAvailable,
|
||||
isKpmConfigured = isKpmConfigured,
|
||||
requireNewKernel = requireNewKernel
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching system status", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private suspend fun fetchSystemInfo() {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val uname = try {
|
||||
Os.uname()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get uname", e)
|
||||
null
|
||||
}
|
||||
|
||||
val kpmVersion = try {
|
||||
getKpmVersion().orSafe("Unknown")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get kpm version", e)
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
val suSFS = try {
|
||||
getSuSFS().orSafe("Unknown")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get SuSFS", e)
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
var suSFSVersion = ""
|
||||
var suSFSVariant = ""
|
||||
var suSFSFeatures = ""
|
||||
var susSUMode = ""
|
||||
|
||||
if (suSFS == "Supported") {
|
||||
suSFSVersion = try {
|
||||
getSuSFSVersion().orSafe("")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get SuSFS version", e)
|
||||
""
|
||||
}
|
||||
|
||||
if (suSFSVersion.isNotEmpty()) {
|
||||
suSFSVariant = try {
|
||||
getSuSFSVariant().orSafe("")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get SuSFS variant", e)
|
||||
""
|
||||
}
|
||||
|
||||
suSFSFeatures = try {
|
||||
getSuSFSFeatures().orSafe("")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get SuSFS features", e)
|
||||
""
|
||||
}
|
||||
|
||||
val isSUS_SU = suSFSFeatures == "CONFIG_KSU_SUSFS_SUS_SU"
|
||||
if (isSUS_SU) {
|
||||
susSUMode = try {
|
||||
susfsSUS_SU_Mode()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get SUS SU mode", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取动态管理器状态和管理器列表
|
||||
val dynamicSignConfig = try {
|
||||
Natives.getDynamicManager()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get dynamic manager config", e)
|
||||
null
|
||||
}
|
||||
|
||||
val isDynamicSignEnabled = try {
|
||||
dynamicSignConfig?.isValid() == true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to check dynamic manager validity", e)
|
||||
false
|
||||
}
|
||||
|
||||
val managersList = if (isDynamicSignEnabled) {
|
||||
try {
|
||||
Natives.getManagersList()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get managers list", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val deviceModel = try {
|
||||
getDeviceModel().orSafe("Unknown")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get device model", e)
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
val managerVersion = try {
|
||||
getManagerVersion(ksuApp.applicationContext).orSafe(Pair("Unknown", 0L))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get manager version", e)
|
||||
Pair("Unknown", 0L)
|
||||
}
|
||||
|
||||
val seLinuxStatus = try {
|
||||
getSELinuxStatus(context).orSafe("Unknown")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get SELinux status", e)
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
val superuserCount = try {
|
||||
getSuperuserCount()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get superuser count", e)
|
||||
0
|
||||
}
|
||||
|
||||
val moduleCount = try {
|
||||
getModuleCount()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get module count", e)
|
||||
0
|
||||
}
|
||||
|
||||
val kpmModuleCount = try {
|
||||
getKpmModuleCount()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get kpm module count", e)
|
||||
0
|
||||
}
|
||||
|
||||
val zygiskImplement = try {
|
||||
getZygiskImplement().orSafe("None")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get Zygisk implement", e)
|
||||
"None"
|
||||
}
|
||||
|
||||
systemInfo = SystemInfo(
|
||||
kernelRelease = uname?.release.orSafe("Unknown"),
|
||||
androidVersion = Build.VERSION.RELEASE.orSafe("Unknown"),
|
||||
deviceModel = deviceModel,
|
||||
managerVersion = managerVersion,
|
||||
seLinuxStatus = seLinuxStatus,
|
||||
kpmVersion = kpmVersion,
|
||||
suSFSStatus = suSFS,
|
||||
suSFSVersion = suSFSVersion,
|
||||
suSFSVariant = suSFSVariant,
|
||||
suSFSFeatures = suSFSFeatures,
|
||||
susSUMode = susSUMode,
|
||||
superuserCount = superuserCount,
|
||||
moduleCount = moduleCount,
|
||||
kpmModuleCount = kpmModuleCount,
|
||||
managersList = managersList,
|
||||
isDynamicSignEnabled = isDynamicSignEnabled,
|
||||
zygiskImplement = zygiskImplement
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching system info", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceInfo(): String {
|
||||
return try {
|
||||
var manufacturer = Build.MANUFACTURER.orSafe("Unknown")
|
||||
manufacturer = manufacturer[0].uppercaseChar().toString() + manufacturer.substring(1)
|
||||
|
||||
val brand = Build.BRAND.orSafe("")
|
||||
if (brand.isNotEmpty() && !brand.equals(Build.MANUFACTURER, ignoreCase = true)) {
|
||||
manufacturer += " " + brand[0].uppercaseChar() + brand.substring(1)
|
||||
}
|
||||
|
||||
val model = Build.MODEL.orSafe("")
|
||||
if (model.isNotEmpty()) {
|
||||
manufacturer += " $model "
|
||||
}
|
||||
|
||||
manufacturer
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get device info", e)
|
||||
"Unknown Device"
|
||||
}
|
||||
}
|
||||
|
||||
@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", // Xiaomi
|
||||
"ro.vendor.oplus.market.name", // Oppo, OnePlus, Realme
|
||||
"ro.vivo.market.name", // Vivo
|
||||
"ro.config.marketing_name" // Huawei
|
||||
)
|
||||
var result = getDeviceInfo()
|
||||
for (key in marketNameKeys) {
|
||||
try {
|
||||
val marketName = getMethod.invoke(null, key, "") as String
|
||||
if (marketName.isNotEmpty()) {
|
||||
result = marketName
|
||||
break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get market name for key: $key", e)
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error getting device model", e)
|
||||
getDeviceInfo()
|
||||
}
|
||||
}
|
||||
|
||||
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.orSafe("Unknown")
|
||||
Pair(versionName, versionCode)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error getting manager version", e)
|
||||
Pair("Unknown", 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,484 @@
|
|||
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.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)
|
||||
}.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.optInt("versionCode", 0),
|
||||
obj.optString("description"),
|
||||
obj.getBoolean("enabled"),
|
||||
obj.getBoolean("update"),
|
||||
obj.getBoolean("remove"),
|
||||
obj.optString("updateJson"),
|
||||
obj.optBoolean("web"),
|
||||
obj.optBoolean("action"),
|
||||
obj.getString("dir_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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小的工具函数
|
||||
*/
|
||||
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]
|
||||
}
|
||||
|
|
@ -0,0 +1,504 @@
|
|||
package com.sukisu.ultra.ui.viewmodel
|
||||
|
||||
import android.content.*
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
import android.os.SystemClock
|
||||
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.HanziToPinyin
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.parcelize.Parcelize
|
||||
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
|
||||
|
||||
// 应用分类
|
||||
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 {
|
||||
return 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 {
|
||||
return entries.find { it.persistKey == key } ?: NAME_ASC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/5/31.
|
||||
*/
|
||||
class SuperUserViewModel : ViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "SuperUserViewModel"
|
||||
var apps by mutableStateOf<List<AppInfo>>(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
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class AppInfo(
|
||||
val label: String,
|
||||
val packageInfo: PackageInfo,
|
||||
val profile: Natives.Profile?,
|
||||
) : Parcelable {
|
||||
val packageName: String
|
||||
get() = packageInfo.packageName
|
||||
val uid: Int
|
||||
get() = packageInfo.applicationInfo!!.uid
|
||||
|
||||
val allowSu: Boolean
|
||||
get() = profile != null && profile.allowSu
|
||||
val hasCustomProfile: Boolean
|
||||
get() {
|
||||
if (profile == null) {
|
||||
return false
|
||||
}
|
||||
return if (profile.allowSu) {
|
||||
!profile.rootUseDefault
|
||||
} else {
|
||||
!profile.nonRootUseDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: SharedPreferences = ksuApp.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var search by mutableStateOf("")
|
||||
|
||||
var showSystemApps by mutableStateOf(loadShowSystemApps())
|
||||
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
|
||||
var loadingMessage by mutableStateOf("")
|
||||
private set
|
||||
|
||||
/**
|
||||
* 从SharedPreferences加载显示系统应用设置
|
||||
*/
|
||||
private fun loadShowSystemApps(): Boolean {
|
||||
return prefs.getBoolean(KEY_SHOW_SYSTEM_APPS, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从SharedPreferences加载选择的应用分类
|
||||
*/
|
||||
private fun loadSelectedCategory(): AppCategory {
|
||||
val categoryKey = prefs.getString(KEY_SELECTED_CATEGORY, AppCategory.ALL.persistKey) ?: AppCategory.ALL.persistKey
|
||||
return AppCategory.fromPersistKey(categoryKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从SharedPreferences加载当前排序方式
|
||||
*/
|
||||
private fun loadCurrentSortType(): SortType {
|
||||
val sortKey = prefs.getString(KEY_CURRENT_SORT_TYPE, SortType.NAME_ASC.persistKey) ?: SortType.NAME_ASC.persistKey
|
||||
return SortType.fromPersistKey(sortKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新显示系统应用设置并保存到SharedPreferences
|
||||
*/
|
||||
fun updateShowSystemApps(newValue: Boolean) {
|
||||
showSystemApps = newValue
|
||||
saveShowSystemApps(newValue)
|
||||
notifyAppListChanged()
|
||||
}
|
||||
|
||||
private fun notifyAppListChanged() {
|
||||
val currentApps = apps
|
||||
apps = emptyList()
|
||||
apps = currentApps
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新选择的应用分类并保存到SharedPreferences
|
||||
*/
|
||||
fun updateSelectedCategory(newCategory: AppCategory) {
|
||||
selectedCategory = newCategory
|
||||
saveSelectedCategory(newCategory)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前排序方式并保存到SharedPreferences
|
||||
*/
|
||||
fun updateCurrentSortType(newSortType: SortType) {
|
||||
currentSortType = newSortType
|
||||
saveCurrentSortType(newSortType)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存显示系统应用设置到SharedPreferences
|
||||
*/
|
||||
private fun saveShowSystemApps(value: Boolean) {
|
||||
prefs.edit {
|
||||
putBoolean(KEY_SHOW_SYSTEM_APPS, value)
|
||||
}
|
||||
Log.d(TAG, "Saved show system apps: $value")
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存选择的应用分类到SharedPreferences
|
||||
*/
|
||||
private fun saveSelectedCategory(category: AppCategory) {
|
||||
prefs.edit {
|
||||
putString(KEY_SELECTED_CATEGORY, category.persistKey)
|
||||
}
|
||||
Log.d(TAG, "Saved selected category: ${category.persistKey}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前排序方式到SharedPreferences
|
||||
*/
|
||||
private fun saveCurrentSortType(sortType: SortType) {
|
||||
prefs.edit {
|
||||
putString(KEY_CURRENT_SORT_TYPE, sortType.persistKey)
|
||||
}
|
||||
Log.d(TAG, "Saved current sort type: ${sortType.persistKey}")
|
||||
}
|
||||
|
||||
private val sortedList by derivedStateOf {
|
||||
val comparator = compareBy<AppInfo> {
|
||||
when {
|
||||
it.allowSu -> 0
|
||||
it.hasCustomProfile -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.then(compareBy(Collator.getInstance(Locale.getDefault()), AppInfo::label))
|
||||
apps.sortedWith(comparator).also {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
val appList by derivedStateOf {
|
||||
val filtered = sortedList.filter {
|
||||
it.label.contains(search, true) || it.packageName.contains(
|
||||
search,
|
||||
true
|
||||
) || HanziToPinyin.getInstance()
|
||||
.toPinyinString(it.label).contains(search, true)
|
||||
}.filter {
|
||||
it.uid == 2000 || showSystemApps || it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0
|
||||
}
|
||||
|
||||
filtered
|
||||
}
|
||||
|
||||
// 切换批量操作模式
|
||||
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) {
|
||||
selectedApps.forEach { packageName ->
|
||||
val app = apps.find { it.packageName == packageName }
|
||||
app?.let {
|
||||
val profile = Natives.getAppProfile(packageName, it.uid)
|
||||
val updatedProfile = profile.copy(allowSu = allowSu)
|
||||
if (Natives.setAppProfile(updatedProfile)) {
|
||||
updateAppProfileLocally(packageName, updatedProfile)
|
||||
notifyConfigChange(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
clearSelection()
|
||||
showBatchActions = false
|
||||
refreshAppConfigurations()
|
||||
}
|
||||
|
||||
// 批量更新权限和umount模块设置
|
||||
suspend fun updateBatchPermissions(allowSu: Boolean, umountModules: Boolean? = null) {
|
||||
selectedApps.forEach { packageName ->
|
||||
val app = apps.find { it.packageName == packageName }
|
||||
app?.let {
|
||||
val profile = Natives.getAppProfile(packageName, it.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
|
||||
}
|
||||
}
|
||||
|
||||
val progress = (batchIndex + 1).toFloat() / batches.size
|
||||
loadingProgress = progress
|
||||
|
||||
batchResult
|
||||
}
|
||||
}.awaitAll().flatten()
|
||||
|
||||
appListMutex.withLock {
|
||||
apps = updatedApps
|
||||
}
|
||||
|
||||
loadingProgress = 1f
|
||||
|
||||
Log.i(TAG, "Refreshed configurations for ${updatedApps.size} apps")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 { connection ->
|
||||
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 result = connectKsuService {
|
||||
Log.w(TAG, "KsuService disconnected")
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
Log.e(TAG, "Failed to connect to KsuService")
|
||||
isRefreshing = false
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val pm = ksuApp.packageManager
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
|
||||
try {
|
||||
val service = KsuService.Stub.asInterface(result)
|
||||
val allPackages = service?.getPackages(0)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
stopKsuService()
|
||||
}
|
||||
loadingProgress = 0.3f
|
||||
|
||||
val packages = allPackages?.list ?: emptyList()
|
||||
|
||||
apps = packages.map { packageInfo ->
|
||||
val appInfo = packageInfo.applicationInfo!!
|
||||
val uid = appInfo.uid
|
||||
val profile = Natives.getAppProfile(packageInfo.packageName, uid)
|
||||
AppInfo(
|
||||
label = appInfo.loadLabel(pm).toString(),
|
||||
packageInfo = packageInfo,
|
||||
profile = profile,
|
||||
)
|
||||
}.filter { it.packageName != ksuApp.packageName }
|
||||
|
||||
loadingProgress = 1f
|
||||
|
||||
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching app list", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
stopKsuService()
|
||||
}
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
loadingProgress = 0f
|
||||
loadingMessage = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
try {
|
||||
stopKsuService()
|
||||
appProcessingThreadPool.close()
|
||||
configChangeListeners.clear()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cleaning up resources", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.sukisu.ultra.ui.webui
|
||||
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageInfo
|
||||
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.becomeManager(ksuApp.packageName)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fun Platform.Companion.getInstalledPackagesAll(catch: (Exception) -> Unit = {}): List<PackageInfo> =
|
||||
try {
|
||||
val packages = mutableListOf<PackageInfo>()
|
||||
val userInfos = userManager.getUsers()
|
||||
|
||||
for (userInfo in userInfos) {
|
||||
packages.addAll(packageManager.getInstalledPackages(0, userInfo.id))
|
||||
}
|
||||
|
||||
packages
|
||||
} catch (e: Exception) {
|
||||
catch(e)
|
||||
packageManager.getInstalledPackages(0, userManager.myUserId)
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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;
|
||||
|
||||
class MimeUtil {
|
||||
|
||||
public static String getMimeFromFileName(String fileName) {
|
||||
if (fileName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Copying the logic and mapping that Chromium follows.
|
||||
// First we check against the OS (this is a limited list by default)
|
||||
// but app developers can extend this.
|
||||
// We then check against a list of hardcoded mime types above if the
|
||||
// OS didn't provide a result.
|
||||
String mimeType = URLConnection.guessContentTypeFromName(fileName);
|
||||
|
||||
if (mimeType != null) {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
return guessHardcodedMime(fileName);
|
||||
}
|
||||
|
||||
// We should keep this map in sync with the lists under
|
||||
// //net/base/mime_util.cc in Chromium.
|
||||
// A bunch of the mime types don't really apply to Android land
|
||||
// like word docs so feel free to filter out where necessary.
|
||||
private static String guessHardcodedMime(String fileName) {
|
||||
int finalFullStop = fileName.lastIndexOf('.');
|
||||
if (finalFullStop == -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String extension = fileName.substring(finalFullStop + 1).toLowerCase();
|
||||
|
||||
return switch (extension) {
|
||||
case "webm" -> "video/webm";
|
||||
case "mpeg", "mpg" -> "video/mpeg";
|
||||
case "mp3" -> "audio/mpeg";
|
||||
case "wasm" -> "application/wasm";
|
||||
case "xhtml", "xht", "xhtm" -> "application/xhtml+xml";
|
||||
case "flac" -> "audio/flac";
|
||||
case "ogg", "oga", "opus" -> "audio/ogg";
|
||||
case "wav" -> "audio/wav";
|
||||
case "m4a" -> "audio/x-m4a";
|
||||
case "gif" -> "image/gif";
|
||||
case "jpeg", "jpg", "jfif", "pjpeg", "pjp" -> "image/jpeg";
|
||||
case "png" -> "image/png";
|
||||
case "apng" -> "image/apng";
|
||||
case "svg", "svgz" -> "image/svg+xml";
|
||||
case "webp" -> "image/webp";
|
||||
case "mht", "mhtml" -> "multipart/related";
|
||||
case "css" -> "text/css";
|
||||
case "html", "htm", "shtml", "shtm", "ehtml" -> "text/html";
|
||||
case "js", "mjs" -> "application/javascript";
|
||||
case "xml" -> "text/xml";
|
||||
case "mp4", "m4v" -> "video/mp4";
|
||||
case "ogv", "ogm" -> "video/ogg";
|
||||
case "ico" -> "image/x-icon";
|
||||
case "woff" -> "application/font-woff";
|
||||
case "gz", "tgz" -> "application/gzip";
|
||||
case "json" -> "application/json";
|
||||
case "pdf" -> "application/pdf";
|
||||
case "zip" -> "application/zip";
|
||||
case "bmp" -> "image/bmp";
|
||||
case "tiff", "tif" -> "image/tiff";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
package com.sukisu.ultra.ui.webui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import androidx.annotation.NonNull;
|
||||
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.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
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
|
||||
* <a href="https://developer.android.com/guide/topics/data/data-storage">Android Developers
|
||||
* Docs: Data and file storage overview</a>.
|
||||
* <p class="note">
|
||||
* To avoid leaking user or app data to the web, make sure to choose {@code directory}
|
||||
* carefully, and assume any file under this directory could be accessed by any web page subject
|
||||
* to same-origin rules.
|
||||
* <p>
|
||||
* A typical usage would be like:
|
||||
* <pre class="prettyprint">
|
||||
* File publicDir = new File(context.getFilesDir(), "public");
|
||||
* // Host "files/public/" in app's data directory under:
|
||||
* // http://appassets.androidplatform.net/public/...
|
||||
* WebViewAssetLoader assetLoader = new WebViewAssetLoader.Builder()
|
||||
* .addPathHandler("/public/", new InternalStoragePathHandler(context, publicDir))
|
||||
* .build();
|
||||
* </pre>
|
||||
*/
|
||||
public final class SuFilePathHandler implements WebViewAssetLoader.PathHandler {
|
||||
private static final String TAG = "SuFilePathHandler";
|
||||
|
||||
/**
|
||||
* Default value to be used as MIME type if guessing MIME type failed.
|
||||
*/
|
||||
public static final String DEFAULT_MIME_TYPE = "text/plain";
|
||||
|
||||
/**
|
||||
* Forbidden subdirectories of {@link Context#getDataDir} that cannot be exposed by this
|
||||
* handler. They are forbidden as they often contain sensitive information.
|
||||
* <p class="note">
|
||||
* Note: Any future addition to this list will be considered breaking changes to the API.
|
||||
*/
|
||||
private static final String[] FORBIDDEN_DATA_DIRS =
|
||||
new String[] {"/data/data", "/data/system"};
|
||||
|
||||
@NonNull
|
||||
private final File mDirectory;
|
||||
|
||||
private final Shell mShell;
|
||||
|
||||
/**
|
||||
* Creates PathHandler for app's internal storage.
|
||||
* The directory to be exposed must be inside either the application's internal data
|
||||
* directory {@link Context#getDataDir} or cache directory {@link Context#getCacheDir}.
|
||||
* External storage is not supported for security reasons, as other apps with
|
||||
* {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} may be able to modify the
|
||||
* files.
|
||||
* <p>
|
||||
* Exposing the entire data or cache directory is not permitted, to avoid accidentally
|
||||
* exposing sensitive application files to the web. Certain existing subdirectories of
|
||||
* {@link Context#getDataDir} are also not permitted as they are often sensitive.
|
||||
* These files are ({@code "app_webview/"}, {@code "databases/"}, {@code "lib/"},
|
||||
* {@code "shared_prefs/"} and {@code "code_cache/"}).
|
||||
* <p>
|
||||
* The application should typically use a dedicated subdirectory for the files it intends to
|
||||
* expose and keep them separate from other files.
|
||||
*
|
||||
* @param directory the absolute path of the exposed app internal storage directory from
|
||||
* which files can be loaded.
|
||||
* @throws IllegalArgumentException if the directory is not allowed.
|
||||
*/
|
||||
public SuFilePathHandler(@NonNull File directory, Shell rootShell) {
|
||||
try {
|
||||
mDirectory = new File(getCanonicalDirPath(directory));
|
||||
if (!isAllowedInternalStorageDir()) {
|
||||
throw new IllegalArgumentException("The given directory \"" + directory
|
||||
+ "\" doesn't exist under an allowed app internal storage directory");
|
||||
}
|
||||
mShell = rootShell;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Failed to resolve the canonical path for the given directory: "
|
||||
+ directory.getPath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAllowedInternalStorageDir() throws IOException {
|
||||
String dir = getCanonicalDirPath(mDirectory);
|
||||
|
||||
for (String forbiddenPath : FORBIDDEN_DATA_DIRS) {
|
||||
if (dir.startsWith(forbiddenPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the requested file from the exposed data directory.
|
||||
* <p>
|
||||
* 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
|
||||
* {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be
|
||||
* returned instead of {@code null}. This saves the time of falling back to network and
|
||||
* trying to resolve a path that doesn't exist. A {@link WebResourceResponse} with
|
||||
* {@code null} {@link InputStream} will be received as an HTTP response with status code
|
||||
* {@code 404} and no body.
|
||||
* <p class="note">
|
||||
* The MIME type for the file will be determined from the file's extension using
|
||||
* {@link java.net.URLConnection#guessContentTypeFromName}. Developers should ensure that
|
||||
* files are named using standard file extensions. If the file does not have a
|
||||
* recognised extension, {@code "text/plain"} will be used by default.
|
||||
*
|
||||
* @param path the suffix path to be handled.
|
||||
* @return {@link WebResourceResponse} for the requested file.
|
||||
*/
|
||||
@Override
|
||||
@WorkerThread
|
||||
@NonNull
|
||||
public WebResourceResponse handle(@NonNull String path) {
|
||||
try {
|
||||
File file = getCanonicalFileIfChild(mDirectory, path);
|
||||
if (file != null) {
|
||||
InputStream is = openFile(file, mShell);
|
||||
String mimeType = guessMimeType(path);
|
||||
return new WebResourceResponse(mimeType, null, is);
|
||||
} else {
|
||||
Log.e(TAG, String.format(
|
||||
"The requested file: %s is outside the mounted directory: %s", path,
|
||||
mDirectory));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Error opening the requested path: " + path, e);
|
||||
}
|
||||
return new WebResourceResponse(null, null, null);
|
||||
}
|
||||
|
||||
public static String getCanonicalDirPath(@NonNull File file) throws IOException {
|
||||
String canonicalPath = file.getCanonicalPath();
|
||||
if (!canonicalPath.endsWith("/")) canonicalPath += "/";
|
||||
return canonicalPath;
|
||||
}
|
||||
|
||||
public static File getCanonicalFileIfChild(@NonNull File parent, @NonNull String child)
|
||||
throws IOException {
|
||||
String parentCanonicalPath = getCanonicalDirPath(parent);
|
||||
String childCanonicalPath = new File(parent, child).getCanonicalPath();
|
||||
if (childCanonicalPath.startsWith(parentCanonicalPath)) {
|
||||
return new File(childCanonicalPath);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static InputStream handleSvgzStream(@NonNull String path,
|
||||
@NonNull InputStream stream) throws IOException {
|
||||
return path.endsWith(".svgz") ? new GZIPInputStream(stream) : stream;
|
||||
}
|
||||
|
||||
public static InputStream openFile(@NonNull File file, @NonNull Shell shell) throws IOException {
|
||||
SuFile suFile = new SuFile(file.getAbsolutePath());
|
||||
suFile.setShell(shell);
|
||||
InputStream fis = SuFileInputStream.open(suFile);
|
||||
return handleSvgzStream(file.getPath(), fis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use {@link MimeUtil#getMimeFromFileName} to guess MIME type or return the
|
||||
* {@link #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 {@link #DEFAULT_MIME_TYPE}.
|
||||
*/
|
||||
@NonNull
|
||||
public static String guessMimeType(@NonNull String filePath) {
|
||||
String mimeType = MimeUtil.getMimeFromFileName(filePath);
|
||||
return mimeType == null ? DEFAULT_MIME_TYPE : mimeType;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package com.sukisu.ultra.ui.webui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.ActivityManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
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 java.io.File
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
class WebUIActivity : ComponentActivity() {
|
||||
private val rootShell by lazy { createRootShell(true) }
|
||||
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)
|
||||
|
||||
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")
|
||||
val webViewAssetLoader = WebViewAssetLoader.Builder()
|
||||
.setDomain("mui.kernelsu.org")
|
||||
.addPathHandler(
|
||||
"/",
|
||||
SuFilePathHandler(webRoot, rootShell)
|
||||
)
|
||||
.build()
|
||||
|
||||
val webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
return webViewAssetLoader.shouldInterceptRequest(request.url)
|
||||
}
|
||||
}
|
||||
|
||||
val webView = WebView(this).apply {
|
||||
webView = this
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets ->
|
||||
val inset = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.updateLayoutParams<MarginLayoutParams> {
|
||||
leftMargin = inset.left
|
||||
rightMargin = inset.right
|
||||
topMargin = inset.top
|
||||
bottomMargin = inset.bottom
|
||||
}
|
||||
return@setOnApplyWindowInsetsListener insets
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
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.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
|
||||
)
|
||||
|
||||
WebUIScreen(
|
||||
webView = webView,
|
||||
options = options,
|
||||
interfaces = listOf(
|
||||
WebViewInterface.factory()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
package com.sukisu.ultra.ui.webui
|
||||
|
||||
import android.app.Activity
|
||||
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.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.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()
|
||||
}
|
||||
|
||||
// =================== 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())
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package io.sukisu.ultra;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import com.sukisu.ultra.ui.util.KsuCli;
|
||||
|
||||
public class UltraShellHelper {
|
||||
public static String runCmd(String cmds) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for(String str : KsuCli.INSTANCE.getGLOBAL_MNT_SHELL()
|
||||
.newJob()
|
||||
.add(cmds)
|
||||
.to(new ArrayList<>(), null)
|
||||
.exec()
|
||||
.getOut()) {
|
||||
sb.append(str).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static boolean isPathExists(String path) {
|
||||
String result = runCmd("test -f '" + path + "' && echo 'exists'");
|
||||
return result.contains("exists");
|
||||
}
|
||||
|
||||
public static void CopyFileTo(String path, String target) {
|
||||
runCmd("cp -f '" + path + "' '" + target + "' 2>&1");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package io.sukisu.ultra;
|
||||
|
||||
import static com.sukisu.ultra.ui.util.KsuCliKt.*;
|
||||
import android.annotation.SuppressLint;
|
||||
|
||||
public class UltraToolInstall {
|
||||
private static final String OUTSIDE_KPMMGR_PATH = "/data/adb/ksu/bin/kpmmgr";
|
||||
private static final String OUTSIDE_SUSFSD_PATH = "/data/adb/ksu/bin/susfsd";
|
||||
|
||||
@SuppressLint("SetWorldReadable")
|
||||
public static void tryToInstall() {
|
||||
String kpmmgrPath = getKpmmgrPath();
|
||||
if (UltraShellHelper.isPathExists(OUTSIDE_KPMMGR_PATH)) {
|
||||
UltraShellHelper.CopyFileTo(kpmmgrPath, OUTSIDE_KPMMGR_PATH);
|
||||
UltraShellHelper.runCmd("chmod a+rx " + OUTSIDE_KPMMGR_PATH);
|
||||
}
|
||||
String SuSFSDaemonPath = getSuSFSDaemonPath();
|
||||
if (UltraShellHelper.isPathExists(OUTSIDE_SUSFSD_PATH)) {
|
||||
UltraShellHelper.CopyFileTo(SuSFSDaemonPath, OUTSIDE_SUSFSD_PATH);
|
||||
UltraShellHelper.runCmd("chmod a+rx " + OUTSIDE_SUSFSD_PATH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
package zako.zako.zako.zakoui.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.ksuApp
|
||||
import com.sukisu.ultra.ui.MainActivity
|
||||
import com.sukisu.ultra.ui.screen.BottomBarDestination
|
||||
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
|
||||
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
|
||||
import zako.zako.zako.zakoui.activity.util.AppData
|
||||
import zako.zako.zako.zakoui.activity.util.AppData.DataRefreshManager
|
||||
import zako.zako.zako.zakoui.activity.util.AppData.getKpmVersionUse
|
||||
|
||||
@SuppressLint("ContextCastToActivity")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BottomBar(navController: NavHostController) {
|
||||
val navigator = navController.rememberDestinationsNavigator()
|
||||
val isFullFeatured = AppData.isFullFeatured(ksuApp.packageName)
|
||||
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 DataRefreshManager.superuserCount.collectAsState()
|
||||
val moduleCount by DataRefreshManager.moduleCount.collectAsState()
|
||||
val kpmModuleCount by 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package zako.zako.zako.zakoui.activity.util
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
object AnimatedBottomBar {
|
||||
@Composable
|
||||
fun AnimatedBottomBarWrapper(
|
||||
showBottomBar: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showBottomBar,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package zako.zako.zako.zakoui.activity.util
|
||||
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
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(packageName: String): Boolean {
|
||||
val isManager = Natives.becomeManager(packageName)
|
||||
return isManager && !Natives.requireNewKernel() && rootAvailable()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package zako.zako.zako.zakoui.activity.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import com.sukisu.ultra.ui.MainActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import zako.zako.zako.zakoui.activity.util.AppData.DataRefreshManager
|
||||
|
||||
object DataRefreshUtils {
|
||||
|
||||
fun startDataRefreshCoroutine(scope: LifecycleCoroutineScope) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
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 {
|
||||
DataRefreshManager.refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package zako.zako.zako.zakoui.activity.util
|
||||
|
||||
import android.content.Context
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package zako.zako.zako.zakoui.activity.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import java.util.*
|
||||
|
||||
object LocaleUtils {
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
fun applyLanguageSetting(context: Context) {
|
||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val languageCode = prefs.getString("app_language", "") ?: ""
|
||||
|
||||
if (languageCode.isNotEmpty()) {
|
||||
val locale = Locale.forLanguageTag(languageCode)
|
||||
Locale.setDefault(locale)
|
||||
|
||||
val resources = context.resources
|
||||
val config = Configuration(resources.configuration)
|
||||
config.setLocale(locale)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
context.createConfigurationContext(config)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(config, resources.displayMetrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyLocale(context: Context): Context {
|
||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val languageCode = prefs.getString("app_language", "") ?: ""
|
||||
|
||||
var newContext = context
|
||||
if (languageCode.isNotEmpty()) {
|
||||
val locale = Locale.forLanguageTag(languageCode)
|
||||
Locale.setDefault(locale)
|
||||
|
||||
val config = Configuration(context.resources.configuration)
|
||||
config.setLocale(locale)
|
||||
newContext = context.createConfigurationContext(config)
|
||||
}
|
||||
|
||||
return newContext
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package zako.zako.zako.zakoui.activity.util
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.os.Handler
|
||||
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(
|
||||
android.provider.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() {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
package zako.zako.zako.zakoui.flash
|
||||
|
||||
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.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")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
package zako.zako.zako.zakoui.screen
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
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 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.flash.FlashState
|
||||
import zako.zako.zako.zakoui.flash.HorizonKernelState
|
||||
import zako.zako.zako.zakoui.flash.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 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
|
||||
}
|
||||
|
||||
// 开始刷写
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
5
manager/app/src/main/jniLibs/.gitignore
vendored
Normal file
5
manager/app/src/main/jniLibs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
libzakozako.so
|
||||
libzakozakozako.so
|
||||
libkpmmgr.so
|
||||
libzako.so
|
||||
libandroidx.graphics.path.so
|
||||
BIN
manager/app/src/main/jniLibs/arm64-v8a/libzakoboot.so
Normal file
BIN
manager/app/src/main/jniLibs/arm64-v8a/libzakoboot.so
Normal file
Binary file not shown.
BIN
manager/app/src/main/jniLibs/arm64-v8a/libzakosign.so
Normal file
BIN
manager/app/src/main/jniLibs/arm64-v8a/libzakosign.so
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue