Introducing miuix
Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
This commit is contained in:
@@ -10,8 +10,6 @@ plugins {
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.lsplugin.apksign)
|
||||
id("kotlin-parcelize")
|
||||
|
||||
|
||||
}
|
||||
|
||||
val managerVersionCode: Int by rootProject.extra
|
||||
@@ -25,7 +23,6 @@ apksign {
|
||||
keyPasswordProperty = "KEY_PASSWORD"
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
|
||||
/**signingConfigs {
|
||||
@@ -117,13 +114,8 @@ dependencies {
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.compose.material)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.foundation)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
@@ -145,24 +137,12 @@ dependencies {
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
implementation(libs.me.zhanghai.android.appiconloader.coil)
|
||||
|
||||
implementation(libs.sheet.compose.dialogs.core)
|
||||
implementation(libs.sheet.compose.dialogs.list)
|
||||
implementation(libs.sheet.compose.dialogs.input)
|
||||
|
||||
implementation(libs.markdown)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
implementation(libs.lsposed.cxx)
|
||||
|
||||
implementation(libs.com.github.topjohnwu.libsu.core)
|
||||
|
||||
implementation(libs.mmrl.platform)
|
||||
compileOnly(libs.mmrl.hidden.api)
|
||||
implementation(libs.mmrl.webui)
|
||||
implementation(libs.mmrl.ui)
|
||||
|
||||
implementation(libs.accompanist.drawablepainter)
|
||||
|
||||
implementation(libs.miuix)
|
||||
implementation(libs.haze)
|
||||
implementation(libs.capsule)
|
||||
}
|
||||
48
manager/app/proguard-rules.pro
vendored
48
manager/app/proguard-rules.pro
vendored
@@ -1,48 +0,0 @@
|
||||
-verbose
|
||||
-optimizationpasses 5
|
||||
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn kotlinx.serialization.**
|
||||
|
||||
# Please add these rules to your existing keep rules in order to suppress warnings.
|
||||
# This is generated automatically by the Android Gradle plugin.
|
||||
-dontwarn com.google.auto.service.AutoService
|
||||
-dontwarn com.google.j2objc.annotations.RetainedWith
|
||||
-dontwarn javax.lang.model.SourceVersion
|
||||
-dontwarn javax.lang.model.element.AnnotationMirror
|
||||
-dontwarn javax.lang.model.element.AnnotationValue
|
||||
-dontwarn javax.lang.model.element.Element
|
||||
-dontwarn javax.lang.model.element.ElementKind
|
||||
-dontwarn javax.lang.model.element.ElementVisitor
|
||||
-dontwarn javax.lang.model.element.ExecutableElement
|
||||
-dontwarn javax.lang.model.element.Modifier
|
||||
-dontwarn javax.lang.model.element.Name
|
||||
-dontwarn javax.lang.model.element.PackageElement
|
||||
-dontwarn javax.lang.model.element.TypeElement
|
||||
-dontwarn javax.lang.model.element.TypeParameterElement
|
||||
-dontwarn javax.lang.model.element.VariableElement
|
||||
-dontwarn javax.lang.model.type.ArrayType
|
||||
-dontwarn javax.lang.model.type.DeclaredType
|
||||
-dontwarn javax.lang.model.type.ExecutableType
|
||||
-dontwarn javax.lang.model.type.TypeKind
|
||||
-dontwarn javax.lang.model.type.TypeMirror
|
||||
-dontwarn javax.lang.model.type.TypeVariable
|
||||
-dontwarn javax.lang.model.type.TypeVisitor
|
||||
-dontwarn javax.lang.model.util.AbstractAnnotationValueVisitor8
|
||||
-dontwarn javax.lang.model.util.AbstractTypeVisitor8
|
||||
-dontwarn javax.lang.model.util.ElementFilter
|
||||
-dontwarn javax.lang.model.util.Elements
|
||||
-dontwarn javax.lang.model.util.SimpleElementVisitor8
|
||||
-dontwarn javax.lang.model.util.SimpleTypeVisitor7
|
||||
-dontwarn javax.lang.model.util.SimpleTypeVisitor8
|
||||
-dontwarn javax.lang.model.util.Types
|
||||
-dontwarn javax.tools.Diagnostic$Kind
|
||||
|
||||
|
||||
# MMRL:webui reflection
|
||||
-keep class com.dergoogler.mmrl.webui.interfaces.** { *; }
|
||||
-keep class com.sukisu.ultra.ui.webui.WebViewInterface { *; }
|
||||
|
||||
-keep,allowobfuscation class * extends com.dergoogler.mmrl.platform.content.IService { *; }
|
||||
|
||||
-keep interface com.sukisu.zako.** { *; }
|
||||
@@ -3,98 +3,29 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
|
||||
<application
|
||||
android:name=".KernelSUApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KernelSU"
|
||||
tools:targetApi="34">
|
||||
<!-- 专门为小米手机桌面卸载添加了提示,提升用户体验 -->
|
||||
<meta-data
|
||||
android:name="app_description_title"
|
||||
android:resource="@string/miui_uninstall_title" />
|
||||
<meta-data
|
||||
android:name="app_description_content"
|
||||
android:resource="@string/miui_uninstall_content" />
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:launchMode="standard"
|
||||
android:documentLaunchMode="intoExisting"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:theme="@style/Theme.KernelSU">
|
||||
android:theme="@style/Theme.KernelSU"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
<data android:scheme="content" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- 切换图标 -->
|
||||
<activity-alias
|
||||
android:name=".ui.MainActivityAlias"
|
||||
android:targetActivity=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_launcher_alt"
|
||||
android:roundIcon="@mipmap/ic_launcher_alt_round">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="application/zip" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name=".ui.webui.WebUIActivity"
|
||||
@@ -103,13 +34,6 @@
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.KernelSU.WebUI" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.webui.WebUIXActivity"
|
||||
android:autoRemoveFromRecents="true"
|
||||
android:documentLaunchMode="intoExisting"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.KernelSU.WebUI" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
package com.sukisu.zako;
|
||||
|
||||
import android.content.pm.PackageInfo;
|
||||
import java.util.List;
|
||||
import rikka.parcelablelist.ParcelableListSlice;
|
||||
|
||||
interface IKsuInterface {
|
||||
int getPackageCount();
|
||||
List<PackageInfo> getPackages(int start, int maxCount);
|
||||
ParcelableListSlice<PackageInfo> getPackages(int flags);
|
||||
}
|
||||
@@ -2,18 +2,8 @@ package com.sukisu.ultra
|
||||
|
||||
import android.app.Application
|
||||
import android.system.Os
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import com.dergoogler.mmrl.platform.Platform
|
||||
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
||||
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
@@ -30,25 +20,6 @@ class KernelSUApplication : Application(), ViewModelStoreOwner {
|
||||
super.onCreate()
|
||||
ksuApp = this
|
||||
|
||||
// For faster response when first entering superuser or webui activity
|
||||
val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java]
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
superUserViewModel.fetchAppList()
|
||||
}
|
||||
|
||||
Platform.setHiddenApiExemptions()
|
||||
|
||||
val context = this
|
||||
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||
Coil.setImageLoader(
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(AppIconKeyer())
|
||||
add(AppIconFetcher.Factory(iconSize, false, context))
|
||||
}
|
||||
.build()
|
||||
)
|
||||
|
||||
val webroot = File(dataDir, "webroot")
|
||||
if (!webroot.exists()) {
|
||||
webroot.mkdir()
|
||||
@@ -62,11 +33,12 @@ class KernelSUApplication : Application(), ViewModelStoreOwner {
|
||||
.addInterceptor { block ->
|
||||
block.proceed(
|
||||
block.request().newBuilder()
|
||||
.header("User-Agent", "SukiSU/${BuildConfig.VERSION_CODE}")
|
||||
.header("User-Agent", "KernelSU/${BuildConfig.VERSION_CODE}")
|
||||
.header("Accept-Language", Locale.getDefault().toLanguageTag()).build()
|
||||
)
|
||||
}.build()
|
||||
}
|
||||
|
||||
override val viewModelStore: ViewModelStore
|
||||
get() = appViewModelStore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,23 @@ import android.system.Os
|
||||
*/
|
||||
|
||||
data class KernelVersion(val major: Int, val patchLevel: Int, val subLevel: Int) {
|
||||
override fun toString(): String = "$major.$patchLevel.$subLevel"
|
||||
fun isGKI(): Boolean = when {
|
||||
major > 5 -> true
|
||||
major == 5 && patchLevel >= 10 -> true
|
||||
else -> false
|
||||
override fun toString(): String {
|
||||
return "$major.$patchLevel.$subLevel"
|
||||
}
|
||||
|
||||
fun isGKI(): Boolean {
|
||||
|
||||
// kernel 6.x
|
||||
if (major > 5) {
|
||||
return true
|
||||
}
|
||||
|
||||
// kernel 5.10.x
|
||||
if (major == 5) {
|
||||
return patchLevel >= 10
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,51 +17,14 @@ object Natives {
|
||||
// 10977: change groups_count and groups to avoid overflow write
|
||||
// 11071: Fix the issue of failing to set a custom SELinux type.
|
||||
// 12143: breaking: new supercall impl
|
||||
const val MINIMAL_SUPPORTED_KERNEL = 12143
|
||||
const val MINIMAL_SUPPORTED_KERNEL = 22000
|
||||
|
||||
// 12040: Support disable sucompat mode
|
||||
const val KERNEL_SU_DOMAIN = "u:r:su:s0"
|
||||
|
||||
const val MINIMAL_SUPPORTED_KERNEL_FULL = "v3.1.8"
|
||||
|
||||
const val MINIMAL_SUPPORTED_KPM = 12800
|
||||
|
||||
const val MINIMAL_SUPPORTED_DYNAMIC_MANAGER = 13215
|
||||
|
||||
const val MINIMAL_SUPPORTED_UID_SCANNER = 13347
|
||||
|
||||
const val MINIMAL_NEW_IOCTL_KERNEL = 13490
|
||||
|
||||
const val ROOT_UID = 0
|
||||
const val ROOT_GID = 0
|
||||
|
||||
// 获取完整版本号
|
||||
external fun getFullVersion(): String
|
||||
|
||||
fun isVersionLessThan(v1Full: String, v2Full: String): Boolean {
|
||||
fun extractVersionParts(version: String): List<Int> {
|
||||
val match = Regex("""v\d+(\.\d+)*""").find(version)
|
||||
val simpleVersion = match?.value ?: version
|
||||
return simpleVersion.trimStart('v').split('.').map { it.toIntOrNull() ?: 0 }
|
||||
}
|
||||
|
||||
val v1Parts = extractVersionParts(v1Full)
|
||||
val v2Parts = extractVersionParts(v2Full)
|
||||
val maxLength = maxOf(v1Parts.size, v2Parts.size)
|
||||
for (i in 0 until maxLength) {
|
||||
val num1 = v1Parts.getOrElse(i) { 0 }
|
||||
val num2 = v2Parts.getOrElse(i) { 0 }
|
||||
if (num1 != num2) return num1 < num2
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun getSimpleVersionFull(): String = getFullVersion().let { version ->
|
||||
Regex("""v\d+(\.\d+)*""").find(version)?.value ?: version
|
||||
}
|
||||
|
||||
init {
|
||||
System.loadLibrary("zakosign")
|
||||
System.loadLibrary("kernelsu")
|
||||
}
|
||||
|
||||
@@ -119,72 +82,8 @@ object Natives {
|
||||
external fun setEnhancedSecurityEnabled(enabled: Boolean): Boolean
|
||||
|
||||
/**
|
||||
* Su Log can be enabled/disabled.
|
||||
* 0: disabled
|
||||
* 1: enabled
|
||||
* negative : error
|
||||
* Get the user name for the uid.
|
||||
*/
|
||||
external fun isSuLogEnabled(): Boolean
|
||||
external fun setSuLogEnabled(enabled: Boolean): Boolean
|
||||
|
||||
external fun isKPMEnabled(): Boolean
|
||||
external fun getHookType(): String
|
||||
|
||||
/**
|
||||
* Get SUSFS feature status from kernel
|
||||
* @return SusfsFeatureStatus object containing all feature states, or null if failed
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set dynamic managerature configuration
|
||||
* @param size APK signature size
|
||||
* @param hash APK signature hash (64 character hex string)
|
||||
* @return true if successful, false otherwise
|
||||
*/
|
||||
external fun setDynamicManager(size: Int, hash: String): Boolean
|
||||
|
||||
|
||||
/**
|
||||
* Get current dynamic managerature configuration
|
||||
* @return DynamicManagerConfig object containing current configuration, or null if not set
|
||||
*/
|
||||
external fun getDynamicManager(): DynamicManagerConfig?
|
||||
|
||||
/**
|
||||
* Clear dynamic managerature configuration
|
||||
* @return true if successful, false otherwise
|
||||
*/
|
||||
external fun clearDynamicManager(): Boolean
|
||||
|
||||
/**
|
||||
* Get active managers list when dynamic manager is enabled
|
||||
* @return ManagersList object containing active managers, or null if failed or not enabled
|
||||
*/
|
||||
external fun getManagersList(): ManagersList?
|
||||
|
||||
// 模块签名验证
|
||||
external fun verifyModuleSignature(modulePath: String): Boolean
|
||||
|
||||
/**
|
||||
* Check if UID scanner is currently enabled
|
||||
* @return true if UID scanner is enabled, false otherwise
|
||||
*/
|
||||
external fun isUidScannerEnabled(): Boolean
|
||||
|
||||
/**
|
||||
* Enable or disable UID scanner
|
||||
* @param enabled true to enable, false to disable
|
||||
* @return true if operation was successful, false otherwise
|
||||
*/
|
||||
external fun setUidScannerEnabled(enabled: Boolean): Boolean
|
||||
|
||||
/**
|
||||
* Clear UID scanner environment (force exit)
|
||||
* This will forcefully stop all UID scanner operations and clear the environment
|
||||
* @return true if operation was successful, false otherwise
|
||||
*/
|
||||
external fun clearUidScannerEnvironment(): Boolean
|
||||
|
||||
external fun getUserName(uid: Int): String?
|
||||
|
||||
private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$"
|
||||
@@ -208,41 +107,9 @@ object Natives {
|
||||
}
|
||||
|
||||
fun requireNewKernel(): Boolean {
|
||||
if (version != -1 && version < MINIMAL_SUPPORTED_KERNEL) return true
|
||||
return isVersionLessThan(getFullVersion(), MINIMAL_SUPPORTED_KERNEL_FULL)
|
||||
return version != -1 && version < MINIMAL_SUPPORTED_KERNEL
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -278,4 +145,4 @@ object Natives {
|
||||
|
||||
constructor() : this("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,71 @@
|
||||
package com.sukisu.ultra.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.IBinder
|
||||
import android.os.UserHandle
|
||||
import android.os.UserManager
|
||||
import android.util.Log
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
import com.sukisu.zako.IKsuInterface
|
||||
import rikka.parcelablelist.ParcelableListSlice
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/10/17.
|
||||
* @author weishu
|
||||
* @date 2023/4/18.
|
||||
*/
|
||||
|
||||
class KsuService : RootService() {
|
||||
|
||||
private val TAG = "KsuService"
|
||||
|
||||
private val cacheLock = Object()
|
||||
private var _all: List<PackageInfo>? = null
|
||||
private val allPackages: List<PackageInfo>
|
||||
get() = synchronized(cacheLock) {
|
||||
_all ?: loadAllPackages().also { _all = it }
|
||||
}
|
||||
|
||||
private fun loadAllPackages(): List<PackageInfo> {
|
||||
val tmp = arrayListOf<PackageInfo>()
|
||||
for (user in (getSystemService(USER_SERVICE) as UserManager).userProfiles) {
|
||||
val userId = user.getUserIdCompat()
|
||||
tmp += getInstalledPackagesAsUser(userId)
|
||||
}
|
||||
return tmp
|
||||
companion object {
|
||||
private const val TAG = "KsuService"
|
||||
}
|
||||
|
||||
internal inner class Stub : IKsuInterface.Stub() {
|
||||
override fun getPackageCount(): Int = allPackages.size
|
||||
|
||||
override fun getPackages(start: Int, maxCount: Int): List<PackageInfo> {
|
||||
val list = allPackages
|
||||
val end = (start + maxCount).coerceAtMost(list.size)
|
||||
return if (start >= list.size) emptyList()
|
||||
else list.subList(start, end)
|
||||
}
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return Stub()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder = Stub()
|
||||
private fun getUserIds(): List<Int> {
|
||||
val result = ArrayList<Int>()
|
||||
val um = getSystemService(USER_SERVICE) as UserManager
|
||||
val userProfiles = um.userProfiles
|
||||
for (userProfile: UserHandle in userProfiles) {
|
||||
result.add(userProfile.hashCode())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateApi")
|
||||
private fun getInstalledPackagesAsUser(userId: Int): List<PackageInfo> {
|
||||
private fun getInstalledPackagesAll(flags: Int): ArrayList<PackageInfo> {
|
||||
val packages = ArrayList<PackageInfo>()
|
||||
for (userId in getUserIds()) {
|
||||
Log.i(TAG, "getInstalledPackagesAll: $userId")
|
||||
packages.addAll(getInstalledPackagesAsUser(flags, userId))
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getInstalledPackagesAsUser(flags: Int, userId: Int): List<PackageInfo> {
|
||||
return try {
|
||||
val pm = packageManager
|
||||
val m = pm.javaClass.getDeclaredMethod(
|
||||
val pm: PackageManager = packageManager
|
||||
val method = pm.javaClass.getDeclaredMethod(
|
||||
"getInstalledPackagesAsUser",
|
||||
Int::class.java,
|
||||
Int::class.java
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
m.invoke(pm, 0, userId) as List<PackageInfo>
|
||||
method.invoke(pm, flags, userId) as List<PackageInfo>
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "getInstalledPackagesAsUser", e)
|
||||
emptyList()
|
||||
Log.e(TAG, "err", e)
|
||||
ArrayList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserHandle.getUserIdCompat(): Int {
|
||||
return try {
|
||||
javaClass.getDeclaredField("identifier").apply { isAccessible = true }.getInt(this)
|
||||
} catch (_: NoSuchFieldException) {
|
||||
javaClass.getDeclaredMethod("getIdentifier").invoke(this) as Int
|
||||
} catch (e: Throwable) {
|
||||
Log.e("KsuService", "getUserIdCompat", e)
|
||||
0
|
||||
private inner class Stub : IKsuInterface.Stub() {
|
||||
override fun getPackages(flags: Int): ParcelableListSlice<PackageInfo> {
|
||||
val list = getInstalledPackagesAll(flags)
|
||||
Log.i(TAG, "getPackages: ${list.size}")
|
||||
return ParcelableListSlice(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,307 +1,169 @@
|
||||
package com.sukisu.ultra.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.NavGraphs
|
||||
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination
|
||||
import com.ramcosta.composedestinations.spec.NavHostGraphSpec
|
||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ui.screen.BottomBarDestination
|
||||
import com.sukisu.ultra.ui.theme.KernelSUTheme
|
||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
||||
import com.sukisu.ultra.ui.util.install
|
||||
import com.sukisu.ultra.ui.viewmodel.HomeViewModel
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
import com.sukisu.ultra.ui.webui.initPlatform
|
||||
import com.sukisu.ultra.ui.component.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import kotlinx.coroutines.launch
|
||||
import com.sukisu.ultra.ui.activity.component.BottomBar
|
||||
import com.sukisu.ultra.ui.activity.util.*
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ui.component.BottomBar
|
||||
import com.sukisu.ultra.ui.screen.HomePager
|
||||
import com.sukisu.ultra.ui.screen.ModulePager
|
||||
import com.sukisu.ultra.ui.screen.SettingPager
|
||||
import com.sukisu.ultra.ui.screen.SuperUserPager
|
||||
import com.sukisu.ultra.ui.theme.KernelSUTheme
|
||||
import com.sukisu.ultra.ui.util.install
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var superUserViewModel: SuperUserViewModel
|
||||
private lateinit var homeViewModel: HomeViewModel
|
||||
internal val settingsStateFlow = MutableStateFlow(SettingsState())
|
||||
|
||||
data class SettingsState(
|
||||
val isHideOtherInfo: Boolean = false,
|
||||
val showKpmInfo: Boolean = false
|
||||
)
|
||||
|
||||
private var showConfirmationDialog = mutableStateOf(false)
|
||||
private var pendingZipFiles = mutableStateOf<List<ZipFileInfo>>(emptyList())
|
||||
|
||||
private lateinit var themeChangeObserver: ThemeChangeContentObserver
|
||||
private var isInitialized = false
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(newBase?.let { LocaleHelper.applyLanguage(it) })
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
try {
|
||||
// 应用自定义 DPI
|
||||
DisplayUtils.applyCustomDpi(this)
|
||||
|
||||
// Enable edge to edge
|
||||
enableEdgeToEdge()
|
||||
// Enable edge to edge
|
||||
enableEdgeToEdge()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
val isManager = Natives.isManager
|
||||
if (isManager && !Natives.requireNewKernel()) install()
|
||||
|
||||
val isManager = Natives.isManager
|
||||
if (isManager && !Natives.requireNewKernel()) {
|
||||
install()
|
||||
}
|
||||
setContent {
|
||||
KernelSUTheme {
|
||||
val navController = rememberNavController()
|
||||
|
||||
// 使用标记控制初始化流程
|
||||
if (!isInitialized) {
|
||||
initializeViewModels()
|
||||
initializeData()
|
||||
isInitialized = true
|
||||
}
|
||||
Scaffold {
|
||||
DestinationsNavHost(
|
||||
modifier = Modifier,
|
||||
navGraph = NavGraphs.root,
|
||||
navController = navController,
|
||||
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
|
||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition =
|
||||
{
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if launched with a ZIP file
|
||||
val zipUri: ArrayList<Uri>? = when (intent?.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
uri?.let { arrayListOf(it) }
|
||||
}
|
||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition =
|
||||
{
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { -it / 5 },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
}
|
||||
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition =
|
||||
{
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -it / 5 },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
|
||||
else -> when {
|
||||
intent?.data != null -> arrayListOf(intent.data!!)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
|
||||
intent.getParcelableArrayListExtra("uris", Uri::class.java)
|
||||
}
|
||||
else -> {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra("uris")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
KernelSUTheme {
|
||||
val navController = rememberNavController()
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
|
||||
|
||||
val bottomBarRoutes = remember {
|
||||
BottomBarDestination.entries.map { it.direction.route }.toSet()
|
||||
}
|
||||
|
||||
val navigator = navController.rememberDestinationsNavigator()
|
||||
|
||||
InstallConfirmationDialog(
|
||||
show = showConfirmationDialog.value,
|
||||
zipFiles = pendingZipFiles.value,
|
||||
onConfirm = { confirmedFiles ->
|
||||
showConfirmationDialog.value = false
|
||||
UltraActivityUtils.navigateToFlashScreen(this, confirmedFiles, navigator)
|
||||
},
|
||||
onDismiss = {
|
||||
showConfirmationDialog.value = false
|
||||
pendingZipFiles.value = emptyList()
|
||||
finish()
|
||||
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition =
|
||||
{
|
||||
slideOutHorizontally(
|
||||
targetOffsetX = { it },
|
||||
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(zipUri) {
|
||||
if (!zipUri.isNullOrEmpty()) {
|
||||
// 检测 ZIP 文件类型并显示确认对话框
|
||||
lifecycleScope.launch {
|
||||
UltraActivityUtils.detectZipTypeAndShowConfirmation(this@MainActivity, zipUri) { infos ->
|
||||
if (infos.isNotEmpty()) {
|
||||
pendingZipFiles.value = infos
|
||||
showConfirmationDialog.value = true
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showBottomBar = when (currentDestination?.route) {
|
||||
ExecuteModuleActionScreenDestination.route -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
initPlatform()
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarHost provides snackBarHostState
|
||||
) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
AnimatedBottomBar.AnimatedBottomBarWrapper(
|
||||
showBottomBar = showBottomBar,
|
||||
content = { BottomBar(navController) }
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0)
|
||||
) { innerPadding ->
|
||||
DestinationsNavHost(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
navGraph = NavGraphs.root as NavHostGraphSpec,
|
||||
navController = navController,
|
||||
defaultTransitions = object : NavHostAnimatedDestinationStyle() {
|
||||
override val enterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
|
||||
// If the target is a detail page (not a bottom navigation page), slide in from the right
|
||||
if (targetState.destination.route !in bottomBarRoutes) {
|
||||
slideInHorizontally(initialOffsetX = { it })
|
||||
} else {
|
||||
// Otherwise (switching between bottom navigation pages), use fade in
|
||||
fadeIn(animationSpec = tween(340))
|
||||
}
|
||||
}
|
||||
|
||||
override val exitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
|
||||
// If navigating from the home page (bottom navigation page) to a detail page, slide out to the left
|
||||
if (initialState.destination.route in bottomBarRoutes && targetState.destination.route !in bottomBarRoutes) {
|
||||
slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut()
|
||||
} else {
|
||||
// Otherwise (switching between bottom navigation pages), use fade out
|
||||
fadeOut(animationSpec = tween(340))
|
||||
}
|
||||
}
|
||||
|
||||
override val popEnterTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
|
||||
// If returning to the home page (bottom navigation page), slide in from the left
|
||||
if (targetState.destination.route in bottomBarRoutes) {
|
||||
slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn()
|
||||
} else {
|
||||
// Otherwise (e.g., returning between multiple detail pages), use default fade in
|
||||
fadeIn(animationSpec = tween(340))
|
||||
}
|
||||
}
|
||||
|
||||
override val popExitTransition: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
|
||||
// If returning from a detail page (not a bottom navigation page), scale down and fade out
|
||||
if (initialState.destination.route !in bottomBarRoutes) {
|
||||
scaleOut(targetScale = 0.9f) + fadeOut()
|
||||
} else {
|
||||
// Otherwise, use default fade out
|
||||
fadeOut(animationSpec = tween(340))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val LocalPagerState = compositionLocalOf<PagerState> { error("No pager state") }
|
||||
val LocalHandlePageChange = compositionLocalOf<(Int) -> Unit> { error("No handle page change") }
|
||||
|
||||
@Composable
|
||||
@Destination<RootGraph>(start = true)
|
||||
fun MainScreen(navController: DestinationsNavigator) {
|
||||
val activity = LocalActivity.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 4 })
|
||||
val hazeState = remember { HazeState() }
|
||||
val hazeStyle = HazeStyle(
|
||||
backgroundColor = MiuixTheme.colorScheme.background,
|
||||
tint = HazeTint(MiuixTheme.colorScheme.background.copy(0.8f))
|
||||
)
|
||||
val handlePageChange: (Int) -> Unit = remember(pagerState, coroutineScope) {
|
||||
{ page ->
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(page) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeViewModels() {
|
||||
superUserViewModel = SuperUserViewModel()
|
||||
homeViewModel = HomeViewModel()
|
||||
|
||||
// 设置主题变化监听器
|
||||
themeChangeObserver = ThemeUtils.registerThemeChangeObserver(this)
|
||||
}
|
||||
|
||||
private fun initializeData() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
superUserViewModel.fetchAppList()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
BackHandler {
|
||||
if (pagerState.currentPage != 0) {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 数据刷新协程
|
||||
DataRefreshUtils.startDataRefreshCoroutine(lifecycleScope)
|
||||
DataRefreshUtils.startSettingsMonitorCoroutine(lifecycleScope, this, settingsStateFlow)
|
||||
|
||||
// 初始化主题相关设置
|
||||
ThemeUtils.initializeThemeSettings(this, settingsStateFlow)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
try {
|
||||
super.onResume()
|
||||
ThemeUtils.onActivityResume()
|
||||
|
||||
// 仅在需要时刷新数据
|
||||
if (isInitialized) {
|
||||
refreshData()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} else {
|
||||
activity?.finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshData() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
superUserViewModel.fetchAppList()
|
||||
DataRefreshUtils.refreshData(lifecycleScope)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
CompositionLocalProvider(
|
||||
LocalPagerState provides pagerState,
|
||||
LocalHandlePageChange provides handlePageChange
|
||||
) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
BottomBar(hazeState, hazeStyle)
|
||||
},
|
||||
) { innerPadding ->
|
||||
HorizontalPager(
|
||||
modifier = Modifier.hazeSource(state = hazeState),
|
||||
state = pagerState,
|
||||
beyondViewportPageCount = 2,
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
when (it) {
|
||||
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
|
||||
1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
|
||||
2 -> ModulePager(navController, innerPadding.calculateBottomPadding())
|
||||
3 -> SettingPager(navController, innerPadding.calculateBottomPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
package com.sukisu.ultra.ui.activity.component
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.NavHostController
|
||||
import com.ramcosta.composedestinations.generated.NavGraphs
|
||||
import com.ramcosta.composedestinations.spec.RouteOrDirection
|
||||
import com.ramcosta.composedestinations.utils.isRouteOnBackStackAsState
|
||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ui.MainActivity
|
||||
import com.sukisu.ultra.ui.activity.util.*
|
||||
import com.sukisu.ultra.ui.activity.util.AppData.getKpmVersionUse
|
||||
import com.sukisu.ultra.ui.screen.BottomBarDestination
|
||||
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
|
||||
import com.sukisu.ultra.ui.theme.CardConfig.cardElevation
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
|
||||
@SuppressLint("ContextCastToActivity")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BottomBar(navController: NavHostController) {
|
||||
val navigator = navController.rememberDestinationsNavigator()
|
||||
val isFullFeatured = AppData.isFullFeatured()
|
||||
val kpmVersion = getKpmVersionUse()
|
||||
val cardColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
val activity = LocalContext.current as MainActivity
|
||||
val settings by activity.settingsStateFlow.collectAsState()
|
||||
|
||||
// 检查是否隐藏红点
|
||||
val isHideOtherInfo = settings.isHideOtherInfo
|
||||
val showKpmInfo = settings.showKpmInfo
|
||||
|
||||
// 收集计数数据
|
||||
val superuserCount by AppData.DataRefreshManager.superuserCount.collectAsState()
|
||||
val moduleCount by AppData.DataRefreshManager.moduleCount.collectAsState()
|
||||
val kpmModuleCount by AppData.DataRefreshManager.kpmModuleCount.collectAsState()
|
||||
|
||||
|
||||
NavigationBar(
|
||||
modifier = Modifier.windowInsetsPadding(
|
||||
WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)
|
||||
),
|
||||
containerColor = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
).containerColor,
|
||||
tonalElevation = cardElevation
|
||||
) {
|
||||
BottomBarDestination.entries.forEach { destination ->
|
||||
if (destination == BottomBarDestination.Kpm) {
|
||||
if (kpmVersion.isNotEmpty() && !kpmVersion.startsWith("Error") && !showKpmInfo && Natives.version >= Natives.MINIMAL_SUPPORTED_KPM) {
|
||||
if (!isFullFeatured && destination.rootRequired) return@forEach
|
||||
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
|
||||
NavigationBarItem(
|
||||
selected = isCurrentDestOnBackStack,
|
||||
onClick = {
|
||||
if (!isCurrentDestOnBackStack) {
|
||||
navigator.popBackStack(destination.direction, false)
|
||||
}
|
||||
navigator.navigate(destination.direction) {
|
||||
popUpTo(NavGraphs.root as RouteOrDirection) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (kpmModuleCount > 0 && !isHideOtherInfo) {
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
) {
|
||||
Text(
|
||||
text = kpmModuleCount.toString(),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
Icon(destination.iconSelected, stringResource(destination.label))
|
||||
} else {
|
||||
Icon(destination.iconNotSelected, stringResource(destination.label))
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) },
|
||||
alwaysShowLabel = false
|
||||
)
|
||||
}
|
||||
} else if (destination == BottomBarDestination.SuperUser) {
|
||||
if (!isFullFeatured && destination.rootRequired) return@forEach
|
||||
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = isCurrentDestOnBackStack,
|
||||
onClick = {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
navigator.popBackStack(destination.direction, false)
|
||||
}
|
||||
navigator.navigate(destination.direction) {
|
||||
popUpTo(NavGraphs.root) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (superuserCount > 0 && !isHideOtherInfo) {
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
) {
|
||||
Text(
|
||||
text = superuserCount.toString(),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
Icon(destination.iconSelected, stringResource(destination.label))
|
||||
} else {
|
||||
Icon(destination.iconNotSelected, stringResource(destination.label))
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) },
|
||||
alwaysShowLabel = false
|
||||
)
|
||||
} else if (destination == BottomBarDestination.Module) {
|
||||
if (!isFullFeatured && destination.rootRequired) return@forEach
|
||||
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = isCurrentDestOnBackStack,
|
||||
onClick = {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
navigator.popBackStack(destination.direction, false)
|
||||
}
|
||||
navigator.navigate(destination.direction) {
|
||||
popUpTo(NavGraphs.root) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (moduleCount > 0 && !isHideOtherInfo) {
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.secondary)
|
||||
{
|
||||
Text(
|
||||
text = moduleCount.toString(),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
Icon(destination.iconSelected, stringResource(destination.label))
|
||||
} else {
|
||||
Icon(destination.iconNotSelected, stringResource(destination.label))
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) },
|
||||
alwaysShowLabel = false
|
||||
)
|
||||
} else {
|
||||
if (!isFullFeatured && destination.rootRequired) return@forEach
|
||||
val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = isCurrentDestOnBackStack,
|
||||
onClick = {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
navigator.popBackStack(destination.direction, false)
|
||||
}
|
||||
navigator.navigate(destination.direction) {
|
||||
popUpTo(NavGraphs.root) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
if (isCurrentDestOnBackStack) {
|
||||
Icon(destination.iconSelected, stringResource(destination.label))
|
||||
} else {
|
||||
Icon(destination.iconNotSelected, stringResource(destination.label))
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(destination.label),style = MaterialTheme.typography.labelMedium) },
|
||||
alwaysShowLabel = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package com.sukisu.ultra.ui.activity.util
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.os.Handler
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.edit
|
||||
import com.sukisu.ultra.ui.MainActivity
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
import com.sukisu.ultra.ui.theme.ThemeConfig
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class ThemeChangeContentObserver(
|
||||
handler: Handler,
|
||||
private val onThemeChanged: () -> Unit
|
||||
) : ContentObserver(handler) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
super.onChange(selfChange)
|
||||
onThemeChanged()
|
||||
}
|
||||
}
|
||||
|
||||
object ThemeUtils {
|
||||
|
||||
fun initializeThemeSettings(activity: MainActivity, settingsStateFlow: MutableStateFlow<MainActivity.SettingsState>) {
|
||||
val prefs = activity.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val isFirstRun = prefs.getBoolean("is_first_run", true)
|
||||
|
||||
settingsStateFlow.value = MainActivity.SettingsState(
|
||||
isHideOtherInfo = prefs.getBoolean("is_hide_other_info", false),
|
||||
showKpmInfo = prefs.getBoolean("show_kpm_info", false)
|
||||
)
|
||||
|
||||
if (isFirstRun) {
|
||||
ThemeConfig.preventBackgroundRefresh = false
|
||||
activity.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
}
|
||||
prefs.edit { putBoolean("is_first_run", false) }
|
||||
}
|
||||
|
||||
// 加载保存的背景设置
|
||||
loadThemeMode()
|
||||
loadThemeColors()
|
||||
loadDynamicColorState()
|
||||
CardConfig.load(activity.applicationContext)
|
||||
}
|
||||
|
||||
fun registerThemeChangeObserver(activity: MainActivity): ThemeChangeContentObserver {
|
||||
val contentObserver = ThemeChangeContentObserver(Handler(activity.mainLooper)) {
|
||||
activity.runOnUiThread {
|
||||
if (!ThemeConfig.preventBackgroundRefresh) {
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
loadCustomBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity.contentResolver.registerContentObserver(
|
||||
Settings.System.getUriFor("ui_night_mode"),
|
||||
false,
|
||||
contentObserver
|
||||
)
|
||||
|
||||
return contentObserver
|
||||
}
|
||||
|
||||
fun unregisterThemeChangeObserver(activity: MainActivity, observer: ThemeChangeContentObserver) {
|
||||
activity.contentResolver.unregisterContentObserver(observer)
|
||||
}
|
||||
|
||||
fun onActivityPause(activity: MainActivity) {
|
||||
CardConfig.save(activity.applicationContext)
|
||||
activity.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
|
||||
putBoolean("prevent_background_refresh", true)
|
||||
}
|
||||
ThemeConfig.preventBackgroundRefresh = true
|
||||
}
|
||||
|
||||
fun onActivityResume() {
|
||||
if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) {
|
||||
loadCustomBackground()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadThemeMode() {
|
||||
}
|
||||
|
||||
private fun loadThemeColors() {
|
||||
}
|
||||
|
||||
private fun loadDynamicColorState() {
|
||||
}
|
||||
|
||||
private fun loadCustomBackground() {
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package com.sukisu.ultra.ui.activity.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ui.MainActivity
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.ui.component.ZipFileDetector
|
||||
import com.sukisu.ultra.ui.component.ZipFileInfo
|
||||
import com.sukisu.ultra.ui.component.ZipType
|
||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.InstallScreenDestination
|
||||
import com.sukisu.ultra.ui.screen.FlashIt
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.core.content.edit
|
||||
|
||||
object AnimatedBottomBar {
|
||||
@Composable
|
||||
fun AnimatedBottomBarWrapper(
|
||||
showBottomBar: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showBottomBar,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object UltraActivityUtils {
|
||||
|
||||
suspend fun detectZipTypeAndShowConfirmation(
|
||||
activity: MainActivity,
|
||||
zipUris: ArrayList<Uri>,
|
||||
onResult: (List<ZipFileInfo>) -> Unit
|
||||
) {
|
||||
val infos = ZipFileDetector.detectAndParseZipFiles(activity, zipUris)
|
||||
withContext(Dispatchers.Main) { onResult(infos) }
|
||||
}
|
||||
|
||||
fun navigateToFlashScreen(
|
||||
activity: MainActivity,
|
||||
zipFiles: List<ZipFileInfo>,
|
||||
navigator: DestinationsNavigator
|
||||
) {
|
||||
activity.lifecycleScope.launch {
|
||||
val moduleUris = zipFiles.filter { it.type == ZipType.MODULE }.map { it.uri }
|
||||
val kernelUris = zipFiles.filter { it.type == ZipType.KERNEL }.map { it.uri }
|
||||
|
||||
when {
|
||||
kernelUris.isNotEmpty() && moduleUris.isEmpty() -> {
|
||||
if (kernelUris.size == 1 && rootAvailable()) {
|
||||
navigator.navigate(
|
||||
InstallScreenDestination(
|
||||
preselectedKernelUri = kernelUris.first().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
setAutoExitAfterFlash(activity)
|
||||
}
|
||||
|
||||
moduleUris.isNotEmpty() -> {
|
||||
navigator.navigate(
|
||||
FlashScreenDestination(
|
||||
FlashIt.FlashModules(ArrayList(moduleUris))
|
||||
)
|
||||
)
|
||||
setAutoExitAfterFlash(activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAutoExitAfterFlash(activity: Context) {
|
||||
activity.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
.edit {
|
||||
putBoolean("auto_exit_after_flash", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object AppData {
|
||||
object DataRefreshManager {
|
||||
// 私有状态流
|
||||
private val _superuserCount = MutableStateFlow(0)
|
||||
private val _moduleCount = MutableStateFlow(0)
|
||||
private val _kpmModuleCount = MutableStateFlow(0)
|
||||
|
||||
// 公开的只读状态流
|
||||
val superuserCount: StateFlow<Int> = _superuserCount.asStateFlow()
|
||||
val moduleCount: StateFlow<Int> = _moduleCount.asStateFlow()
|
||||
val kpmModuleCount: StateFlow<Int> = _kpmModuleCount.asStateFlow()
|
||||
|
||||
/**
|
||||
* 刷新所有数据计数
|
||||
*/
|
||||
fun refreshData() {
|
||||
_superuserCount.value = getSuperuserCountUse()
|
||||
_moduleCount.value = getModuleCountUse()
|
||||
_kpmModuleCount.value = getKpmModuleCountUse()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取超级用户应用计数
|
||||
*/
|
||||
fun getSuperuserCountUse(): Int {
|
||||
return try {
|
||||
if (!rootAvailable()) return 0
|
||||
getSuperuserCount()
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块计数
|
||||
*/
|
||||
fun getModuleCountUse(): Int {
|
||||
return try {
|
||||
if (!rootAvailable()) return 0
|
||||
getModuleCount()
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取KPM模块计数
|
||||
*/
|
||||
fun getKpmModuleCountUse(): Int {
|
||||
return try {
|
||||
if (!rootAvailable()) return 0
|
||||
val kpmVersion = getKpmVersionUse()
|
||||
if (kpmVersion.isEmpty() || kpmVersion.startsWith("Error")) return 0
|
||||
getKpmModuleCount()
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取KPM版本
|
||||
*/
|
||||
fun getKpmVersionUse(): String {
|
||||
return try {
|
||||
if (!rootAvailable()) return ""
|
||||
val version = getKpmVersion()
|
||||
version.ifEmpty { "" }
|
||||
} catch (e: Exception) {
|
||||
"Error: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是完整功能模式
|
||||
*/
|
||||
fun isFullFeatured(): Boolean {
|
||||
val isManager = Natives.isManager
|
||||
return isManager && !Natives.requireNewKernel() && rootAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
object DataRefreshUtils {
|
||||
fun startDataRefreshCoroutine(scope: LifecycleCoroutineScope) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
AppData.DataRefreshManager.refreshData()
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startSettingsMonitorCoroutine(
|
||||
scope: LifecycleCoroutineScope,
|
||||
activity: MainActivity,
|
||||
settingsStateFlow: MutableStateFlow<MainActivity.SettingsState>
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
val prefs = activity.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
settingsStateFlow.value = MainActivity.SettingsState(
|
||||
isHideOtherInfo = prefs.getBoolean("is_hide_other_info", false),
|
||||
showKpmInfo = prefs.getBoolean("show_kpm_info", false)
|
||||
)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshData(scope: LifecycleCoroutineScope) {
|
||||
scope.launch {
|
||||
AppData.DataRefreshManager.refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object DisplayUtils {
|
||||
fun applyCustomDpi(context: Context) {
|
||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val customDpi = prefs.getInt("app_dpi", 0)
|
||||
|
||||
if (customDpi > 0) {
|
||||
try {
|
||||
val resources = context.resources
|
||||
val metrics = resources.displayMetrics
|
||||
metrics.density = customDpi / 160f
|
||||
@Suppress("DEPRECATION")
|
||||
metrics.scaledDensity = customDpi / 160f
|
||||
metrics.densityDpi = customDpi
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.sukisu.ultra.BuildConfig
|
||||
import com.sukisu.ultra.R
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AboutCard() {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
AboutCardContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AboutDialog(dismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = { dismiss() }
|
||||
) {
|
||||
AboutCard()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutCardContent() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
color = colorResource(id = R.color.ic_launcher_background),
|
||||
shape = CircleShape
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_monochrome),
|
||||
contentDescription = "icon",
|
||||
modifier = Modifier.scale(1.4f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
|
||||
Text(
|
||||
stringResource(id = R.string.app_name),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
BuildConfig.VERSION_NAME,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val annotatedString = AnnotatedString.fromHtml(
|
||||
htmlString = stringResource(
|
||||
id = R.string.about_source_code,
|
||||
"<b><a href=\"https://github.com/ShirkNeko/SukiSU-Ultra\">GitHub</a></b>",
|
||||
"<b><a href=\"https://t.me/SukiKSU\">Telegram</a></b>",
|
||||
"<b>怡子曰曰</b>",
|
||||
"<b>明风 OuO</b>",
|
||||
"<b><a href=\"https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.txt\">CC BY-NC-SA 4.0</a></b>"
|
||||
),
|
||||
linkStyles = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
pressedStyle = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
background = MaterialTheme.colorScheme.secondaryContainer,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = annotatedString,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.kyant.capsule.ContinuousRoundedRectangle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
|
||||
@Composable
|
||||
fun AppIconImage(
|
||||
packageInfo: PackageInfo,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var icon by remember(packageInfo.packageName) { mutableStateOf<ImageBitmap?>(null) }
|
||||
|
||||
LaunchedEffect(packageInfo.packageName) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val drawable = packageInfo.applicationInfo?.loadIcon(context.packageManager)
|
||||
val bitmap = drawable?.toBitmap()?.asImageBitmap()
|
||||
icon = bitmap
|
||||
}
|
||||
}
|
||||
|
||||
icon.let { imageBitmap ->
|
||||
imageBitmap?.let {
|
||||
Image(
|
||||
bitmap = it,
|
||||
contentDescription = label,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
} ?: Box(
|
||||
modifier = modifier
|
||||
.clip(ContinuousRoundedRectangle(12.dp))
|
||||
.background(colorScheme.secondaryContainer),
|
||||
contentAlignment = Alignment.Center
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Cottage
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.Security
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.LocalHandlePageChange
|
||||
import com.sukisu.ultra.ui.LocalPagerState
|
||||
import com.sukisu.ultra.ui.util.rootAvailable
|
||||
import top.yukonga.miuix.kmp.basic.NavigationBar
|
||||
import top.yukonga.miuix.kmp.basic.NavigationItem
|
||||
|
||||
|
||||
@Composable
|
||||
fun BottomBar(
|
||||
hazeState: HazeState,
|
||||
hazeStyle: HazeStyle
|
||||
) {
|
||||
val isManager = Natives.isManager
|
||||
val fullFeatured = isManager && !Natives.requireNewKernel() && rootAvailable()
|
||||
|
||||
val page = LocalPagerState.current.targetPage
|
||||
val handlePageChange = LocalHandlePageChange.current
|
||||
|
||||
if (!fullFeatured) return
|
||||
|
||||
val item = BottomBarDestination.entries.mapIndexed { index, destination ->
|
||||
NavigationItem(
|
||||
label = stringResource(destination.label),
|
||||
icon = destination.icon,
|
||||
)
|
||||
}
|
||||
|
||||
NavigationBar(
|
||||
modifier = Modifier
|
||||
.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
},
|
||||
color = Color.Transparent,
|
||||
items = item,
|
||||
selected = page,
|
||||
onClick = handlePageChange
|
||||
)
|
||||
}
|
||||
|
||||
enum class BottomBarDestination(
|
||||
@get:StringRes val label: Int,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Home(R.string.home, Icons.Rounded.Cottage),
|
||||
SuperUser(R.string.superuser, Icons.Rounded.Security),
|
||||
Module(R.string.module, Icons.Rounded.Extension),
|
||||
Setting(R.string.settings, Icons.Rounded.Settings)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.util.getSupportedKmis
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
|
||||
@Composable
|
||||
fun ChooseKmiDialog(
|
||||
showDialog: MutableState<Boolean>,
|
||||
onSelected: (String?) -> Unit
|
||||
) {
|
||||
val supportedKmi by produceState(initialValue = emptyList()) {
|
||||
value = getSupportedKmis()
|
||||
}
|
||||
val options = supportedKmi.map { it }
|
||||
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
insideMargin = DpSize(0.dp, 0.dp),
|
||||
onDismissRequest = {
|
||||
showDialog.value = false
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp, bottom = 12.dp),
|
||||
text = stringResource(R.string.select_kmi),
|
||||
fontSize = MiuixTheme.textStyles.title4.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
options.forEachIndexed { index, type ->
|
||||
SuperArrow(
|
||||
title = type,
|
||||
onClick = {
|
||||
onSelected(type)
|
||||
showDialog.value = false
|
||||
},
|
||||
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
text = stringResource(id = android.R.string.cancel),
|
||||
onClick = {
|
||||
showDialog.value = false
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 24.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -7,45 +7,66 @@ import android.text.Layout
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.utils.NoCopySpannableFactory
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.sukisu.ultra.R
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val TAG = "DialogComponent"
|
||||
|
||||
interface ConfirmDialogVisuals : Parcelable {
|
||||
val title: String
|
||||
val content: String
|
||||
val content: String?
|
||||
val isMarkdown: Boolean
|
||||
val confirm: String?
|
||||
val dismiss: String?
|
||||
@@ -54,7 +75,7 @@ interface ConfirmDialogVisuals : Parcelable {
|
||||
@Parcelize
|
||||
private data class ConfirmDialogVisualsImpl(
|
||||
override val title: String,
|
||||
override val content: String,
|
||||
override val content: String?,
|
||||
override val isMarkdown: Boolean,
|
||||
override val confirm: String?,
|
||||
override val dismiss: String?,
|
||||
@@ -86,16 +107,15 @@ interface ConfirmDialogHandle : DialogHandle {
|
||||
|
||||
fun showConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
content: String? = null,
|
||||
markdown: Boolean = false,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
)
|
||||
|
||||
suspend fun awaitConfirm(
|
||||
|
||||
title: String,
|
||||
content: String,
|
||||
content: String? = null,
|
||||
markdown: Boolean = false,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
@@ -159,7 +179,10 @@ interface ConfirmCallback {
|
||||
val isEmpty: Boolean get() = onConfirm == null && onDismiss == null
|
||||
|
||||
companion object {
|
||||
operator fun invoke(onConfirmProvider: () -> NullableCallback, onDismissProvider: () -> NullableCallback): ConfirmCallback {
|
||||
operator fun invoke(
|
||||
onConfirmProvider: () -> NullableCallback,
|
||||
onDismissProvider: () -> NullableCallback
|
||||
): ConfirmCallback {
|
||||
return object : ConfirmCallback {
|
||||
override val onConfirm: NullableCallback
|
||||
get() = onConfirmProvider()
|
||||
@@ -250,7 +273,7 @@ private class ConfirmDialogHandleImpl(
|
||||
|
||||
override fun showConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
content: String?,
|
||||
markdown: Boolean,
|
||||
confirm: String?,
|
||||
dismiss: String?
|
||||
@@ -263,7 +286,7 @@ private class ConfirmDialogHandleImpl(
|
||||
|
||||
override suspend fun awaitConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
content: String?,
|
||||
markdown: Boolean,
|
||||
confirm: String?,
|
||||
dismiss: String?
|
||||
@@ -299,23 +322,12 @@ private class ConfirmDialogHandleImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private class CustomDialogHandleImpl(
|
||||
visible: MutableState<Boolean>,
|
||||
coroutineScope: CoroutineScope
|
||||
) : DialogHandleBase(visible, coroutineScope) {
|
||||
override val dialogType: String get() = "CustomDialog"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberLoadingDialog(): LoadingDialogHandle {
|
||||
val visible = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val visible = remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (visible.value) {
|
||||
LoadingDialog()
|
||||
}
|
||||
LoadingDialog(visible)
|
||||
|
||||
return remember {
|
||||
LoadingDialogHandleImpl(visible, coroutineScope)
|
||||
@@ -343,7 +355,8 @@ private fun rememberConfirmDialog(visuals: ConfirmDialogVisuals, callback: Confi
|
||||
ConfirmDialog(
|
||||
handle.visuals,
|
||||
confirm = { coroutineScope.launch { resultChannel.send(ConfirmResult.Confirmed) } },
|
||||
dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } }
|
||||
dismiss = { coroutineScope.launch { resultChannel.send(ConfirmResult.Canceled) } },
|
||||
showDialog = visible
|
||||
)
|
||||
}
|
||||
|
||||
@@ -370,99 +383,130 @@ fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCustomDialog(composable: @Composable (dismiss: () -> Unit) -> Unit): DialogHandle {
|
||||
val visible = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
if (visible.value) {
|
||||
composable { visible.value = false }
|
||||
}
|
||||
return remember {
|
||||
CustomDialogHandleImpl(visible, coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingDialog() {
|
||||
Dialog(
|
||||
private fun LoadingDialog(showDialog: MutableState<Boolean>) {
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
onDismissRequest = {},
|
||||
properties = DialogProperties(dismissOnClickOutside = false, dismissOnBackPress = false)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
content = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
InfiniteProgressIndicator(
|
||||
color = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
text = stringResource(R.string.processing),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmDialog(visuals: ConfirmDialogVisuals, confirm: () -> Unit, dismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
private fun ConfirmDialog(
|
||||
visuals: ConfirmDialogVisuals,
|
||||
confirm: () -> Unit,
|
||||
dismiss: () -> Unit,
|
||||
showDialog: MutableState<Boolean>
|
||||
) {
|
||||
SuperDialog(
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
|
||||
show = showDialog,
|
||||
title = visuals.title,
|
||||
onDismissRequest = {
|
||||
dismiss()
|
||||
showDialog.value = false
|
||||
},
|
||||
title = {
|
||||
Text(text = visuals.title)
|
||||
},
|
||||
text = {
|
||||
if (visuals.isMarkdown) {
|
||||
MarkdownContent(content = visuals.content)
|
||||
} else {
|
||||
Text(text = visuals.content)
|
||||
content = {
|
||||
Layout(
|
||||
content = {
|
||||
visuals.content?.let {
|
||||
if (visuals.isMarkdown) {
|
||||
MarkdownContent(content = visuals.content!!)
|
||||
} else {
|
||||
Text(text = visuals.content!!)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
) {
|
||||
TextButton(
|
||||
text = visuals.dismiss ?: stringResource(id = android.R.string.cancel),
|
||||
onClick = {
|
||||
dismiss()
|
||||
showDialog.value = false
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
TextButton(
|
||||
text = visuals.confirm ?: stringResource(id = android.R.string.ok),
|
||||
onClick = {
|
||||
confirm()
|
||||
showDialog.value = false
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
if (measurables.size != 2) {
|
||||
val button = measurables[0].measure(constraints)
|
||||
layout(constraints.maxWidth, button.height) {
|
||||
button.place(0, 0)
|
||||
}
|
||||
} else {
|
||||
val button = measurables[1].measure(constraints)
|
||||
val lazyList = measurables[0].measure(constraints.copy(maxHeight = constraints.maxHeight - button.height))
|
||||
layout(constraints.maxWidth, lazyList.height + button.height) {
|
||||
lazyList.place(0, 0)
|
||||
button.place(0, lazyList.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = confirm) {
|
||||
Text(text = visuals.confirm ?: stringResource(id = android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = dismiss) {
|
||||
Text(text = visuals.dismiss ?: stringResource(id = android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkdownContent(content: String) {
|
||||
val contentColor = LocalContentColor.current
|
||||
val scrollState = rememberScrollState()
|
||||
val contentColor = MiuixTheme.colorScheme.onBackground.toArgb()
|
||||
|
||||
Column(
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val scrollView = ScrollView(context)
|
||||
val textView = TextView(context).apply {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
|
||||
}
|
||||
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
scrollView.addView(textView)
|
||||
scrollView
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(
|
||||
state = scrollState,
|
||||
flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
|
||||
}
|
||||
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
},
|
||||
update = {
|
||||
Markwon.create(it.context).setMarkdown(it, content)
|
||||
it.setTextColor(contentColor.toArgb())
|
||||
}
|
||||
)
|
||||
}
|
||||
.wrapContentHeight()
|
||||
.clipToBounds(),
|
||||
update = {
|
||||
val textView = it.getChildAt(0) as TextView
|
||||
Markwon.create(textView.context).setMarkdown(textView, content)
|
||||
textView.setTextColor(contentColor)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
@Composable
|
||||
fun DropdownItem(
|
||||
text: String,
|
||||
optionSize: Int,
|
||||
index: Int,
|
||||
dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
|
||||
onSelectedIndexChange: (Int) -> Unit
|
||||
) {
|
||||
val currentOnSelectedIndexChange = rememberUpdatedState(onSelectedIndexChange)
|
||||
val additionalTopPadding = if (index == 0) 20f.dp else 12f.dp
|
||||
val additionalBottomPadding = if (index == optionSize - 1) 20f.dp else 12f.dp
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { currentOnSelectedIndexChange.value(index) }
|
||||
.background(dropdownColors.containerColor)
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(
|
||||
top = additionalTopPadding,
|
||||
bottom = additionalBottomPadding
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = MiuixTheme.textStyles.body1.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = dropdownColors.contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.FocusInteraction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun EditText(
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
textValue: MutableState<String>,
|
||||
onTextValueChange: (String) -> Unit = {},
|
||||
textHint: String = "",
|
||||
enabled: Boolean = true,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
titleColor: BasicComponentColors = EditTextDefaults.titleColor(),
|
||||
summaryColor: BasicComponentColors = EditTextDefaults.summaryColor(),
|
||||
rightActionColor: BasicComponentColors = EditTextDefaults.rightActionColors(),
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focused = interactionSource.collectIsFocusedAsState().value
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
if (focused) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = null
|
||||
) {
|
||||
if (enabled) {
|
||||
coroutineScope.launch {
|
||||
interactionSource.emit(FocusInteraction.Focus())
|
||||
}
|
||||
}
|
||||
}
|
||||
.heightIn(min = 56.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(EditTextDefaults.InsideMargin),
|
||||
) {
|
||||
Layout(
|
||||
content = {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = MiuixTheme.textStyles.headline1.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = titleColor.color(enabled)
|
||||
)
|
||||
summary?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = summaryColor.color(enabled)
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = textValue.value,
|
||||
onValueChange = {
|
||||
onTextValueChange(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.semantics {
|
||||
onClick {
|
||||
focusRequester.requestFocus()
|
||||
true
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
textStyle = MiuixTheme.textStyles.main.copy(
|
||||
textAlign = TextAlign.End,
|
||||
color = if (isError) {
|
||||
Color.Red.copy(alpha = if (isSystemInDarkTheme()) 0.3f else 0.6f)
|
||||
} else {
|
||||
rightActionColor.color(enabled)
|
||||
}
|
||||
),
|
||||
keyboardOptions = keyboardOptions,
|
||||
cursorBrush = SolidColor(colorScheme.primary),
|
||||
interactionSource = interactionSource,
|
||||
decorationBox =
|
||||
@Composable { innerTextField ->
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Text(
|
||||
text = if (textValue.value.isEmpty()) textHint else "",
|
||||
color = rightActionColor.color(enabled),
|
||||
textAlign = TextAlign.End,
|
||||
softWrap = false,
|
||||
maxLines = 1
|
||||
)
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
val leftConstraints = constraints.copy(maxWidth = constraints.maxWidth / 2)
|
||||
val hasSummary = measurables.size > 2
|
||||
val titleText = measurables[0].measure(leftConstraints)
|
||||
val summaryText = (if (hasSummary) measurables[1] else null)?.measure(leftConstraints)
|
||||
val leftWidth = max(titleText.width, (summaryText?.width ?: 0))
|
||||
val leftHeight = titleText.height + (summaryText?.height ?: 0)
|
||||
val rightWidth = constraints.maxWidth - leftWidth - 16.dp.roundToPx()
|
||||
val rightConstraints = constraints.copy(maxWidth = rightWidth)
|
||||
val inputField = (if (hasSummary) measurables[2] else measurables[1]).measure(rightConstraints)
|
||||
val totalHeight = max(leftHeight, inputField.height)
|
||||
layout(constraints.maxWidth, totalHeight) {
|
||||
val titleY = (totalHeight - leftHeight) / 2
|
||||
titleText.placeRelative(0, titleY)
|
||||
summaryText?.placeRelative(0, titleY + titleText.height)
|
||||
inputField.placeRelative(constraints.maxWidth - inputField.width, (totalHeight - inputField.height) / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EditTextDefaults {
|
||||
val InsideMargin = PaddingValues(16.dp)
|
||||
|
||||
@Composable
|
||||
fun titleColor(
|
||||
color: Color = colorScheme.onSurface,
|
||||
disabledColor: Color = colorScheme.disabledOnSecondaryVariant
|
||||
): BasicComponentColors {
|
||||
return BasicComponentColors(
|
||||
color = color,
|
||||
disabledColor = disabledColor
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun summaryColor(
|
||||
color: Color = colorScheme.onSurfaceVariantSummary,
|
||||
disabledColor: Color = colorScheme.disabledOnSecondaryVariant
|
||||
): BasicComponentColors {
|
||||
return BasicComponentColors(
|
||||
color = color,
|
||||
disabledColor = disabledColor
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rightActionColors(
|
||||
color: Color = colorScheme.onSurfaceVariantActions,
|
||||
disabledColor: Color = colorScheme.disabledOnSecondaryVariant,
|
||||
): BasicComponentColors {
|
||||
return BasicComponentColors(
|
||||
color = color,
|
||||
disabledColor = disabledColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class BasicComponentColors(
|
||||
private val color: Color,
|
||||
private val disabledColor: Color
|
||||
) {
|
||||
@Stable
|
||||
fun color(enabled: Boolean): Color = if (enabled) color else disabledColor
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@SuppressLint("AutoboxingStateCreation")
|
||||
@Composable
|
||||
fun rememberFabVisibilityState(listState: LazyListState): State<Boolean> {
|
||||
var previousScrollOffset by remember { mutableStateOf(0) }
|
||||
var previousIndex by remember { mutableStateOf(0) }
|
||||
val fabVisible = remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
if (previousIndex == 0 && previousScrollOffset == 0) {
|
||||
fabVisible.value = true
|
||||
} else {
|
||||
val isScrollingDown = when {
|
||||
index > previousIndex -> false
|
||||
index < previousIndex -> true
|
||||
else -> offset < previousScrollOffset
|
||||
}
|
||||
|
||||
fabVisible.value = isScrollingDown
|
||||
}
|
||||
|
||||
previousIndex = index
|
||||
previousScrollOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
return fabVisible
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedFab(
|
||||
visible: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.8f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.scale(scale)
|
||||
.alpha(scale)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.GetApp
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
enum class ZipType {
|
||||
MODULE,
|
||||
KERNEL,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
data class ZipFileInfo(
|
||||
val uri: Uri,
|
||||
val type: ZipType,
|
||||
val name: String = "",
|
||||
val version: String = "",
|
||||
val versionCode: String = "",
|
||||
val author: String = "",
|
||||
val description: String = "",
|
||||
val kernelVersion: String = "",
|
||||
val supported: String = ""
|
||||
)
|
||||
|
||||
object ZipFileDetector {
|
||||
|
||||
fun detectZipType(context: Context, uri: Uri): ZipType {
|
||||
return try {
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
ZipInputStream(inputStream).use { zipStream ->
|
||||
var hasModuleProp = false
|
||||
var hasToolsFolder = false
|
||||
var hasAnykernelSh = false
|
||||
|
||||
var entry = zipStream.nextEntry
|
||||
while (entry != null) {
|
||||
val entryName = entry.name.lowercase()
|
||||
|
||||
when {
|
||||
entryName == "module.prop" || entryName.endsWith("/module.prop") -> {
|
||||
hasModuleProp = true
|
||||
}
|
||||
entryName.startsWith("tools/") || entryName == "tools" -> {
|
||||
hasToolsFolder = true
|
||||
}
|
||||
entryName == "anykernel.sh" || entryName.endsWith("/anykernel.sh") -> {
|
||||
hasAnykernelSh = true
|
||||
}
|
||||
}
|
||||
|
||||
zipStream.closeEntry()
|
||||
entry = zipStream.nextEntry
|
||||
}
|
||||
|
||||
when {
|
||||
hasModuleProp -> ZipType.MODULE
|
||||
hasToolsFolder && hasAnykernelSh -> ZipType.KERNEL
|
||||
else -> ZipType.UNKNOWN
|
||||
}
|
||||
}
|
||||
} ?: ZipType.UNKNOWN
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
ZipType.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
fun parseModuleInfo(context: Context, uri: Uri): ZipFileInfo {
|
||||
var zipInfo = ZipFileInfo(uri = uri, type = ZipType.MODULE)
|
||||
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
ZipInputStream(inputStream).use { zipStream ->
|
||||
var entry = zipStream.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name.lowercase() == "module.prop" || entry.name.endsWith("/module.prop")) {
|
||||
val reader = BufferedReader(InputStreamReader(zipStream))
|
||||
val props = mutableMapOf<String, String>()
|
||||
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (line.contains("=") && !line.startsWith("#")) {
|
||||
val parts = line.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
props[parts[0].trim()] = parts[1].trim()
|
||||
}
|
||||
}
|
||||
line = reader.readLine()
|
||||
}
|
||||
|
||||
zipInfo = zipInfo.copy(
|
||||
name = props["name"] ?: context.getString(R.string.unknown_module),
|
||||
version = props["version"] ?: "",
|
||||
versionCode = props["versionCode"] ?: "",
|
||||
author = props["author"] ?: "",
|
||||
description = props["description"] ?: ""
|
||||
)
|
||||
break
|
||||
}
|
||||
zipStream.closeEntry()
|
||||
entry = zipStream.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return zipInfo
|
||||
}
|
||||
|
||||
fun parseKernelInfo(context: Context, uri: Uri): ZipFileInfo {
|
||||
var zipInfo = ZipFileInfo(uri = uri, type = ZipType.KERNEL)
|
||||
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
ZipInputStream(inputStream).use { zipStream ->
|
||||
var entry = zipStream.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name.lowercase() == "anykernel.sh" || entry.name.endsWith("/anykernel.sh")) {
|
||||
val reader = BufferedReader(InputStreamReader(zipStream))
|
||||
val props = mutableMapOf<String, String>()
|
||||
|
||||
var inPropertiesBlock = false
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (line.contains("properties()")) {
|
||||
inPropertiesBlock = true
|
||||
} else if (inPropertiesBlock && line.contains("'; }")) {
|
||||
inPropertiesBlock = false
|
||||
} else if (inPropertiesBlock) {
|
||||
val propertyLine = line.trim()
|
||||
if (propertyLine.contains("=") && !propertyLine.startsWith("#")) {
|
||||
val parts = propertyLine.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val key = parts[0].trim()
|
||||
val value = parts[1].trim().removeSurrounding("'").removeSurrounding("\"")
|
||||
when (key) {
|
||||
"kernel.string" -> props["name"] = value
|
||||
"supported.versions" -> props["supported"] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析普通变量定义
|
||||
if (line.contains("kernel.string=") && !inPropertiesBlock) {
|
||||
val value = line.substringAfter("kernel.string=").trim().removeSurrounding("\"")
|
||||
props["name"] = value
|
||||
}
|
||||
if (line.contains("supported.versions=") && !inPropertiesBlock) {
|
||||
val value = line.substringAfter("supported.versions=").trim().removeSurrounding("\"")
|
||||
props["supported"] = value
|
||||
}
|
||||
if (line.contains("kernel.version=") && !inPropertiesBlock) {
|
||||
val value = line.substringAfter("kernel.version=").trim().removeSurrounding("\"")
|
||||
props["version"] = value
|
||||
}
|
||||
if (line.contains("kernel.author=") && !inPropertiesBlock) {
|
||||
val value = line.substringAfter("kernel.author=").trim().removeSurrounding("\"")
|
||||
props["author"] = value
|
||||
}
|
||||
|
||||
line = reader.readLine()
|
||||
}
|
||||
|
||||
zipInfo = zipInfo.copy(
|
||||
name = props["name"] ?: context.getString(R.string.unknown_kernel),
|
||||
version = props["version"] ?: "",
|
||||
author = props["author"] ?: "",
|
||||
supported = props["supported"] ?: "",
|
||||
kernelVersion = props["version"] ?: ""
|
||||
)
|
||||
break
|
||||
}
|
||||
zipStream.closeEntry()
|
||||
entry = zipStream.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return zipInfo
|
||||
}
|
||||
|
||||
suspend fun detectAndParseZipFiles(context: Context, zipUris: List<Uri>): List<ZipFileInfo> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val zipFileInfos = mutableListOf<ZipFileInfo>()
|
||||
|
||||
for (uri in zipUris) {
|
||||
val zipType = detectZipType(context, uri)
|
||||
val zipInfo = when (zipType) {
|
||||
ZipType.MODULE -> parseModuleInfo(context, uri)
|
||||
ZipType.KERNEL -> parseKernelInfo(context, uri)
|
||||
ZipType.UNKNOWN -> ZipFileInfo(
|
||||
uri = uri,
|
||||
type = ZipType.UNKNOWN,
|
||||
name = context.getString(R.string.unknown_file)
|
||||
)
|
||||
}
|
||||
zipFileInfos.add(zipInfo)
|
||||
}
|
||||
|
||||
zipFileInfos.filter { it.type != ZipType.UNKNOWN }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InstallConfirmationDialog(
|
||||
show: Boolean,
|
||||
zipFiles: List<ZipFileInfo>,
|
||||
onConfirm: (List<ZipFileInfo>) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
if (show && zipFiles.isNotEmpty()) {
|
||||
val context = LocalContext.current
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (zipFiles.any { it.type == ZipType.KERNEL })
|
||||
Icons.Default.Memory else Icons.Default.Extension,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = if (zipFiles.size == 1) {
|
||||
context.getString(R.string.confirm_installation)
|
||||
} else {
|
||||
context.getString(R.string.confirm_multiple_installation, zipFiles.size)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(zipFiles.size) { index ->
|
||||
val zipFile = zipFiles[index]
|
||||
InstallItemCard(zipFile = zipFile)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onConfirm(zipFiles) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.GetApp,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(context.getString(R.string.install_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(
|
||||
context.getString(android.R.string.cancel),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.widthIn(min = 320.dp, max = 560.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InstallItemCard(zipFile: ZipFileInfo) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = when (zipFile.type) {
|
||||
ZipType.MODULE -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
ZipType.KERNEL -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
),
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (zipFile.type) {
|
||||
ZipType.MODULE -> Icons.Default.Extension
|
||||
ZipType.KERNEL -> Icons.Default.Memory
|
||||
else -> Icons.AutoMirrored.Filled.Help
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = when (zipFile.type) {
|
||||
ZipType.MODULE -> MaterialTheme.colorScheme.primary
|
||||
ZipType.KERNEL -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = zipFile.name.ifEmpty {
|
||||
when (zipFile.type) {
|
||||
ZipType.MODULE -> context.getString(R.string.unknown_module)
|
||||
ZipType.KERNEL -> context.getString(R.string.unknown_kernel)
|
||||
else -> context.getString(R.string.unknown_file)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = when (zipFile.type) {
|
||||
ZipType.MODULE -> context.getString(R.string.module_package)
|
||||
ZipType.KERNEL -> context.getString(R.string.kernel_package)
|
||||
else -> context.getString(R.string.unknown_package)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 详细信息
|
||||
if (zipFile.version.isNotEmpty() || zipFile.author.isNotEmpty() ||
|
||||
zipFile.description.isNotEmpty() || zipFile.supported.isNotEmpty()) {
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 版本信息
|
||||
if (zipFile.version.isNotEmpty()) {
|
||||
InfoRow(
|
||||
label = context.getString(R.string.version),
|
||||
value = zipFile.version + if (zipFile.versionCode.isNotEmpty()) " (${zipFile.versionCode})" else ""
|
||||
)
|
||||
}
|
||||
|
||||
// 作者信息
|
||||
if (zipFile.author.isNotEmpty()) {
|
||||
InfoRow(
|
||||
label = context.getString(R.string.author),
|
||||
value = zipFile.author
|
||||
)
|
||||
}
|
||||
|
||||
// 描述信息 (仅模块)
|
||||
if (zipFile.description.isNotEmpty() && zipFile.type == ZipType.MODULE) {
|
||||
InfoRow(
|
||||
label = context.getString(R.string.description),
|
||||
value = zipFile.description
|
||||
)
|
||||
}
|
||||
|
||||
// 支持设备 (仅内核)
|
||||
if (zipFile.supported.isNotEmpty() && zipFile.type == ZipType.KERNEL) {
|
||||
InfoRow(
|
||||
label = context.getString(R.string.supported_devices),
|
||||
value = zipFile.supported
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Text(
|
||||
text = "$label:",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.widthIn(min = 60.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,4 @@ fun KeyEventBlocker(predicate: (KeyEvent) -> Boolean) {
|
||||
LaunchedEffect(Unit) {
|
||||
requester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ksuApp
|
||||
|
||||
@Composable
|
||||
fun KsuIsValid(
|
||||
@@ -14,4 +13,4 @@ fun KsuIsValid(
|
||||
if (ksuVersion != null) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
|
||||
private const val TAG = "SearchBar"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
searchText: String,
|
||||
onSearchTextChange: (String) -> Unit,
|
||||
onClearClick: () -> Unit,
|
||||
onBackClick: (() -> Unit)? = null,
|
||||
onConfirm: (() -> Unit)? = null,
|
||||
dropdownContent: @Composable (() -> Unit)? = null,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var onSearch by remember { mutableStateOf(false) }
|
||||
|
||||
// 获取卡片颜色和透明度
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
||||
colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
colorScheme.background
|
||||
}
|
||||
val cardAlpha = CardConfig.cardAlpha
|
||||
|
||||
if (onSearch) {
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
Box {
|
||||
AnimatedVisibility(
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
visible = !onSearch,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
content = { title() }
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = onSearch,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp, bottom = 2.dp, end = if (onBackClick != null) 0.dp else 14.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
if (focusState.isFocused) onSearch = true
|
||||
Log.d(TAG, "onFocusChanged: $focusState")
|
||||
},
|
||||
value = searchText,
|
||||
onValueChange = onSearchTextChange,
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSearch = false
|
||||
keyboardController?.hide()
|
||||
onClearClick()
|
||||
},
|
||||
content = { Icon(Icons.Filled.Close, null) }
|
||||
)
|
||||
},
|
||||
maxLines = 1,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
onConfirm?.invoke()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (onBackClick != null) {
|
||||
IconButton(
|
||||
onClick = onBackClick,
|
||||
content = { Icon(Icons.AutoMirrored.Outlined.ArrowBack, null) }
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
AnimatedVisibility(
|
||||
visible = !onSearch
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { onSearch = true },
|
||||
content = { Icon(Icons.Filled.Search, null) }
|
||||
)
|
||||
}
|
||||
|
||||
if (dropdownContent != null) {
|
||||
dropdownContent()
|
||||
}
|
||||
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SearchAppBarPreview() {
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
SearchAppBar(
|
||||
title = { Text("Search text") },
|
||||
searchText = searchText,
|
||||
onSearchTextChange = { searchText = it },
|
||||
onClearClick = { searchText = "" }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Save
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.sukisu.ultra.BuildConfig
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.util.getBugreportFile
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun SendLogDialog(
|
||||
showDialog: MutableState<Boolean>,
|
||||
loadingDialog: LoadingDialogHandle,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val exportBugreportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/gzip")
|
||||
) { uri: Uri? ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch(Dispatchers.IO) {
|
||||
loadingDialog.show()
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
getBugreportFile(context).inputStream().use {
|
||||
it.copyTo(output)
|
||||
}
|
||||
}
|
||||
loadingDialog.hide()
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, context.getString(R.string.log_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
insideMargin = DpSize(0.dp, 0.dp),
|
||||
onDismissRequest = {
|
||||
showDialog.value = false
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp, bottom = 12.dp),
|
||||
text = stringResource(R.string.send_log),
|
||||
fontSize = MiuixTheme.textStyles.title4.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
SuperArrow(
|
||||
title = stringResource(id = R.string.save_log),
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Rounded.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
tint = colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH_mm")
|
||||
val current = LocalDateTime.now().format(formatter)
|
||||
exportBugreportLauncher.launch("KernelSU_bugreport_${current}.tar.gz")
|
||||
showDialog.value = false
|
||||
},
|
||||
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
SuperArrow(
|
||||
title = stringResource(id = R.string.send_log),
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Rounded.Share,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
tint = colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
scope.launch {
|
||||
showDialog.value = false
|
||||
val bugreport = loadingDialog.withLoading {
|
||||
withContext(Dispatchers.IO) {
|
||||
getBugreportFile(context)
|
||||
}
|
||||
}
|
||||
|
||||
val uri: Uri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
bugreport
|
||||
)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
setDataAndType(uri, "application/gzip")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
context.getString(R.string.send_log)
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
TextButton(
|
||||
text = stringResource(id = android.R.string.cancel),
|
||||
onClick = {
|
||||
showDialog.value = false
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 24.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import com.dergoogler.mmrl.ui.component.LabelItem
|
||||
import com.dergoogler.mmrl.ui.component.text.TextRow
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
|
||||
@Composable
|
||||
fun SwitchItem(
|
||||
icon: ImageVector? = null,
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
beta: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val stateAlpha = remember(checked, enabled) { Modifier.alpha(if (enabled) 1f else 0.5f) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.toggleable(
|
||||
value = checked,
|
||||
interactionSource = interactionSource,
|
||||
role = Role.Switch,
|
||||
enabled = enabled,
|
||||
indication = LocalIndication.current,
|
||||
onValueChange = onCheckedChange
|
||||
),
|
||||
headlineContent = {
|
||||
TextRow(
|
||||
leadingContent = if (beta) {
|
||||
{
|
||||
LabelItem(
|
||||
modifier = Modifier.then(stateAlpha),
|
||||
text = "Beta"
|
||||
)
|
||||
}
|
||||
} else null
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.then(stateAlpha),
|
||||
text = title,
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingContent = icon?.let {
|
||||
{
|
||||
Icon(
|
||||
modifier = Modifier.then(stateAlpha),
|
||||
imageVector = icon,
|
||||
contentDescription = title
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onCheckedChange,
|
||||
interactionSource = interactionSource
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
if (summary != null) {
|
||||
Text(
|
||||
modifier = Modifier.then(stateAlpha),
|
||||
text = summary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RadioItem(
|
||||
title: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(title)
|
||||
},
|
||||
leadingContent = {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,250 +1,299 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.BlendModeColorFilter
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
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.dp
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponentColors
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
|
||||
import top.yukonga.miuix.kmp.basic.ListPopup
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.icons.basic.ArrowUpDownIntegrated
|
||||
import top.yukonga.miuix.kmp.icon.icons.basic.Check
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/**
|
||||
* A dropdown with a title and a summary.
|
||||
*
|
||||
* @param items The options of the [SuperDropdown].
|
||||
* @param selectedIndex The index of the selected option.
|
||||
* @param title The title of the [SuperDropdown].
|
||||
* @param titleColor The color of the title.
|
||||
* @param summary The summary of the [SuperDropdown].
|
||||
* @param summaryColor The color of the summary.
|
||||
* @param dropdownColors The [DropdownColors] of the [SuperDropdown].
|
||||
* @param insideMargin The margin inside the [SuperDropdown].
|
||||
* @param maxHeight The maximum height of the [ListPopup].
|
||||
* @param enabled Whether the [SuperDropdown] is enabled.
|
||||
* @param showValue Whether to show the selected value of the [SuperDropdown].
|
||||
* @param onClick The callback when the [SuperDropdown] is clicked.
|
||||
* @param onSelectedIndexChange The callback when the selected index of the [SuperDropdown] is changed.
|
||||
*/
|
||||
@Composable
|
||||
fun SuperDropdown(
|
||||
items: List<String>,
|
||||
selectedIndex: Int,
|
||||
title: String,
|
||||
titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(),
|
||||
summary: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(),
|
||||
dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
|
||||
leftAction: (@Composable (() -> Unit))? = null,
|
||||
insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin,
|
||||
maxHeight: Dp? = null,
|
||||
enabled: Boolean = true,
|
||||
showValue: Boolean = true,
|
||||
maxHeight: Dp? = 400.dp,
|
||||
colors: SuperDropdownColors = SuperDropdownDefaults.colors(),
|
||||
leftAction: (@Composable () -> Unit)? = null,
|
||||
onSelectedIndexChange: (Int) -> Unit
|
||||
onClick: (() -> Unit)? = null,
|
||||
onSelectedIndexChange: ((Int) -> Unit)?,
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val selectedItemText = items.getOrNull(selectedIndex) ?: ""
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isDropdownExpanded = remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
val itemsNotEmpty = items.isNotEmpty()
|
||||
val actualEnabled = enabled && itemsNotEmpty
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = actualEnabled) { showDialog = true }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
if (leftAction != null) {
|
||||
leftAction()
|
||||
} else if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (actualEnabled) colors.iconColor else colors.disabledIconColor,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (actualEnabled) colors.titleColor else colors.disabledTitleColor
|
||||
)
|
||||
|
||||
if (summary != null) {
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (actualEnabled) colors.summaryColor else colors.disabledSummaryColor
|
||||
)
|
||||
}
|
||||
|
||||
if (showValue && itemsNotEmpty) {
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Text(
|
||||
text = selectedItemText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (actualEnabled) colors.valueColor else colors.disabledValueColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
tint = if (actualEnabled) colors.arrowColor else colors.disabledArrowColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
val actionColor = if (actualEnabled) {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||
} else {
|
||||
MiuixTheme.colorScheme.disabledOnSecondaryVariant
|
||||
}
|
||||
|
||||
if (showDialog && itemsNotEmpty) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDialog = false },
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
val dialogMaxHeight = maxHeight ?: 400.dp
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = dialogMaxHeight),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(items.size) { index ->
|
||||
DropdownItem(
|
||||
text = items[index],
|
||||
isSelected = selectedIndex == index,
|
||||
colors = colors,
|
||||
onClick = {
|
||||
onSelectedIndexChange(index)
|
||||
showDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
val handleClick: () -> Unit = {
|
||||
if (actualEnabled) {
|
||||
onClick?.invoke()
|
||||
isDropdownExpanded.value = !isDropdownExpanded.value
|
||||
if (isDropdownExpanded.value) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicComponent(
|
||||
interactionSource = interactionSource,
|
||||
insideMargin = insideMargin,
|
||||
title = title,
|
||||
titleColor = titleColor,
|
||||
summary = summary,
|
||||
summaryColor = summaryColor,
|
||||
leftAction = if (itemsNotEmpty) {
|
||||
{
|
||||
SuperDropdownPopup(
|
||||
items = items,
|
||||
selectedIndex = selectedIndex,
|
||||
isDropdownExpanded = isDropdownExpanded,
|
||||
maxHeight = maxHeight,
|
||||
dropdownColors = dropdownColors,
|
||||
hapticFeedback = hapticFeedback,
|
||||
onSelectedIndexChange = onSelectedIndexChange
|
||||
)
|
||||
leftAction?.invoke()
|
||||
}
|
||||
} else null,
|
||||
rightActions = {
|
||||
SuperDropdownRightActions(
|
||||
showValue = showValue,
|
||||
itemsNotEmpty = itemsNotEmpty,
|
||||
items = items,
|
||||
selectedIndex = selectedIndex,
|
||||
actionColor = actionColor
|
||||
)
|
||||
},
|
||||
onClick = handleClick,
|
||||
holdDownState = isDropdownExpanded.value,
|
||||
enabled = actualEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuperDropdownPopup(
|
||||
items: List<String>,
|
||||
selectedIndex: Int,
|
||||
isDropdownExpanded: MutableState<Boolean>,
|
||||
maxHeight: Dp?,
|
||||
dropdownColors: DropdownColors,
|
||||
hapticFeedback: HapticFeedback,
|
||||
onSelectedIndexChange: ((Int) -> Unit)?
|
||||
) {
|
||||
val onSelectState = rememberUpdatedState(onSelectedIndexChange)
|
||||
ListPopup(
|
||||
show = isDropdownExpanded,
|
||||
alignment = PopupPositionProvider.Align.Right,
|
||||
onDismissRequest = {
|
||||
isDropdownExpanded.value = false
|
||||
},
|
||||
maxHeight = maxHeight
|
||||
) {
|
||||
ListPopupColumn {
|
||||
items.forEachIndexed { index, string ->
|
||||
key(index) {
|
||||
DropdownImpl(
|
||||
text = string,
|
||||
optionSize = items.size,
|
||||
isSelected = selectedIndex == index,
|
||||
dropdownColors = dropdownColors,
|
||||
onSelectedIndexChange = { selectedIdx ->
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
onSelectState.value?.invoke(selectedIdx)
|
||||
isDropdownExpanded.value = false
|
||||
},
|
||||
index = index
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showDialog = false }) {
|
||||
Text(text = stringResource(id = android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
containerColor = colors.dialogBackgroundColor,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 4.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DropdownItem(
|
||||
text: String,
|
||||
isSelected: Boolean,
|
||||
colors: SuperDropdownColors,
|
||||
onClick: () -> Unit
|
||||
private fun RowScope.SuperDropdownRightActions(
|
||||
showValue: Boolean,
|
||||
itemsNotEmpty: Boolean,
|
||||
items: List<String>,
|
||||
selectedIndex: Int,
|
||||
actionColor: Color
|
||||
) {
|
||||
val backgroundColor = if (isSelected) {
|
||||
colors.selectedBackgroundColor
|
||||
if (showValue && itemsNotEmpty) {
|
||||
Text(
|
||||
modifier = Modifier.widthIn(max = 130.dp),
|
||||
text = items[selectedIndex],
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = actionColor,
|
||||
textAlign = TextAlign.End,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(10.dp, 16.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
imageVector = MiuixIcons.Basic.ArrowUpDownIntegrated,
|
||||
colorFilter = ColorFilter.tint(actionColor),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The implementation of the dropdown.
|
||||
*
|
||||
* @param text The text of the current option.
|
||||
* @param optionSize The size of the options.
|
||||
* @param isSelected Whether the option is selected.
|
||||
* @param index The index of the current option in the options.
|
||||
* @param onSelectedIndexChange The callback when the index is selected.
|
||||
*/
|
||||
@Composable
|
||||
fun DropdownImpl(
|
||||
text: String,
|
||||
optionSize: Int,
|
||||
isSelected: Boolean,
|
||||
index: Int,
|
||||
dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
|
||||
onSelectedIndexChange: (Int) -> Unit
|
||||
) {
|
||||
val additionalTopPadding = if (index == 0) 20.dp else 12.dp
|
||||
val additionalBottomPadding = if (index == optionSize - 1) 20.dp else 12.dp
|
||||
|
||||
val (textColor, backgroundColor) = if (isSelected) {
|
||||
dropdownColors.selectedContentColor to dropdownColors.selectedContainerColor
|
||||
} else {
|
||||
dropdownColors.contentColor to dropdownColors.containerColor
|
||||
}
|
||||
|
||||
val checkColor = if (isSelected) {
|
||||
dropdownColors.selectedContentColor
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
val contentColor = if (isSelected) {
|
||||
colors.selectedContentColor
|
||||
} else {
|
||||
colors.contentColor
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable { onSelectedIndexChange(index) }
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 12.dp, horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(
|
||||
top = additionalTopPadding,
|
||||
bottom = additionalBottomPadding
|
||||
)
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null,
|
||||
colors = RadioButtonDefaults.colors(
|
||||
selectedColor = colors.selectedContentColor,
|
||||
unselectedColor = colors.contentColor
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
modifier = Modifier.widthIn(max = 200.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor,
|
||||
modifier = Modifier.weight(1f)
|
||||
fontSize = MiuixTheme.textStyles.body1.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor,
|
||||
)
|
||||
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.size(20.dp),
|
||||
imageVector = MiuixIcons.Basic.Check,
|
||||
colorFilter = BlendModeColorFilter(checkColor, BlendMode.SrcIn),
|
||||
contentDescription = null,
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = colors.selectedContentColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class SuperDropdownColors(
|
||||
val titleColor: Color,
|
||||
val summaryColor: Color,
|
||||
val valueColor: Color,
|
||||
val iconColor: Color,
|
||||
val arrowColor: Color,
|
||||
val disabledTitleColor: Color,
|
||||
val disabledSummaryColor: Color,
|
||||
val disabledValueColor: Color,
|
||||
val disabledIconColor: Color,
|
||||
val disabledArrowColor: Color,
|
||||
val dialogBackgroundColor: Color,
|
||||
class DropdownColors(
|
||||
val contentColor: Color,
|
||||
val containerColor: Color,
|
||||
val selectedContentColor: Color,
|
||||
val selectedBackgroundColor: Color
|
||||
val selectedContainerColor: Color
|
||||
)
|
||||
|
||||
object SuperDropdownDefaults {
|
||||
object DropdownDefaults {
|
||||
|
||||
@Composable
|
||||
fun colors(
|
||||
titleColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
summaryColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
valueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconColor: Color = MaterialTheme.colorScheme.primary,
|
||||
arrowColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledTitleColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
disabledSummaryColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
disabledValueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
disabledIconColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
disabledArrowColor: Color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
dialogBackgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
selectedContentColor: Color = MaterialTheme.colorScheme.primary,
|
||||
selectedBackgroundColor: Color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
): SuperDropdownColors {
|
||||
return SuperDropdownColors(
|
||||
titleColor = titleColor,
|
||||
summaryColor = summaryColor,
|
||||
valueColor = valueColor,
|
||||
iconColor = iconColor,
|
||||
arrowColor = arrowColor,
|
||||
disabledTitleColor = disabledTitleColor,
|
||||
disabledSummaryColor = disabledSummaryColor,
|
||||
disabledValueColor = disabledValueColor,
|
||||
disabledIconColor = disabledIconColor,
|
||||
disabledArrowColor = disabledArrowColor,
|
||||
dialogBackgroundColor = dialogBackgroundColor,
|
||||
fun dropdownColors(
|
||||
contentColor: Color = MiuixTheme.colorScheme.onSurface,
|
||||
containerColor: Color = MiuixTheme.colorScheme.surface,
|
||||
selectedContentColor: Color = MiuixTheme.colorScheme.onTertiaryContainer,
|
||||
selectedContainerColor: Color = MiuixTheme.colorScheme.surface
|
||||
): DropdownColors {
|
||||
return DropdownColors(
|
||||
contentColor = contentColor,
|
||||
containerColor = containerColor,
|
||||
selectedContentColor = selectedContentColor,
|
||||
selectedBackgroundColor = selectedBackgroundColor
|
||||
selectedContainerColor = selectedContainerColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.filter.FilterNumber
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponentColors
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.extra.RightActionColors
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrowDefaults
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
|
||||
@Composable
|
||||
fun SuperEditArrow(
|
||||
title: String,
|
||||
titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(),
|
||||
defaultValue: Int = -1,
|
||||
summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(),
|
||||
leftAction: @Composable (() -> Unit)? = null,
|
||||
rightActionColor: RightActionColors = SuperArrowDefaults.rightActionColors(),
|
||||
modifier: Modifier = Modifier,
|
||||
insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin,
|
||||
enabled: Boolean = true,
|
||||
onValueChange: ((Int) -> Unit)? = null
|
||||
) {
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
val dialogTextFieldValue = remember { mutableIntStateOf(defaultValue) }
|
||||
|
||||
SuperArrow(
|
||||
title = title,
|
||||
titleColor = titleColor,
|
||||
summary = dialogTextFieldValue.intValue.toString(),
|
||||
summaryColor = summaryColor,
|
||||
leftAction = leftAction,
|
||||
rightActionColor = rightActionColor,
|
||||
modifier = modifier,
|
||||
insideMargin = insideMargin,
|
||||
onClick = {
|
||||
showDialog.value = true
|
||||
},
|
||||
holdDownState = showDialog.value,
|
||||
enabled = enabled
|
||||
)
|
||||
|
||||
EditDialog(
|
||||
title,
|
||||
showDialog,
|
||||
dialogTextFieldValue = dialogTextFieldValue.intValue,
|
||||
) {
|
||||
dialogTextFieldValue.intValue = it
|
||||
onValueChange?.invoke(dialogTextFieldValue.intValue)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditDialog(
|
||||
title: String,
|
||||
showDialog: MutableState<Boolean>,
|
||||
dialogTextFieldValue: Int,
|
||||
onValueChange: (Int) -> Unit,
|
||||
) {
|
||||
val inputTextFieldValue = remember { mutableIntStateOf(dialogTextFieldValue) }
|
||||
val filter = remember(key1 = inputTextFieldValue.intValue) { FilterNumber(dialogTextFieldValue) }
|
||||
|
||||
SuperDialog(
|
||||
title = title,
|
||||
show = showDialog,
|
||||
onDismissRequest = {
|
||||
showDialog.value = false
|
||||
filter.setInputValue(dialogTextFieldValue.toString())
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
value = filter.getInputValue(),
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
onValueChange = filter.onValueChange()
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
onClick = {
|
||||
showDialog.value = false
|
||||
filter.setInputValue(dialogTextFieldValue.toString())
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
TextButton(
|
||||
text = stringResource(R.string.confirm),
|
||||
onClick = {
|
||||
showDialog.value = false
|
||||
with(filter.getInputValue().text) {
|
||||
if (isEmpty()) {
|
||||
onValueChange(0)
|
||||
filter.setInputValue("0")
|
||||
} else {
|
||||
onValueChange(this@with.toInt())
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.InputField
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.icons.basic.Search
|
||||
import top.yukonga.miuix.kmp.icon.icons.basic.SearchCleanup
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.utils.BackHandler
|
||||
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||
|
||||
// Search Status Class
|
||||
@Stable
|
||||
class SearchStatus(val label: String) {
|
||||
var searchText by mutableStateOf("")
|
||||
var current by mutableStateOf(Status.COLLAPSED)
|
||||
|
||||
var offsetY by mutableStateOf(0.dp)
|
||||
var resultStatus by mutableStateOf(ResultStatus.DEFAULT)
|
||||
|
||||
fun isExpand() = current == Status.EXPANDED
|
||||
fun isCollapsed() = current == Status.COLLAPSED
|
||||
fun shouldExpand() = current == Status.EXPANDED || current == Status.EXPANDING
|
||||
fun shouldCollapsed() = current == Status.COLLAPSED || current == Status.COLLAPSING
|
||||
fun isAnimatingExpand() = current == Status.EXPANDING
|
||||
|
||||
// 动画完成回调
|
||||
fun onAnimationComplete() {
|
||||
current = when (current) {
|
||||
Status.EXPANDING -> Status.EXPANDED
|
||||
Status.COLLAPSING -> {
|
||||
searchText = ""
|
||||
Status.COLLAPSED
|
||||
}
|
||||
|
||||
else -> current
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopAppBarAnim(
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = shouldCollapsed(),
|
||||
hazeState: HazeState? = null,
|
||||
hazeStyle: HazeStyle? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val topAppBarAlpha = animateFloatAsState(
|
||||
if (visible) 1f else 0f,
|
||||
animationSpec = tween(if (visible) 550 else 0, easing = FastOutSlowInEasing),
|
||||
)
|
||||
Box(modifier = modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.then(
|
||||
if (hazeState != null && hazeStyle != null) {
|
||||
Modifier.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
}
|
||||
} else {
|
||||
Modifier.background(colorScheme.background)
|
||||
}
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.alpha(topAppBarAlpha.value)
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
enum class Status { EXPANDED, EXPANDING, COLLAPSED, COLLAPSING }
|
||||
enum class ResultStatus { DEFAULT, EMPTY, LOAD, SHOW }
|
||||
}
|
||||
|
||||
// Search Box Composable
|
||||
@Composable
|
||||
fun SearchStatus.SearchBox(
|
||||
collapseBar: @Composable (SearchStatus, Dp, PaddingValues) -> Unit = { searchStatus, topPadding, innerPadding ->
|
||||
SearchBarFake(searchStatus.label, topPadding, innerPadding)
|
||||
},
|
||||
searchBarTopPadding: Dp = 12.dp,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
hazeState: HazeState,
|
||||
hazeStyle: HazeStyle,
|
||||
content: @Composable (MutableState<Dp>) -> Unit
|
||||
) {
|
||||
val searchStatus = this
|
||||
val density = LocalDensity.current
|
||||
|
||||
animateFloatAsState(if (searchStatus.shouldCollapsed()) 1f else 0f)
|
||||
|
||||
val offsetY = remember { mutableIntStateOf(0) }
|
||||
val boxHeight = remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(10f)
|
||||
.alpha(if (searchStatus.isCollapsed()) 1f else 0f)
|
||||
.offset(y = contentPadding.calculateTopPadding())
|
||||
.onGloballyPositioned {
|
||||
it.positionInWindow().y.apply {
|
||||
offsetY.intValue = (this@apply * 0.9).toInt()
|
||||
with(density) {
|
||||
searchStatus.offsetY = this@apply.toDp()
|
||||
boxHeight.value = it.size.height.toDp()
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { searchStatus.current = SearchStatus.Status.EXPANDING }
|
||||
}
|
||||
.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
}
|
||||
) {
|
||||
collapseBar(searchStatus, searchBarTopPadding, contentPadding)
|
||||
}
|
||||
Box {
|
||||
AnimatedVisibility(
|
||||
visible = searchStatus.shouldCollapsed(),
|
||||
enter = fadeIn(tween(300, easing = LinearOutSlowInEasing)) + slideInVertically(
|
||||
tween(
|
||||
300,
|
||||
easing = LinearOutSlowInEasing
|
||||
)
|
||||
) { -offsetY.intValue },
|
||||
exit = fadeOut(tween(300, easing = LinearOutSlowInEasing)) + slideOutVertically(
|
||||
tween(
|
||||
300,
|
||||
easing = LinearOutSlowInEasing
|
||||
)
|
||||
) { -offsetY.intValue }
|
||||
) {
|
||||
content(boxHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search Pager Composable
|
||||
@Composable
|
||||
fun SearchStatus.SearchPager(
|
||||
defaultResult: @Composable () -> Unit,
|
||||
expandBar: @Composable (SearchStatus, Dp) -> Unit = { searchStatus, padding ->
|
||||
SearchBar(searchStatus, padding)
|
||||
},
|
||||
searchBarTopPadding: Dp = 12.dp,
|
||||
result: LazyListScope.() -> Unit
|
||||
) {
|
||||
val searchStatus = this
|
||||
val systemBarsPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
|
||||
val topPadding by animateDpAsState(
|
||||
if (searchStatus.shouldExpand()) systemBarsPadding + 5.dp else searchStatus.offsetY,
|
||||
animationSpec = tween(300, easing = LinearOutSlowInEasing)
|
||||
) {
|
||||
searchStatus.onAnimationComplete()
|
||||
}
|
||||
val backgroundAlpha by animateFloatAsState(
|
||||
if (searchStatus.shouldExpand()) 1f else 0f,
|
||||
animationSpec = tween(200, easing = FastOutSlowInEasing)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(5f)
|
||||
.background(colorScheme.background.copy(alpha = backgroundAlpha))
|
||||
.semantics { onClick { false } }
|
||||
.then(
|
||||
if (!searchStatus.isCollapsed()) Modifier.pointerInput(Unit) { } else Modifier
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = topPadding)
|
||||
.then(
|
||||
if (!searchStatus.isCollapsed()) Modifier.background(colorScheme.background)
|
||||
else Modifier
|
||||
),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (!searchStatus.isCollapsed()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(colorScheme.background)
|
||||
) {
|
||||
expandBar(searchStatus, searchBarTopPadding)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = searchStatus.isExpand() || searchStatus.isAnimatingExpand(),
|
||||
enter = expandHorizontally() + slideInHorizontally(initialOffsetX = { it }),
|
||||
exit = shrinkHorizontally() + slideOutHorizontally(targetOffsetX = { it })
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp, end = 16.dp, top = searchBarTopPadding)
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
enabled = searchStatus.isExpand(),
|
||||
indication = null
|
||||
) { searchStatus.current = SearchStatus.Status.COLLAPSING }
|
||||
)
|
||||
BackHandler(enabled = true) {
|
||||
searchStatus.current = SearchStatus.Status.COLLAPSING
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = searchStatus.isExpand(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(1f),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
when (searchStatus.resultStatus) {
|
||||
SearchStatus.ResultStatus.DEFAULT -> defaultResult()
|
||||
SearchStatus.ResultStatus.EMPTY -> {}
|
||||
SearchStatus.ResultStatus.LOAD -> {}
|
||||
SearchStatus.ResultStatus.SHOW -> LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.overScrollVertical(),
|
||||
) {
|
||||
result()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
searchStatus: SearchStatus,
|
||||
searchBarTopPadding: Dp = 12.dp,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
InputField(
|
||||
query = searchStatus.searchText,
|
||||
onQueryChange = { searchStatus.searchText = it },
|
||||
label = "",
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Basic.Search,
|
||||
contentDescription = "back",
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.padding(start = 16.dp, end = 8.dp),
|
||||
tint = colorScheme.onSurfaceContainerHigh,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
AnimatedVisibility(
|
||||
searchStatus.searchText.isNotEmpty(),
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Basic.SearchCleanup,
|
||||
tint = colorScheme.onSurface,
|
||||
contentDescription = "Clean",
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.padding(start = 8.dp, end = 16.dp)
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = null
|
||||
) {
|
||||
searchStatus.searchText = ""
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = searchBarTopPadding, bottom = 6.dp)
|
||||
.focusRequester(focusRequester),
|
||||
onSearch = { it },
|
||||
expanded = searchStatus.shouldExpand(),
|
||||
onExpandedChange = {
|
||||
searchStatus.current = if (it) SearchStatus.Status.EXPANDED else SearchStatus.Status.COLLAPSED
|
||||
}
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
if (!expanded && searchStatus.shouldExpand()) {
|
||||
focusRequester.requestFocus()
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBarFake(
|
||||
label: String,
|
||||
searchBarTopPadding: Dp = 12.dp,
|
||||
innerPadding: PaddingValues = PaddingValues(0.dp)
|
||||
) {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
InputField(
|
||||
query = "",
|
||||
onQueryChange = { },
|
||||
label = label,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Basic.Search,
|
||||
contentDescription = "Clean",
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.padding(start = 16.dp, end = 8.dp),
|
||||
tint = colorScheme.onSurfaceContainerHigh,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(
|
||||
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
end = innerPadding.calculateEndPadding(layoutDirection)
|
||||
)
|
||||
.padding(top = searchBarTopPadding, bottom = 6.dp),
|
||||
onSearch = { },
|
||||
enabled = false,
|
||||
expanded = false,
|
||||
onExpandedChange = { }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.screen.FlashIt
|
||||
import com.sukisu.ultra.ui.screen.UninstallType
|
||||
import com.sukisu.ultra.ui.screen.UninstallType.NONE
|
||||
import com.sukisu.ultra.ui.screen.UninstallType.PERMANENT
|
||||
import com.sukisu.ultra.ui.screen.UninstallType.RESTORE_STOCK_IMAGE
|
||||
import com.sukisu.ultra.ui.screen.UninstallType.TEMPORARY
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
@Composable
|
||||
fun UninstallDialog(
|
||||
showDialog: MutableState<Boolean>,
|
||||
navigator: DestinationsNavigator,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val options = listOf(
|
||||
// TEMPORARY,
|
||||
PERMANENT,
|
||||
RESTORE_STOCK_IMAGE
|
||||
)
|
||||
val showTodo = {
|
||||
Toast.makeText(context, "TODO", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val showConfirmDialog = remember(showDialog.value) { mutableStateOf(false) }
|
||||
val runType = remember(showDialog.value) { mutableStateOf<UninstallType?>(null) }
|
||||
|
||||
val run = { type: UninstallType ->
|
||||
when (type) {
|
||||
PERMANENT -> navigator.navigate(FlashScreenDestination(FlashIt.FlashUninstall)) {
|
||||
popUpTo(FlashScreenDestination) {
|
||||
inclusive = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
RESTORE_STOCK_IMAGE -> navigator.navigate(FlashScreenDestination(FlashIt.FlashRestore)) {
|
||||
popUpTo(FlashScreenDestination) {
|
||||
inclusive = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
TEMPORARY -> showTodo()
|
||||
NONE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
insideMargin = DpSize(0.dp, 0.dp),
|
||||
onDismissRequest = {
|
||||
showDialog.value = false
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp, bottom = 12.dp),
|
||||
text = stringResource(R.string.uninstall),
|
||||
fontSize = MiuixTheme.textStyles.title4.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MiuixTheme.colorScheme.onSurface
|
||||
)
|
||||
options.forEachIndexed { index, type ->
|
||||
SuperArrow(
|
||||
onClick = {
|
||||
showConfirmDialog.value = true
|
||||
runType.value = type
|
||||
},
|
||||
title = stringResource(type.title),
|
||||
leftAction = {
|
||||
Icon(
|
||||
imageVector = type.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
tint = MiuixTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
insideMargin = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
text = stringResource(id = android.R.string.cancel),
|
||||
onClick = {
|
||||
showDialog.value = false
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 24.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
val confirmDialog = rememberConfirmDialog(
|
||||
onConfirm = {
|
||||
showConfirmDialog.value = false
|
||||
showDialog.value = false
|
||||
runType.value?.let { type ->
|
||||
run(type)
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
)
|
||||
val dialogTitle = runType.value?.let { type ->
|
||||
options.find { it == type }?.let { stringResource(it.title) }
|
||||
} ?: ""
|
||||
val dialogContent = runType.value?.let { type ->
|
||||
options.find { it == type }?.let { stringResource(it.message) }
|
||||
}
|
||||
if (showConfirmDialog.value) {
|
||||
confirmDialog.showConfirm(title = dialogTitle, content = dialogContent)
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
|
||||
data class FabMenuItem(
|
||||
val icon: ImageVector,
|
||||
val labelRes: Int,
|
||||
val color: Color = Color.Unspecified,
|
||||
val onClick: () -> Unit
|
||||
)
|
||||
|
||||
object FabAnimationConfig {
|
||||
const val ANIMATION_DURATION = 300
|
||||
const val STAGGER_DELAY = 50
|
||||
val BUTTON_SPACING = 72.dp
|
||||
val BUTTON_SIZE = 56.dp
|
||||
val SMALL_BUTTON_SIZE = 48.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalExpandableFab(
|
||||
menuItems: List<FabMenuItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
buttonSize: Dp = FabAnimationConfig.BUTTON_SIZE,
|
||||
smallButtonSize: Dp = FabAnimationConfig.SMALL_BUTTON_SIZE,
|
||||
buttonSpacing: Dp = FabAnimationConfig.BUTTON_SPACING,
|
||||
animationDurationMs: Int = FabAnimationConfig.ANIMATION_DURATION,
|
||||
staggerDelayMs: Int = FabAnimationConfig.STAGGER_DELAY,
|
||||
mainButtonIcon: ImageVector = Icons.Filled.Add,
|
||||
mainButtonExpandedIcon: ImageVector = Icons.Filled.Close,
|
||||
onMainButtonClick: (() -> Unit)? = null,
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val rotationAngle by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 45f else 0f,
|
||||
animationSpec = tween(animationDurationMs, easing = FastOutSlowInEasing),
|
||||
label = "mainButtonRotation"
|
||||
)
|
||||
|
||||
val mainButtonScale by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 1.1f else 1f,
|
||||
animationSpec = tween(animationDurationMs, easing = FastOutSlowInEasing),
|
||||
label = "mainButtonScale"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier.wrapContentSize(),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
menuItems.forEachIndexed { index, menuItem ->
|
||||
val animatedOffsetY by animateFloatAsState(
|
||||
targetValue = if (isExpanded) -(buttonSpacing.value * (index + 1)) else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = animationDurationMs,
|
||||
delayMillis = if (isExpanded) {
|
||||
index * staggerDelayMs
|
||||
} else {
|
||||
(menuItems.size - index - 1) * staggerDelayMs
|
||||
},
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "fabOffset$index"
|
||||
)
|
||||
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 1f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = animationDurationMs,
|
||||
delayMillis = if (isExpanded) {
|
||||
index * staggerDelayMs + 100
|
||||
} else {
|
||||
(menuItems.size - index - 1) * staggerDelayMs
|
||||
},
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "fabScale$index"
|
||||
)
|
||||
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 1f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = animationDurationMs,
|
||||
delayMillis = if (isExpanded) {
|
||||
index * staggerDelayMs + 150
|
||||
} else {
|
||||
(menuItems.size - index - 1) * staggerDelayMs
|
||||
},
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
label = "fabAlpha$index"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.offset(y = animatedOffsetY.dp)
|
||||
.scale(animatedScale)
|
||||
.alpha(animatedAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded && animatedScale > 0.5f,
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it / 2 },
|
||||
animationSpec = tween(200)
|
||||
) + fadeIn(animationSpec = tween(200)),
|
||||
exit = slideOutHorizontally(
|
||||
targetOffsetX = { it / 2 },
|
||||
animationSpec = tween(150)
|
||||
) + fadeOut(animationSpec = tween(150))
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.inverseSurface,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(menuItem.labelRes),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
menuItem.onClick()
|
||||
isExpanded = false
|
||||
},
|
||||
modifier = Modifier.size(smallButtonSize),
|
||||
containerColor = if (menuItem.color != Color.Unspecified) {
|
||||
menuItem.color
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
},
|
||||
contentColor = if (menuItem.color != Color.Unspecified) {
|
||||
if (menuItem.color == Color.Gray) Color.White
|
||||
else MaterialTheme.colorScheme.onSecondary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSecondary
|
||||
},
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 4.dp,
|
||||
pressedElevation = 6.dp
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = menuItem.icon,
|
||||
contentDescription = stringResource(menuItem.labelRes),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
onMainButtonClick?.invoke()
|
||||
isExpanded = !isExpanded
|
||||
},
|
||||
modifier = Modifier.size(buttonSize).scale(mainButtonScale),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 6.dp,
|
||||
pressedElevation = 8.dp,
|
||||
hoveredElevation = 8.dp
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isExpanded) mainButtonExpandedIcon else mainButtonIcon,
|
||||
contentDescription = stringResource(
|
||||
if (isExpanded) R.string.collapse_menu else R.string.expand_menu
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(if (mainButtonIcon == Icons.Filled.Add) rotationAngle else 0f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object FabMenuPresets {
|
||||
fun getScrollMenuItems(
|
||||
onScrollToTop: () -> Unit,
|
||||
onScrollToBottom: () -> Unit
|
||||
) = listOf(
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.KeyboardArrowDown,
|
||||
labelRes = R.string.scroll_to_bottom,
|
||||
onClick = onScrollToBottom
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.KeyboardArrowUp,
|
||||
labelRes = R.string.scroll_to_top,
|
||||
onClick = onScrollToTop
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun getBatchActionMenuItems(
|
||||
onCancel: () -> Unit,
|
||||
onDeny: () -> Unit,
|
||||
onAllow: () -> Unit,
|
||||
onUnmountModules: () -> Unit,
|
||||
onDisableUnmount: () -> Unit
|
||||
) = listOf(
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.Close,
|
||||
labelRes = R.string.cancel,
|
||||
color = Color.Gray,
|
||||
onClick = onCancel
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.Block,
|
||||
labelRes = R.string.deny_authorization,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
onClick = onDeny
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.Check,
|
||||
labelRes = R.string.grant_authorization,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
onClick = onAllow
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.FolderOff,
|
||||
labelRes = R.string.unmount_modules,
|
||||
onClick = onUnmountModules
|
||||
),
|
||||
FabMenuItem(
|
||||
icon = Icons.Filled.Folder,
|
||||
labelRes = R.string.disable_unmount,
|
||||
onClick = onDisableUnmount
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.sukisu.ultra.ui.component.filter
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
|
||||
open class BaseFieldFilter() {
|
||||
private var inputValue = mutableStateOf(TextFieldValue())
|
||||
|
||||
constructor(value: String) : this() {
|
||||
inputValue.value = TextFieldValue(value, TextRange(value.lastIndex + 1))
|
||||
}
|
||||
|
||||
protected open fun onFilter(inputTextFieldValue: TextFieldValue, lastTextFieldValue: TextFieldValue): TextFieldValue {
|
||||
return TextFieldValue()
|
||||
}
|
||||
|
||||
protected open fun computePos(): Int {
|
||||
// TODO
|
||||
return 0
|
||||
}
|
||||
|
||||
protected fun getNewTextRange(
|
||||
lastTextFiled: TextFieldValue,
|
||||
inputTextFieldValue: TextFieldValue
|
||||
): TextRange? {
|
||||
return null
|
||||
}
|
||||
|
||||
protected fun getNewText(
|
||||
lastTextFiled: TextFieldValue,
|
||||
inputTextFieldValue: TextFieldValue
|
||||
): TextRange? {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun setInputValue(value: String) {
|
||||
inputValue.value = TextFieldValue(value, TextRange(value.lastIndex + 1))
|
||||
}
|
||||
|
||||
fun getInputValue(): TextFieldValue {
|
||||
return inputValue.value
|
||||
}
|
||||
|
||||
fun onValueChange(): (TextFieldValue) -> Unit {
|
||||
return {
|
||||
inputValue.value = onFilter(it, inputValue.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.sukisu.ultra.ui.component.filter
|
||||
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
|
||||
class FilterNumber(
|
||||
private val value: Int,
|
||||
private val minValue: Int = Int.MIN_VALUE,
|
||||
private val maxValue: Int = Int.MAX_VALUE,
|
||||
) : BaseFieldFilter(value.toString()) {
|
||||
|
||||
override fun onFilter(
|
||||
inputTextFieldValue: TextFieldValue,
|
||||
lastTextFieldValue: TextFieldValue
|
||||
): TextFieldValue {
|
||||
return filterInputNumber(inputTextFieldValue, lastTextFieldValue, minValue, maxValue)
|
||||
}
|
||||
|
||||
private fun filterInputNumber(
|
||||
inputTextFieldValue: TextFieldValue,
|
||||
lastInputTextFieldValue: TextFieldValue,
|
||||
minValue: Int = Int.MIN_VALUE,
|
||||
maxValue: Int = Int.MAX_VALUE,
|
||||
): TextFieldValue {
|
||||
val inputString = inputTextFieldValue.text
|
||||
lastInputTextFieldValue.text
|
||||
|
||||
val newString = StringBuilder()
|
||||
val supportNegative = minValue < 0
|
||||
var isNegative = false
|
||||
|
||||
// 只允许负号在首位,并且只允许一个负号
|
||||
if (supportNegative && inputString.isNotEmpty() && inputString.first() == '-') {
|
||||
isNegative = true
|
||||
newString.append('-')
|
||||
}
|
||||
|
||||
for ((i, c) in inputString.withIndex()) {
|
||||
if (i == 0 && isNegative) continue // 首字符已经处理
|
||||
when (c) {
|
||||
in '0'..'9' -> {
|
||||
newString.append(c)
|
||||
// 检查是否超出范围
|
||||
val tempText = newString.toString()
|
||||
// 只在不是单独 '-' 时做判断(因为 '-' toInt 会异常)
|
||||
if (tempText != "-" && tempText.isNotEmpty()) {
|
||||
try {
|
||||
val tempValue = tempText.toInt()
|
||||
if (tempValue > maxValue || tempValue < minValue) {
|
||||
newString.deleteCharAt(newString.lastIndex)
|
||||
}
|
||||
} catch (e: NumberFormatException) {
|
||||
// 超出int范围
|
||||
newString.deleteCharAt(newString.lastIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 忽略其他字符(包括点号)
|
||||
}
|
||||
}
|
||||
|
||||
val textRange: TextRange
|
||||
if (inputTextFieldValue.selection.collapsed) { // 表示的是光标范围
|
||||
if (inputTextFieldValue.selection.end != inputTextFieldValue.text.length) { // 光标没有指向末尾
|
||||
var newPosition = inputTextFieldValue.selection.end + (newString.length - inputString.length)
|
||||
if (newPosition < 0) {
|
||||
newPosition = inputTextFieldValue.selection.end
|
||||
}
|
||||
textRange = TextRange(newPosition)
|
||||
} else { // 光标指向了末尾
|
||||
textRange = TextRange(newString.length)
|
||||
}
|
||||
} else {
|
||||
textRange = TextRange(newString.length)
|
||||
}
|
||||
|
||||
return lastInputTextFieldValue.copy(
|
||||
text = newString.toString(),
|
||||
selection = textRange
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
package com.sukisu.ultra.ui.component.profile
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.SwitchItem
|
||||
import com.sukisu.ultra.ui.component.EditText
|
||||
import top.yukonga.miuix.kmp.extra.SuperSwitch
|
||||
|
||||
@Composable
|
||||
fun AppProfileConfig(
|
||||
@@ -21,13 +24,15 @@ fun AppProfileConfig(
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (!fixedName) {
|
||||
OutlinedTextField(
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
value = profile.name,
|
||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
||||
EditText(
|
||||
title = stringResource(R.string.profile_name),
|
||||
textValue = remember { mutableStateOf(profile.name) },
|
||||
onTextValueChange = { onProfileChange(profile.copy(name = it)) },
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
SwitchItem(
|
||||
|
||||
SuperSwitch(
|
||||
title = stringResource(R.string.profile_umount_modules),
|
||||
summary = stringResource(R.string.profile_umount_modules_summary),
|
||||
checked = if (enabled) {
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
package com.sukisu.ultra.ui.component.profile
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.input.InputDialog
|
||||
import com.maxkeppeler.sheets.input.models.*
|
||||
import com.maxkeppeler.sheets.list.ListDialog
|
||||
import com.maxkeppeler.sheets.list.models.ListOption
|
||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.profile.Capabilities
|
||||
import com.sukisu.ultra.profile.Groups
|
||||
import com.sukisu.ultra.ui.component.rememberCustomDialog
|
||||
import com.sukisu.ultra.ui.component.SuperEditArrow
|
||||
import com.sukisu.ultra.ui.util.isSepolicyValid
|
||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.extra.CheckboxLocation
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||
import top.yukonga.miuix.kmp.extra.SuperCheckbox
|
||||
import top.yukonga.miuix.kmp.extra.SuperDialog
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RootProfileConfig(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -36,94 +48,49 @@ fun RootProfileConfig(
|
||||
profile: Natives.Profile,
|
||||
onProfileChange: (Natives.Profile) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
if (!fixedName) {
|
||||
OutlinedTextField(
|
||||
label = { Text(stringResource(R.string.profile_name)) },
|
||||
TextField(
|
||||
label = stringResource(R.string.profile_name),
|
||||
value = profile.name,
|
||||
onValueChange = { onProfileChange(profile.copy(name = it)) }
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val currentNamespace = when (profile.namespace) {
|
||||
Natives.Profile.Namespace.INHERITED.ordinal -> stringResource(R.string.profile_namespace_inherited)
|
||||
Natives.Profile.Namespace.GLOBAL.ordinal -> stringResource(R.string.profile_namespace_global)
|
||||
Natives.Profile.Namespace.INDIVIDUAL.ordinal -> stringResource(R.string.profile_namespace_individual)
|
||||
else -> stringResource(R.string.profile_namespace_inherited)
|
||||
}
|
||||
ListItem(headlineContent = {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.profile_namespace)) },
|
||||
value = currentNamespace,
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
||||
},
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_inherited)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INHERITED.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_global)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.GLOBAL.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.profile_namespace_individual)) },
|
||||
onClick = {
|
||||
onProfileChange(profile.copy(namespace = Natives.Profile.Namespace.INDIVIDUAL.ordinal))
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
*/
|
||||
|
||||
UidPanel(uid = profile.uid, label = "uid", onUidChange = {
|
||||
SuperEditArrow(
|
||||
title = "UID",
|
||||
defaultValue = profile.uid,
|
||||
) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
uid = it,
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
UidPanel(uid = profile.gid, label = "gid", onUidChange = {
|
||||
}
|
||||
|
||||
SuperEditArrow(
|
||||
title = "GID",
|
||||
defaultValue = profile.gid,
|
||||
) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
gid = it,
|
||||
rootUseDefault = false
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
val selectedGroups = profile.groups.ifEmpty { listOf(0) }.let { e ->
|
||||
e.mapNotNull { g ->
|
||||
Groups.entries.find { it.gid == g }
|
||||
}
|
||||
}
|
||||
|
||||
GroupsPanel(selectedGroups) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
@@ -155,15 +122,15 @@ fun RootProfileConfig(
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>) -> Unit) {
|
||||
val selectGroupsDialog = rememberCustomDialog { dismiss: () -> Unit ->
|
||||
val groups = Groups.entries.toTypedArray().sortedWith(
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
val groups = remember {
|
||||
Groups.entries.toTypedArray().sortedWith(
|
||||
compareBy<Groups> { if (selected.contains(it)) 0 else 1 }
|
||||
.then(compareBy {
|
||||
when (it) {
|
||||
@@ -174,308 +141,257 @@ fun GroupsPanel(selected: List<Groups>, closeSelection: (selection: Set<Groups>)
|
||||
}
|
||||
})
|
||||
.then(compareBy { it.name })
|
||||
|
||||
)
|
||||
val options = groups.map { value ->
|
||||
ListOption(
|
||||
titleText = value.display,
|
||||
subtitleText = value.desc,
|
||||
selected = selected.contains(value),
|
||||
)
|
||||
}
|
||||
|
||||
val selection = HashSet(selected)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
ListDialog(
|
||||
state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
||||
closeSelection(selection)
|
||||
}, onCloseRequest = {
|
||||
dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.profile_groups),
|
||||
),
|
||||
selection = ListSelection.Multiple(
|
||||
showCheckBoxes = true,
|
||||
options = options,
|
||||
maxChoices = 32, // Kernel only supports 32 groups at most
|
||||
) { indecies, _ ->
|
||||
// Handle selection
|
||||
selection.clear()
|
||||
indecies.forEach { index ->
|
||||
val group = groups[index]
|
||||
selection.add(group)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val currentSelection = remember { mutableStateOf(selected.toSet()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
selectGroupsDialog.show()
|
||||
}
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.profile_groups))
|
||||
FlowRow {
|
||||
selected.forEach { group ->
|
||||
AssistChip(
|
||||
modifier = Modifier.padding(3.dp),
|
||||
onClick = { /*TODO*/ },
|
||||
label = { Text(group.display) })
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = stringResource(R.string.profile_groups),
|
||||
summary = "${currentSelection.value.size} / 32",
|
||||
insideMargin = DpSize(0.dp, 24.dp),
|
||||
onDismissRequest = { showDialog.value = false }
|
||||
) {
|
||||
Column(modifier = Modifier.heightIn(max = 500.dp)) {
|
||||
LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
|
||||
items(groups) { group ->
|
||||
SuperCheckbox(
|
||||
title = group.display,
|
||||
summary = group.desc,
|
||||
insideMargin = PaddingValues(horizontal = 30.dp, vertical = 16.dp),
|
||||
checkboxLocation = CheckboxLocation.Right,
|
||||
checked = currentSelection.value.contains(group),
|
||||
holdDownState = currentSelection.value.contains(group),
|
||||
onCheckedChange = { isChecked ->
|
||||
val newSelection = currentSelection.value.toMutableSet()
|
||||
if (isChecked) {
|
||||
if (newSelection.size < 32) newSelection.add(group)
|
||||
} else {
|
||||
newSelection.remove(group)
|
||||
}
|
||||
currentSelection.value = newSelection
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
currentSelection.value = selected.toSet()
|
||||
showDialog.value = false
|
||||
},
|
||||
text = stringResource(android.R.string.cancel),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
closeSelection(currentSelection.value)
|
||||
showDialog.value = false
|
||||
},
|
||||
text = stringResource(R.string.confirm),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val tag = if (selected.isEmpty()) {
|
||||
"None"
|
||||
} else {
|
||||
selected.joinToString(separator = ",", transform = { it.display })
|
||||
}
|
||||
SuperArrow(
|
||||
title = stringResource(R.string.profile_groups),
|
||||
summary = tag,
|
||||
onClick = {
|
||||
showDialog.value = true
|
||||
},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CapsPanel(
|
||||
selected: Collection<Capabilities>,
|
||||
closeSelection: (selection: Set<Capabilities>) -> Unit
|
||||
) {
|
||||
val selectCapabilitiesDialog = rememberCustomDialog { dismiss ->
|
||||
val caps = Capabilities.entries.toTypedArray().sortedWith(
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
val caps = remember {
|
||||
Capabilities.entries.toTypedArray().sortedWith(
|
||||
compareBy<Capabilities> { if (selected.contains(it)) 0 else 1 }
|
||||
.then(compareBy { it.name })
|
||||
)
|
||||
val options = caps.map { value ->
|
||||
ListOption(
|
||||
titleText = value.display,
|
||||
subtitleText = value.desc,
|
||||
selected = selected.contains(value),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val selection = HashSet(selected)
|
||||
val currentSelection = remember { mutableStateOf(selected.toSet()) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
ListDialog(
|
||||
state = rememberUseCaseState(visible = true, onFinishedRequest = {
|
||||
closeSelection(selection)
|
||||
}, onCloseRequest = {
|
||||
dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.profile_capabilities),
|
||||
),
|
||||
selection = ListSelection.Multiple(
|
||||
showCheckBoxes = true,
|
||||
options = options
|
||||
) { indecies, _ ->
|
||||
// Handle selection
|
||||
selection.clear()
|
||||
indecies.forEach { index ->
|
||||
val group = caps[index]
|
||||
selection.add(group)
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = stringResource(R.string.profile_capabilities),
|
||||
insideMargin = DpSize(0.dp, 24.dp),
|
||||
onDismissRequest = { showDialog.value = false },
|
||||
content = {
|
||||
Column(modifier = Modifier.heightIn(max = 500.dp)) {
|
||||
LazyColumn(modifier = Modifier.weight(1f, fill = false)) {
|
||||
items(caps) { cap ->
|
||||
SuperCheckbox(
|
||||
title = cap.display,
|
||||
summary = cap.desc,
|
||||
insideMargin = PaddingValues(horizontal = 30.dp, vertical = 16.dp),
|
||||
checkboxLocation = CheckboxLocation.Right,
|
||||
checked = currentSelection.value.contains(cap),
|
||||
holdDownState = currentSelection.value.contains(cap),
|
||||
onCheckedChange = { isChecked ->
|
||||
val newSelection = currentSelection.value.toMutableSet()
|
||||
if (isChecked) {
|
||||
newSelection.add(cap)
|
||||
} else {
|
||||
newSelection.remove(cap)
|
||||
}
|
||||
currentSelection.value = newSelection
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable {
|
||||
selectCapabilitiesDialog.show()
|
||||
}
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.profile_capabilities))
|
||||
FlowRow {
|
||||
selected.forEach { group ->
|
||||
AssistChip(
|
||||
modifier = Modifier.padding(3.dp),
|
||||
onClick = { /*TODO*/ },
|
||||
label = { Text(group.display) })
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDialog.value = false
|
||||
currentSelection.value = selected.toSet()
|
||||
},
|
||||
text = stringResource(android.R.string.cancel),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
closeSelection(currentSelection.value)
|
||||
showDialog.value = false
|
||||
},
|
||||
text = stringResource(R.string.confirm),
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val tag = if (selected.isEmpty()) {
|
||||
"None"
|
||||
} else {
|
||||
selected.joinToString(separator = ",", transform = { it.display })
|
||||
}
|
||||
SuperArrow(
|
||||
title = stringResource(R.string.profile_capabilities),
|
||||
summary = tag,
|
||||
onClick = {
|
||||
showDialog.value = true
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UidPanel(uid: Int, label: String, onUidChange: (Int) -> Unit) {
|
||||
|
||||
ListItem(headlineContent = {
|
||||
var isError by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var lastValidUid by remember {
|
||||
mutableIntStateOf(uid)
|
||||
}
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
value = uid.toString(),
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
}),
|
||||
onValueChange = {
|
||||
if (it.isEmpty()) {
|
||||
onUidChange(0)
|
||||
return@OutlinedTextField
|
||||
}
|
||||
val valid = isTextValidUid(it)
|
||||
|
||||
val targetUid = if (valid) it.toInt() else lastValidUid
|
||||
if (valid) {
|
||||
lastValidUid = it.toInt()
|
||||
}
|
||||
|
||||
onUidChange(targetUid)
|
||||
|
||||
isError = !valid
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SELinuxPanel(
|
||||
profile: Natives.Profile,
|
||||
onSELinuxChange: (domain: String, rules: String) -> Unit
|
||||
) {
|
||||
val editSELinuxDialog = rememberCustomDialog { dismiss ->
|
||||
var domain by remember { mutableStateOf(profile.context) }
|
||||
var rules by remember { mutableStateOf(profile.rules) }
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
|
||||
val inputOptions = listOf(
|
||||
InputTextField(
|
||||
text = domain,
|
||||
header = InputHeader(
|
||||
title = stringResource(id = R.string.profile_selinux_domain),
|
||||
),
|
||||
type = InputTextFieldType.OUTLINED,
|
||||
required = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
resultListener = {
|
||||
domain = it ?: ""
|
||||
},
|
||||
validationListener = { value ->
|
||||
// value can be a-zA-Z0-9_
|
||||
val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$")
|
||||
if (value?.matches(regex) == true) ValidationResult.Valid
|
||||
else ValidationResult.Invalid("Domain must be in the format of \"user:role:type:level\"")
|
||||
}
|
||||
),
|
||||
InputTextField(
|
||||
text = rules,
|
||||
header = InputHeader(
|
||||
title = stringResource(id = R.string.profile_selinux_rules),
|
||||
),
|
||||
type = InputTextFieldType.OUTLINED,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
singleLine = false,
|
||||
resultListener = {
|
||||
rules = it ?: ""
|
||||
},
|
||||
validationListener = { value ->
|
||||
if (isSepolicyValid(value)) ValidationResult.Valid
|
||||
else ValidationResult.Invalid("SELinux rules is invalid!")
|
||||
}
|
||||
)
|
||||
)
|
||||
var domain by remember { mutableStateOf(profile.context) }
|
||||
var rules by remember { mutableStateOf(profile.rules) }
|
||||
|
||||
val isDomainValid = remember(domain) {
|
||||
val regex = Regex("^[a-z_]+:[a-z0-9_]+:[a-z0-9_]+(:[a-z0-9_]+)?$")
|
||||
domain.matches(regex)
|
||||
}
|
||||
val isRulesValid = remember(rules) { isSepolicyValid(rules) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = MaterialTheme.colorScheme.copy(
|
||||
surface = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
InputDialog(
|
||||
state = rememberUseCaseState(
|
||||
visible = true,
|
||||
onFinishedRequest = {
|
||||
onSELinuxChange(domain, rules)
|
||||
},
|
||||
onCloseRequest = {
|
||||
dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.profile_selinux_context),
|
||||
),
|
||||
selection = InputSelection(
|
||||
input = inputOptions,
|
||||
onPositiveClick = { result ->
|
||||
// Handle selection
|
||||
SuperDialog(
|
||||
show = showDialog,
|
||||
title = stringResource(R.string.profile_selinux_context),
|
||||
onDismissRequest = { showDialog.value = false }
|
||||
) {
|
||||
Column(modifier = Modifier.heightIn(max = 500.dp)) {
|
||||
Column(modifier = Modifier.weight(1f, fill = false)) {
|
||||
TextField(
|
||||
value = domain,
|
||||
onValueChange = { domain = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
label = stringResource(id = R.string.profile_selinux_domain),
|
||||
backgroundColor = colorScheme.surfaceContainer,
|
||||
borderColor = if (isDomainValid) {
|
||||
colorScheme.primary
|
||||
} else {
|
||||
Color.Red.copy(alpha = if (isSystemInDarkTheme()) 0.3f else 0.6f)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
)
|
||||
TextField(
|
||||
value = rules,
|
||||
onValueChange = { rules = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
label = stringResource(id = R.string.profile_selinux_rules),
|
||||
backgroundColor = colorScheme.surfaceContainer,
|
||||
borderColor = if (isRulesValid) {
|
||||
colorScheme.primary
|
||||
} else {
|
||||
Color.Red.copy(alpha = if (isSystemInDarkTheme()) 0.3f else 0.6f)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
singleLine = false
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { showDialog.value = false },
|
||||
text = stringResource(android.R.string.cancel),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSELinuxChange(domain, rules)
|
||||
showDialog.value = false
|
||||
},
|
||||
text = stringResource(R.string.confirm),
|
||||
enabled = isDomainValid && isRulesValid,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.textButtonColorsPrimary()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(headlineContent = {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
editSELinuxDialog.show()
|
||||
},
|
||||
enabled = false,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledBorderColor = MaterialTheme.colorScheme.outline,
|
||||
disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
label = { Text(text = stringResource(R.string.profile_selinux_context)) },
|
||||
value = profile.context,
|
||||
onValueChange = { }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RootProfileConfigPreview() {
|
||||
var profile by remember { mutableStateOf(Natives.Profile("")) }
|
||||
RootProfileConfig(fixedName = true, profile = profile) {
|
||||
profile = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTextValidUid(text: String): Boolean {
|
||||
return text.isNotEmpty() && text.isDigitsOnly() && text.toInt() >= 0
|
||||
SuperArrow(
|
||||
title = stringResource(R.string.profile_selinux_context),
|
||||
summary = profile.context,
|
||||
onClick = { showDialog.value = true }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,105 +1,94 @@
|
||||
package com.sukisu.ultra.ui.component.profile
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ReadMore
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ArrowDropUp
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.icons.rounded.Create
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.SuperDropdown
|
||||
import com.sukisu.ultra.ui.util.listAppProfileTemplates
|
||||
import com.sukisu.ultra.ui.util.setSepolicy
|
||||
import com.sukisu.ultra.ui.viewmodel.getTemplateInfoById
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/10/21.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TemplateConfig(
|
||||
modifier: Modifier = Modifier,
|
||||
profile: Natives.Profile,
|
||||
onViewTemplate: (id: String) -> Unit = {},
|
||||
onManageTemplate: () -> Unit = {},
|
||||
onProfileChange: (Natives.Profile) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var template by rememberSaveable {
|
||||
mutableStateOf(profile.rootTemplate ?: "")
|
||||
}
|
||||
val profileTemplates = listAppProfileTemplates()
|
||||
val noTemplates = profileTemplates.isEmpty()
|
||||
|
||||
ListItem(headlineContent = {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
.fillMaxWidth(),
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.profile_template)) },
|
||||
value = template.ifEmpty { "None" },
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
if (noTemplates) {
|
||||
IconButton(
|
||||
onClick = onManageTemplate
|
||||
) {
|
||||
Icon(Icons.Filled.Create, null)
|
||||
}
|
||||
} else if (expanded) Icon(Icons.Filled.ArrowDropUp, null)
|
||||
else Icon(Icons.Filled.ArrowDropDown, null)
|
||||
if (noTemplates) {
|
||||
SuperArrow(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.app_profile_template_create),
|
||||
leftAction = {
|
||||
Icon(
|
||||
Icons.Rounded.Create,
|
||||
null,
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
tint = MiuixTheme.colorScheme.onBackground
|
||||
)
|
||||
},
|
||||
onClick = onManageTemplate,
|
||||
)
|
||||
} else {
|
||||
var template by rememberSaveable { mutableStateOf(profile.rootTemplate ?: profileTemplates[0]) }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
SuperDropdown(
|
||||
title = stringResource(R.string.profile_template),
|
||||
items = profileTemplates,
|
||||
selectedIndex = profileTemplates.indexOf(template).takeIf { it >= 0 } ?: 0,
|
||||
onSelectedIndexChange = { index ->
|
||||
if (index < 0 || index >= profileTemplates.size) return@SuperDropdown
|
||||
template = profileTemplates[index]
|
||||
val templateInfo = getTemplateInfoById(template)
|
||||
if (templateInfo != null && setSepolicy(template, templateInfo.rules.joinToString("\n"))) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
rootTemplate = template,
|
||||
rootUseDefault = false,
|
||||
uid = templateInfo.uid,
|
||||
gid = templateInfo.gid,
|
||||
groups = templateInfo.groups,
|
||||
capabilities = templateInfo.capabilities,
|
||||
context = templateInfo.context,
|
||||
namespace = templateInfo.namespace,
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
expanded = !expanded
|
||||
},
|
||||
maxHeight = 280.dp
|
||||
)
|
||||
SuperArrow(
|
||||
title = stringResource(R.string.app_profile_template_view),
|
||||
onClick = { onViewTemplate(template) }
|
||||
)
|
||||
if (profileTemplates.isEmpty()) {
|
||||
return@ExposedDropdownMenuBox
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
profileTemplates.forEach { tid ->
|
||||
val templateInfo =
|
||||
getTemplateInfoById(tid) ?: return@forEach
|
||||
DropdownMenuItem(
|
||||
text = { Text(tid) },
|
||||
onClick = {
|
||||
template = tid
|
||||
if (setSepolicy(tid, templateInfo.rules.joinToString("\n"))) {
|
||||
onProfileChange(
|
||||
profile.copy(
|
||||
rootTemplate = tid,
|
||||
rootUseDefault = false,
|
||||
uid = templateInfo.uid,
|
||||
gid = templateInfo.gid,
|
||||
groups = templateInfo.groups,
|
||||
capabilities = templateInfo.capabilities,
|
||||
context = templateInfo.context,
|
||||
namespace = templateInfo.namespace,
|
||||
)
|
||||
)
|
||||
}
|
||||
expanded = false
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
onViewTemplate(tid)
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ReadMore, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.sukisu.ultra.ui.component
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.screen.RebootDropdownItem
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.ListPopup
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Reboot
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
|
||||
@Composable
|
||||
fun RebootListPopup(
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: PopupPositionProvider.Align = PopupPositionProvider.Align.TopRight
|
||||
) {
|
||||
val showTopPopup = remember { mutableStateOf(false) }
|
||||
KsuIsValid {
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
onClick = { showTopPopup.value = true },
|
||||
holdDownState = showTopPopup.value
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Reboot,
|
||||
contentDescription = stringResource(id = R.string.reboot),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
ListPopup(
|
||||
show = showTopPopup,
|
||||
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||
alignment = alignment,
|
||||
onDismissRequest = {
|
||||
showTopPopup.value = false
|
||||
}
|
||||
) {
|
||||
val pm = LocalContext.current.getSystemService(Context.POWER_SERVICE) as PowerManager?
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val isRebootingUserspaceSupported =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && pm?.isRebootingUserspaceSupported == true
|
||||
|
||||
ListPopupColumn {
|
||||
val rebootOptions = mutableListOf(
|
||||
Pair(R.string.reboot, ""),
|
||||
Pair(R.string.reboot_recovery, "recovery"),
|
||||
Pair(R.string.reboot_bootloader, "bootloader"),
|
||||
Pair(R.string.reboot_download, "download"),
|
||||
Pair(R.string.reboot_edl, "edl")
|
||||
)
|
||||
if (isRebootingUserspaceSupported) {
|
||||
rebootOptions.add(1, Pair(R.string.reboot_userspace, "userspace"))
|
||||
}
|
||||
rebootOptions.forEachIndexed { idx, (id, reason) ->
|
||||
RebootDropdownItem(
|
||||
id = id,
|
||||
reason = reason,
|
||||
showTopPopup = showTopPopup,
|
||||
optionSize = rebootOptions.size,
|
||||
index = idx
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
207
manager/app/src/main/java/com/sukisu/ultra/ui/screen/About.kt
Normal file
207
manager/app/src/main/java/com/sukisu/ultra/ui/screen/About.kt
Normal file
@@ -0,0 +1,207 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.add
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.captionBar
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.FixedScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.dropUnlessResumed
|
||||
import com.kyant.capsule.ContinuousRoundedRectangle
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import com.sukisu.ultra.BuildConfig
|
||||
import com.sukisu.ultra.R
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.extra.SuperArrow
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun AboutScreen(navigator: DestinationsNavigator) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
val hazeState = remember { HazeState() }
|
||||
val hazeStyle = HazeStyle(
|
||||
backgroundColor = colorScheme.background,
|
||||
tint = HazeTint(colorScheme.background.copy(0.8f))
|
||||
)
|
||||
|
||||
val htmlString = stringResource(
|
||||
id = R.string.about_source_code,
|
||||
"<b><a href=\"https://github.com/ShirkNeko/SukiSU-Ultra\">GitHub</a></b>",
|
||||
"<b><a href=\"https://t.me/SukiKSU\">Telegram</a></b>",
|
||||
"<b>怡子曰曰</b>",
|
||||
"<b>明风 OuO</b>",
|
||||
"<b><a href=\"https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode.txt\">CC BY-NC-SA 4.0</a></b>"
|
||||
)
|
||||
val result = extractLinks(htmlString)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
modifier = Modifier.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
},
|
||||
color = Color.Transparent,
|
||||
title = stringResource(R.string.about),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = dropUnlessResumed { navigator.popBackStack() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
popupHost = { },
|
||||
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.height(getWindowSize().height.dp)
|
||||
.overScrollVertical()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.hazeSource(state = hazeState)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = innerPadding,
|
||||
overscrollEffect = null,
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(vertical = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(ContinuousRoundedRectangle(16.dp))
|
||||
.background(Color.White)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground),
|
||||
contentDescription = "icon",
|
||||
contentScale = FixedScale(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
text = stringResource(id = R.string.app_name),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 26.sp
|
||||
)
|
||||
Text(
|
||||
text = BuildConfig.VERSION_NAME,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
) {
|
||||
result.forEach {
|
||||
SuperArrow(
|
||||
title = it.fullText,
|
||||
onClick = {
|
||||
uriHandler.openUri(it.url)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LinkInfo(
|
||||
val fullText: String,
|
||||
val url: String
|
||||
)
|
||||
|
||||
fun extractLinks(html: String): List<LinkInfo> {
|
||||
val regex = Regex(
|
||||
"""([^<>\n\r]+?)\s*<b>\s*<a\b[^>]*\bhref\s*=\s*(['"]?)([^'"\s>]+)\2[^>]*>([^<]+)</a>\s*</b>\s*(.*?)\s*(?=<br|\n|$)""",
|
||||
RegexOption.MULTILINE
|
||||
)
|
||||
|
||||
Log.d("ggc", "extractLinks: $html")
|
||||
|
||||
return regex.findAll(html).mapNotNull { match ->
|
||||
try {
|
||||
val before = match.groupValues[1].trim()
|
||||
val url = match.groupValues[3].trim()
|
||||
val title = match.groupValues[4].trim()
|
||||
val after = match.groupValues[5].trim()
|
||||
|
||||
val fullText = "$before $title $after"
|
||||
Log.d("ggc", "extractLinks: $fullText -> $url")
|
||||
LinkInfo(fullText, url)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ggc", "匹配失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}.toList()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.ramcosta.composedestinations.generated.destinations.*
|
||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
||||
import com.sukisu.ultra.R
|
||||
|
||||
enum class BottomBarDestination(
|
||||
val direction: DirectionDestinationSpec,
|
||||
@param:StringRes val label: Int,
|
||||
val iconSelected: ImageVector,
|
||||
val iconNotSelected: ImageVector,
|
||||
val rootRequired: Boolean,
|
||||
) {
|
||||
Home(HomeScreenDestination, R.string.home, Icons.Filled.Home, Icons.Outlined.Home, false),
|
||||
Kpm(KpmScreenDestination, R.string.kpm_title, Icons.Filled.Archive, Icons.Outlined.Archive, true),
|
||||
SuperUser(SuperUserScreenDestination, R.string.superuser, Icons.Filled.AdminPanelSettings, Icons.Outlined.AdminPanelSettings, true),
|
||||
Module(ModuleScreenDestination, R.string.module, Icons.Filled.Extension, Icons.Outlined.Extension, true),
|
||||
Settings(SettingScreenDestination, R.string.settings, Icons.Filled.Settings, Icons.Outlined.Settings, false),
|
||||
}
|
||||
@@ -1,50 +1,86 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.add
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.captionBar
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.dropUnlessResumed
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
||||
import com.sukisu.ultra.ui.util.runModuleAction
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||
import com.sukisu.ultra.ui.util.runModuleAction
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Save
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
var tempText : String
|
||||
var tempText: String
|
||||
val logContent = rememberSaveable { StringBuilder() }
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
var isActionRunning by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
BackHandler(enabled = isActionRunning) {
|
||||
// Disable back button if action is running
|
||||
}
|
||||
var actionResult: Boolean
|
||||
val hazeState = remember { HazeState() }
|
||||
val hazeStyle = HazeStyle(
|
||||
backgroundColor = colorScheme.background,
|
||||
tint = HazeTint(colorScheme.background.copy(0.8f))
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (text.isNotEmpty()) {
|
||||
@@ -65,83 +101,110 @@ fun ExecuteModuleActionScreen(navigator: DestinationsNavigator, moduleId: String
|
||||
onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
}
|
||||
)
|
||||
).let {
|
||||
actionResult = it
|
||||
}
|
||||
}
|
||||
isActionRunning = false
|
||||
if (actionResult) navigator.popBackStack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
isActionRunning = isActionRunning,
|
||||
onBack = dropUnlessResumed {
|
||||
navigator.popBackStack()
|
||||
},
|
||||
onSave = {
|
||||
if (!isActionRunning) {
|
||||
scope.launch {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
val date = format.format(Date())
|
||||
val file = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"KernelSU_module_action_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
snackBarHost.showSnackbar("Log saved to ${file.absolutePath}")
|
||||
}
|
||||
scope.launch {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
val date = format.format(Date())
|
||||
val file = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"KernelSU_module_action_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
Toast.makeText(context, "Log saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
hazeState = hazeState,
|
||||
hazeStyle = hazeStyle,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!isActionRunning) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(text = stringResource(R.string.close)) },
|
||||
icon = { Icon(Icons.Filled.Close, contentDescription = null) },
|
||||
onClick = {
|
||||
navigator.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing,
|
||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
||||
popupHost = { },
|
||||
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.padding(innerPadding)
|
||||
.scrollEndHaptic()
|
||||
.hazeSource(state = hazeState)
|
||||
.padding(
|
||||
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
end = innerPadding.calculateStartPadding(layoutDirection),
|
||||
)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
LaunchedEffect(text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||
Text(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = text,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight,
|
||||
)
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
12.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(isActionRunning: Boolean, onSave: () -> Unit = {}) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.action)) },
|
||||
actions = {
|
||||
private fun TopBar(
|
||||
onBack: () -> Unit = {},
|
||||
onSave: () -> Unit = {},
|
||||
hazeState: HazeState,
|
||||
hazeStyle: HazeStyle,
|
||||
) {
|
||||
SmallTopAppBar(
|
||||
modifier = Modifier.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
},
|
||||
title = stringResource(R.string.action),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onSave,
|
||||
enabled = !isActionRunning
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
imageVector = MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = onSave
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Save,
|
||||
contentDescription = stringResource(id = R.string.save_log),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,257 +1,132 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.add
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.captionBar
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.dropUnlessResumed
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||
import com.ramcosta.composedestinations.generated.destinations.ModuleScreenDestination
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import com.sukisu.ultra.ui.viewmodel.ModuleViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||
import com.sukisu.ultra.ui.util.FlashResult
|
||||
import com.sukisu.ultra.ui.util.LkmSelection
|
||||
import com.sukisu.ultra.ui.util.flashModule
|
||||
import com.sukisu.ultra.ui.util.installBoot
|
||||
import com.sukisu.ultra.ui.util.reboot
|
||||
import com.sukisu.ultra.ui.util.restoreBoot
|
||||
import com.sukisu.ultra.ui.util.uninstallPermanently
|
||||
import top.yukonga.miuix.kmp.basic.FloatingActionButton
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Save
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import androidx.core.content.edit
|
||||
import com.sukisu.ultra.ui.util.module.ModuleOperationUtils
|
||||
import com.sukisu.ultra.ui.util.module.ModuleUtils
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/5/31.
|
||||
* @author weishu
|
||||
* @date 2023/1/1.
|
||||
*/
|
||||
|
||||
enum class FlashingStatus {
|
||||
FLASHING,
|
||||
SUCCESS,
|
||||
FAILED
|
||||
}
|
||||
|
||||
private var currentFlashingStatus = mutableStateOf(FlashingStatus.FLASHING)
|
||||
|
||||
// 添加模块安装状态跟踪
|
||||
data class ModuleInstallStatus(
|
||||
val totalModules: Int = 0,
|
||||
val currentModule: Int = 0,
|
||||
val currentModuleName: String = "",
|
||||
val failedModules: MutableList<String> = mutableListOf(),
|
||||
val verifiedModules: MutableList<String> = mutableListOf() // 添加已验证模块列表
|
||||
)
|
||||
|
||||
private var moduleInstallStatus = mutableStateOf(ModuleInstallStatus())
|
||||
|
||||
// 存储模块URI和验证状态的映射
|
||||
private var moduleVerificationMap = mutableMapOf<Uri, Boolean>()
|
||||
|
||||
fun setFlashingStatus(status: FlashingStatus) {
|
||||
currentFlashingStatus.value = status
|
||||
}
|
||||
|
||||
fun updateModuleInstallStatus(
|
||||
totalModules: Int? = null,
|
||||
currentModule: Int? = null,
|
||||
currentModuleName: String? = null,
|
||||
failedModule: String? = null,
|
||||
verifiedModule: String? = null
|
||||
) {
|
||||
val current = moduleInstallStatus.value
|
||||
moduleInstallStatus.value = current.copy(
|
||||
totalModules = totalModules ?: current.totalModules,
|
||||
currentModule = currentModule ?: current.currentModule,
|
||||
currentModuleName = currentModuleName ?: current.currentModuleName
|
||||
)
|
||||
|
||||
if (failedModule != null) {
|
||||
val updatedFailedModules = current.failedModules.toMutableList()
|
||||
updatedFailedModules.add(failedModule)
|
||||
moduleInstallStatus.value = moduleInstallStatus.value.copy(
|
||||
failedModules = updatedFailedModules
|
||||
)
|
||||
}
|
||||
|
||||
if (verifiedModule != null) {
|
||||
val updatedVerifiedModules = current.verifiedModules.toMutableList()
|
||||
updatedVerifiedModules.add(verifiedModule)
|
||||
moduleInstallStatus.value = moduleInstallStatus.value.copy(
|
||||
verifiedModules = updatedVerifiedModules
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setModuleVerificationStatus(uri: Uri, isVerified: Boolean) {
|
||||
moduleVerificationMap[uri] = isVerified
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val shouldAutoExit = remember {
|
||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
sharedPref.getBoolean("auto_exit_after_flash", false)
|
||||
}
|
||||
|
||||
// 是否通过从外部启动的模块安装
|
||||
val isExternalInstall = remember {
|
||||
when (flashIt) {
|
||||
is FlashIt.FlashModule -> {
|
||||
(context as? ComponentActivity)?.intent?.let { intent ->
|
||||
intent.action == Intent.ACTION_VIEW || intent.action == Intent.ACTION_SEND
|
||||
} ?: false
|
||||
// Lets you flash modules sequentially when mutiple zipUris are selected
|
||||
fun flashModulesSequentially(
|
||||
uris: List<Uri>,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
): FlashResult {
|
||||
for (uri in uris) {
|
||||
flashModule(uri, onStdout, onStderr).apply {
|
||||
if (code != 0) {
|
||||
return FlashResult(code, err, showReboot)
|
||||
}
|
||||
is FlashIt.FlashModules -> {
|
||||
(context as? ComponentActivity)?.intent?.let { intent ->
|
||||
intent.action == Intent.ACTION_VIEW || intent.action == Intent.ACTION_SEND
|
||||
} ?: false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
return FlashResult(0, "", true)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun FlashScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
flashIt: FlashIt
|
||||
) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
var tempText: String
|
||||
val logContent = rememberSaveable { StringBuilder() }
|
||||
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
||||
// 添加状态跟踪是否已经完成刷写
|
||||
var hasFlashCompleted by rememberSaveable { mutableStateOf(false) }
|
||||
var hasExecuted by rememberSaveable { mutableStateOf(false) }
|
||||
// 更新模块状态管理
|
||||
var hasUpdateExecuted by rememberSaveable { mutableStateOf(false) }
|
||||
var hasUpdateCompleted by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val viewModel: ModuleViewModel = viewModel()
|
||||
|
||||
val errorCodeString = stringResource(R.string.error_code)
|
||||
val checkLogString = stringResource(R.string.check_log)
|
||||
val logSavedString = stringResource(R.string.log_saved)
|
||||
val installingModuleString = stringResource(R.string.installing_module)
|
||||
|
||||
// 当前模块安装状态
|
||||
val currentStatus = moduleInstallStatus.value
|
||||
|
||||
// 重置状态
|
||||
LaunchedEffect(flashIt) {
|
||||
when (flashIt) {
|
||||
is FlashIt.FlashModules -> {
|
||||
if (flashIt.currentIndex == 0) {
|
||||
moduleInstallStatus.value = ModuleInstallStatus(
|
||||
totalModules = flashIt.uris.size,
|
||||
currentModule = 1
|
||||
)
|
||||
hasFlashCompleted = false
|
||||
hasExecuted = false
|
||||
moduleVerificationMap.clear()
|
||||
}
|
||||
}
|
||||
is FlashIt.FlashModuleUpdate -> {
|
||||
hasUpdateCompleted = false
|
||||
hasUpdateExecuted = false
|
||||
}
|
||||
else -> {
|
||||
hasFlashCompleted = false
|
||||
hasExecuted = false
|
||||
}
|
||||
}
|
||||
var flashing by rememberSaveable {
|
||||
mutableStateOf(FlashingStatus.FLASHING)
|
||||
}
|
||||
|
||||
// 处理更新模块安装
|
||||
LaunchedEffect(flashIt) {
|
||||
if (flashIt !is FlashIt.FlashModuleUpdate) return@LaunchedEffect
|
||||
if (hasUpdateExecuted || hasUpdateCompleted || text.isNotEmpty()) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (text.isNotEmpty()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hasUpdateExecuted = true
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
setFlashingStatus(FlashingStatus.FLASHING)
|
||||
|
||||
try {
|
||||
logContent.append(text).append("\n")
|
||||
} catch (_: Exception) {
|
||||
logContent.append(text).append("\n")
|
||||
}
|
||||
|
||||
flashModuleUpdate(flashIt.uri, onFinish = { showReboot, code ->
|
||||
if (code != 0) {
|
||||
text += "$errorCodeString $code.\n$checkLogString\n"
|
||||
setFlashingStatus(FlashingStatus.FAILED)
|
||||
} else {
|
||||
setFlashingStatus(FlashingStatus.SUCCESS)
|
||||
|
||||
// 处理模块更新成功后的验证标志
|
||||
val isVerified = moduleVerificationMap[flashIt.uri] ?: false
|
||||
ModuleOperationUtils.handleModuleUpdate(context, flashIt.uri, isVerified)
|
||||
|
||||
viewModel.markNeedRefresh()
|
||||
}
|
||||
if (showReboot) {
|
||||
text += "\n\n\n"
|
||||
showFloatAction = true
|
||||
|
||||
// 如果是内部安装,显示重启按钮后不自动返回
|
||||
if (isExternalInstall) {
|
||||
return@flashModuleUpdate
|
||||
}
|
||||
}
|
||||
hasUpdateCompleted = true
|
||||
|
||||
// 如果是外部安装或需要自动退出的模块更新且不需要重启,延迟后自动返回
|
||||
if (isExternalInstall || shouldAutoExit) {
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
if (shouldAutoExit) {
|
||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
||||
}
|
||||
(context as? ComponentActivity)?.finish()
|
||||
}
|
||||
}
|
||||
}, onStdout = {
|
||||
flashIt(flashIt, onStdout = {
|
||||
tempText = "$it\n"
|
||||
if (tempText.startsWith("[H[J")) { // clear command
|
||||
text = tempText.substring(6)
|
||||
@@ -261,156 +136,24 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
logContent.append(it).append("\n")
|
||||
}, onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 安装但排除更新模块
|
||||
LaunchedEffect(flashIt) {
|
||||
if (flashIt is FlashIt.FlashModuleUpdate) return@LaunchedEffect
|
||||
if (hasExecuted || hasFlashCompleted || text.isNotEmpty()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
hasExecuted = true
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
setFlashingStatus(FlashingStatus.FLASHING)
|
||||
|
||||
if (flashIt is FlashIt.FlashModules) {
|
||||
try {
|
||||
val currentUri = flashIt.uris[flashIt.currentIndex]
|
||||
val moduleName = getModuleNameFromUri(context, currentUri)
|
||||
updateModuleInstallStatus(
|
||||
currentModuleName = moduleName
|
||||
)
|
||||
text = installingModuleString.format(flashIt.currentIndex + 1, flashIt.uris.size, moduleName)
|
||||
logContent.append(text).append("\n")
|
||||
} catch (_: Exception) {
|
||||
text = installingModuleString.format(flashIt.currentIndex + 1, flashIt.uris.size, "Module")
|
||||
logContent.append(text).append("\n")
|
||||
}
|
||||
}
|
||||
|
||||
flashIt(flashIt, onFinish = { showReboot, code ->
|
||||
}).apply {
|
||||
if (code != 0) {
|
||||
text += "$errorCodeString $code.\n$checkLogString\n"
|
||||
setFlashingStatus(FlashingStatus.FAILED)
|
||||
|
||||
if (flashIt is FlashIt.FlashModules) {
|
||||
updateModuleInstallStatus(
|
||||
failedModule = moduleInstallStatus.value.currentModuleName
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setFlashingStatus(FlashingStatus.SUCCESS)
|
||||
|
||||
// 处理模块安装成功后的验证标志
|
||||
when (flashIt) {
|
||||
is FlashIt.FlashModule -> {
|
||||
val isVerified = moduleVerificationMap[flashIt.uri] ?: false
|
||||
ModuleOperationUtils.handleModuleInstallSuccess(context, flashIt.uri, isVerified)
|
||||
if (isVerified) {
|
||||
updateModuleInstallStatus(verifiedModule = moduleInstallStatus.value.currentModuleName)
|
||||
}
|
||||
}
|
||||
is FlashIt.FlashModules -> {
|
||||
val currentUri = flashIt.uris[flashIt.currentIndex]
|
||||
val isVerified = moduleVerificationMap[currentUri] ?: false
|
||||
ModuleOperationUtils.handleModuleInstallSuccess(context, currentUri, isVerified)
|
||||
if (isVerified) {
|
||||
updateModuleInstallStatus(verifiedModule = moduleInstallStatus.value.currentModuleName)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
viewModel.markNeedRefresh()
|
||||
text += "Error code: $code.\n $err Please save and check the log.\n"
|
||||
}
|
||||
if (showReboot) {
|
||||
text += "\n\n\n"
|
||||
showFloatAction = true
|
||||
}
|
||||
|
||||
hasFlashCompleted = true
|
||||
|
||||
if (flashIt is FlashIt.FlashModules && flashIt.currentIndex < flashIt.uris.size - 1) {
|
||||
val nextFlashIt = flashIt.copy(
|
||||
currentIndex = flashIt.currentIndex + 1
|
||||
)
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(500)
|
||||
navigator.navigate(FlashScreenDestination(nextFlashIt))
|
||||
}
|
||||
} else if ((isExternalInstall || shouldAutoExit) && flashIt is FlashIt.FlashModules && flashIt.currentIndex >= flashIt.uris.size - 1) {
|
||||
// 如果是外部安装或需要自动退出且是最后一个模块,安装完成后自动返回
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
if (shouldAutoExit) {
|
||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
||||
}
|
||||
(context as? ComponentActivity)?.finish()
|
||||
}
|
||||
} else if ((isExternalInstall || shouldAutoExit) && flashIt is FlashIt.FlashModule) {
|
||||
// 如果是外部安装或需要自动退出的单个模块,安装完成后自动返回
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(1000)
|
||||
if (shouldAutoExit) {
|
||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
||||
}
|
||||
(context as? ComponentActivity)?.finish()
|
||||
}
|
||||
}
|
||||
}, onStdout = {
|
||||
tempText = "$it\n"
|
||||
if (tempText.startsWith("[H[J")) { // clear command
|
||||
text = tempText.substring(6)
|
||||
} else {
|
||||
text += tempText
|
||||
}
|
||||
logContent.append(it).append("\n")
|
||||
}, onStderr = {
|
||||
logContent.append(it).append("\n")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
val onBack: () -> Unit = {
|
||||
val canGoBack = when (flashIt) {
|
||||
is FlashIt.FlashModuleUpdate -> currentFlashingStatus.value != FlashingStatus.FLASHING
|
||||
else -> currentFlashingStatus.value != FlashingStatus.FLASHING
|
||||
}
|
||||
|
||||
if (canGoBack) {
|
||||
if (isExternalInstall) {
|
||||
(context as? ComponentActivity)?.finish()
|
||||
} else {
|
||||
if (flashIt is FlashIt.FlashModules || flashIt is FlashIt.FlashModuleUpdate) {
|
||||
viewModel.markNeedRefresh()
|
||||
viewModel.fetchModuleList()
|
||||
navigator.navigate(ModuleScreenDestination)
|
||||
} else {
|
||||
viewModel.markNeedRefresh()
|
||||
viewModel.fetchModuleList()
|
||||
navigator.popBackStack()
|
||||
}
|
||||
flashing = if (code == 0) FlashingStatus.SUCCESS else FlashingStatus.FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
onBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
currentFlashingStatus.value,
|
||||
currentStatus,
|
||||
onBack = onBack,
|
||||
flashing,
|
||||
onBack = dropUnlessResumed { navigator.popBackStack() },
|
||||
onSave = {
|
||||
scope.launch {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
@@ -420,15 +163,22 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
"KernelSU_install_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
snackBarHost.showSnackbar(logSavedString.format(file.absolutePath))
|
||||
Toast.makeText(context, "Log saved to ${file.absolutePath}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (showFloatAction) {
|
||||
ExtendedFloatingActionButton(
|
||||
val reboot = stringResource(id = R.string.reboot)
|
||||
FloatingActionButton(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding() + 20.dp,
|
||||
end = 20.dp
|
||||
)
|
||||
.border(0.05.dp, colorScheme.outline.copy(alpha = 0.5f), CircleShape),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -436,25 +186,22 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
shadowElevation = 0.dp,
|
||||
content = {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(id = R.string.reboot)
|
||||
Icons.Rounded.Refresh,
|
||||
reboot,
|
||||
Modifier.size(40.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(id = R.string.reboot))
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
expanded = true
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
popupHost = { },
|
||||
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
@@ -462,307 +209,107 @@ fun FlashScreen(navigator: DestinationsNavigator, flashIt: FlashIt) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
.scrollEndHaptic()
|
||||
.padding(
|
||||
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
end = innerPadding.calculateStartPadding(layoutDirection),
|
||||
)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
if (flashIt is FlashIt.FlashModules) {
|
||||
ModuleInstallProgressBar(
|
||||
currentIndex = flashIt.currentIndex + 1,
|
||||
totalCount = flashIt.uris.size,
|
||||
currentModuleName = currentStatus.currentModuleName,
|
||||
status = currentFlashingStatus.value,
|
||||
failedModules = currentStatus.failedModules
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LaunchedEffect(text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
LaunchedEffect(text) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显示模块安装进度条和状态
|
||||
@Composable
|
||||
fun ModuleInstallProgressBar(
|
||||
currentIndex: Int,
|
||||
totalCount: Int,
|
||||
currentModuleName: String,
|
||||
status: FlashingStatus,
|
||||
failedModules: List<String>
|
||||
) {
|
||||
val progressColor = when(status) {
|
||||
FlashingStatus.FLASHING -> MaterialTheme.colorScheme.primary
|
||||
FlashingStatus.SUCCESS -> MaterialTheme.colorScheme.tertiary
|
||||
FlashingStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
val progress = animateFloatAsState(
|
||||
targetValue = currentIndex.toFloat() / totalCount.toFloat(),
|
||||
label = "InstallProgress"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 模块名称和进度
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = currentModuleName.ifEmpty { stringResource(R.string.module) },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "$currentIndex/$totalCount",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 进度条
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.value },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp),
|
||||
color = progressColor,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||
Text(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 失败模块列表
|
||||
AnimatedVisibility(
|
||||
visible = failedModules.isNotEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.module_failed_count, failedModules.size),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// 失败模块列表
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
failedModules.forEach { moduleName ->
|
||||
Text(
|
||||
text = "• $moduleName",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
status: FlashingStatus,
|
||||
moduleStatus: ModuleInstallStatus = ModuleInstallStatus(),
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
||||
colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
colorScheme.background
|
||||
}
|
||||
val cardAlpha = CardConfig.cardAlpha
|
||||
|
||||
val statusColor = when(status) {
|
||||
FlashingStatus.FLASHING -> MaterialTheme.colorScheme.primary
|
||||
FlashingStatus.SUCCESS -> MaterialTheme.colorScheme.tertiary
|
||||
FlashingStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(
|
||||
when (status) {
|
||||
FlashingStatus.FLASHING -> R.string.flashing
|
||||
FlashingStatus.SUCCESS -> R.string.flash_success
|
||||
FlashingStatus.FAILED -> R.string.flash_failed
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = statusColor
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
12.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
|
||||
if (moduleStatus.failedModules.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.module_failed_count, moduleStatus.failedModules.size),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = onSave) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
contentDescription = stringResource(id = R.string.save_log),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getModuleNameFromUri(context: Context, uri: Uri): String {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (uri == Uri.EMPTY) {
|
||||
return@withContext context.getString(R.string.unknown_module)
|
||||
}
|
||||
if (!ModuleUtils.isUriAccessible(context, uri)) {
|
||||
return@withContext context.getString(R.string.unknown_module)
|
||||
}
|
||||
ModuleUtils.extractModuleName(context, uri)
|
||||
} catch (_: Exception) {
|
||||
context.getString(R.string.unknown_module)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed class FlashIt : Parcelable {
|
||||
data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) : FlashIt()
|
||||
data class FlashModule(val uri: Uri) : FlashIt()
|
||||
data class FlashModules(val uris: List<Uri>, val currentIndex: Int = 0) : FlashIt()
|
||||
data class FlashModuleUpdate(val uri: Uri) : FlashIt() // 模块更新
|
||||
data object FlashRestore : FlashIt()
|
||||
data object FlashUninstall : FlashIt()
|
||||
}
|
||||
data class FlashBoot(val boot: Uri? = null, val lkm: LkmSelection, val ota: Boolean, val partition: String? = null) :
|
||||
FlashIt()
|
||||
|
||||
// 模块更新刷写
|
||||
fun flashModuleUpdate(
|
||||
uri: Uri,
|
||||
onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
) {
|
||||
flashModule(uri, onFinish, onStdout, onStderr)
|
||||
data class FlashModules(val uris: List<Uri>) : FlashIt()
|
||||
|
||||
data object FlashRestore : FlashIt()
|
||||
|
||||
data object FlashUninstall : FlashIt()
|
||||
}
|
||||
|
||||
fun flashIt(
|
||||
flashIt: FlashIt,
|
||||
onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
) {
|
||||
when (flashIt) {
|
||||
): FlashResult {
|
||||
return when (flashIt) {
|
||||
is FlashIt.FlashBoot -> installBoot(
|
||||
flashIt.boot,
|
||||
flashIt.lkm,
|
||||
flashIt.ota,
|
||||
flashIt.partition,
|
||||
onFinish,
|
||||
onStdout,
|
||||
onStderr
|
||||
)
|
||||
is FlashIt.FlashModule -> flashModule(flashIt.uri, onFinish, onStdout, onStderr)
|
||||
|
||||
is FlashIt.FlashModules -> {
|
||||
if (flashIt.uris.isEmpty() || flashIt.currentIndex >= flashIt.uris.size) {
|
||||
onFinish(false, 0)
|
||||
return
|
||||
}
|
||||
|
||||
val currentUri = flashIt.uris[flashIt.currentIndex]
|
||||
onStdout("\n")
|
||||
|
||||
flashModule(currentUri, onFinish, onStdout, onStderr)
|
||||
flashModulesSequentially(flashIt.uris, onStdout, onStderr)
|
||||
}
|
||||
is FlashIt.FlashModuleUpdate -> {
|
||||
onFinish(false, 0)
|
||||
}
|
||||
FlashIt.FlashRestore -> restoreBoot(onFinish, onStdout, onStderr)
|
||||
FlashIt.FlashUninstall -> uninstallPermanently(onFinish, onStdout, onStderr)
|
||||
|
||||
FlashIt.FlashRestore -> restoreBoot(onStdout, onStderr)
|
||||
|
||||
FlashIt.FlashUninstall -> uninstallPermanently(onStdout, onStderr)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun FlashScreenPreview() {
|
||||
FlashScreen(EmptyDestinationsNavigator, FlashIt.FlashUninstall)
|
||||
}
|
||||
private fun TopBar(
|
||||
status: FlashingStatus,
|
||||
onBack: () -> Unit = {},
|
||||
onSave: () -> Unit = {},
|
||||
) {
|
||||
SmallTopAppBar(
|
||||
title = stringResource(
|
||||
when (status) {
|
||||
FlashingStatus.FLASHING -> R.string.flashing
|
||||
FlashingStatus.SUCCESS -> R.string.flash_success
|
||||
FlashingStatus.FAILED -> R.string.flash_failed
|
||||
}
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) {
|
||||
Icon(
|
||||
MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = onSave
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Save,
|
||||
contentDescription = stringResource(id = R.string.save_log),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,742 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,941 +0,0 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.*
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
|
||||
import com.sukisu.ultra.ui.theme.getCardColors
|
||||
import com.sukisu.ultra.ui.theme.getCardElevation
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.*
|
||||
import java.time.format.DateTimeFormatter
|
||||
import android.os.Process.myUid
|
||||
import androidx.core.content.edit
|
||||
|
||||
private val SPACING_SMALL = 4.dp
|
||||
private val SPACING_MEDIUM = 8.dp
|
||||
private val SPACING_LARGE = 16.dp
|
||||
|
||||
private const val PAGE_SIZE = 10000
|
||||
private const val MAX_TOTAL_LOGS = 100000
|
||||
|
||||
private const val LOGS_PATCH = "/data/adb/ksu/log/sulog.log"
|
||||
|
||||
data class LogEntry(
|
||||
val timestamp: String,
|
||||
val type: LogType,
|
||||
val uid: String,
|
||||
val comm: String,
|
||||
val details: String,
|
||||
val pid: String,
|
||||
val rawLine: String
|
||||
)
|
||||
|
||||
data class LogPageInfo(
|
||||
val currentPage: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
val totalLogs: Int = 0,
|
||||
val hasMore: Boolean = false
|
||||
)
|
||||
|
||||
enum class LogType(val displayName: String, val color: Color) {
|
||||
SU_GRANT("SU_GRANT", Color(0xFF4CAF50)),
|
||||
SU_EXEC("SU_EXEC", Color(0xFF2196F3)),
|
||||
PERM_CHECK("PERM_CHECK", Color(0xFFFF9800)),
|
||||
SYSCALL("SYSCALL", Color(0xFF00BCD4)),
|
||||
MANAGER_OP("MANAGER_OP", Color(0xFF9C27B0)),
|
||||
UNKNOWN("UNKNOWN", Color(0xFF757575))
|
||||
}
|
||||
|
||||
enum class LogExclType(val displayName: String, val color: Color) {
|
||||
CURRENT_APP("Current app", Color(0xFF9E9E9E)),
|
||||
PRCTL_STAR("prctl_*", Color(0xFF00BCD4)),
|
||||
PRCTL_UNKNOWN("prctl_unknown", Color(0xFF00BCD4)),
|
||||
SETUID("setuid", Color(0xFF00BCD4))
|
||||
}
|
||||
|
||||
private val utcFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
private val localFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
private fun saveExcludedSubTypes(context: Context, types: Set<LogExclType>) {
|
||||
val prefs = context.getSharedPreferences("sulog", Context.MODE_PRIVATE)
|
||||
val nameSet = types.map { it.name }.toSet()
|
||||
prefs.edit { putStringSet("excluded_subtypes", nameSet) }
|
||||
}
|
||||
|
||||
private fun loadExcludedSubTypes(context: Context): Set<LogExclType> {
|
||||
val prefs = context.getSharedPreferences("sulog", Context.MODE_PRIVATE)
|
||||
val nameSet = prefs.getStringSet("excluded_subtypes", emptySet()) ?: emptySet()
|
||||
return nameSet.mapNotNull { name ->
|
||||
LogExclType.entries.firstOrNull { it.name == name }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun LogViewerScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var logEntries by remember { mutableStateOf<List<LogEntry>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var filterType by rememberSaveable { mutableStateOf<LogType?>(null) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var showSearchBar by rememberSaveable { mutableStateOf(false) }
|
||||
var pageInfo by remember { mutableStateOf(LogPageInfo()) }
|
||||
var lastLogFileHash by remember { mutableStateOf("") }
|
||||
val currentUid = remember { myUid().toString() }
|
||||
|
||||
val initialExcluded = remember {
|
||||
loadExcludedSubTypes(context)
|
||||
}
|
||||
|
||||
var excludedSubTypes by rememberSaveable { mutableStateOf(initialExcluded) }
|
||||
|
||||
LaunchedEffect(excludedSubTypes) {
|
||||
saveExcludedSubTypes(context, excludedSubTypes)
|
||||
}
|
||||
|
||||
val filteredEntries = remember(
|
||||
logEntries, filterType, searchQuery, excludedSubTypes
|
||||
) {
|
||||
logEntries.filter { entry ->
|
||||
val matchesSearch = searchQuery.isEmpty() ||
|
||||
entry.comm.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.details.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.uid.contains(searchQuery, ignoreCase = true)
|
||||
|
||||
// 排除本应用
|
||||
if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false
|
||||
|
||||
// 排除 SYSCALL 子类型
|
||||
if (entry.type == LogType.SYSCALL) {
|
||||
val detail = entry.details
|
||||
if (LogExclType.PRCTL_STAR in excludedSubTypes && detail.startsWith("Syscall: prctl") && !detail.startsWith("Syscall: prctl_unknown")) return@filter false
|
||||
if (LogExclType.PRCTL_UNKNOWN in excludedSubTypes && detail.startsWith("Syscall: prctl_unknown")) return@filter false
|
||||
if (LogExclType.SETUID in excludedSubTypes && detail.startsWith("Syscall: setuid")) return@filter false
|
||||
}
|
||||
|
||||
// 普通类型筛选
|
||||
val matchesFilter = filterType == null || entry.type == filterType
|
||||
matchesFilter && matchesSearch
|
||||
}
|
||||
}
|
||||
|
||||
val loadingDialog = rememberLoadingDialog()
|
||||
val confirmDialog = rememberConfirmDialog()
|
||||
|
||||
val loadPage: (Int, Boolean) -> Unit = { page, forceRefresh ->
|
||||
scope.launch {
|
||||
if (isLoading) return@launch
|
||||
|
||||
isLoading = true
|
||||
try {
|
||||
loadLogsWithPagination(
|
||||
page,
|
||||
forceRefresh,
|
||||
lastLogFileHash
|
||||
) { entries, newPageInfo, newHash ->
|
||||
logEntries = if (page == 0 || forceRefresh) {
|
||||
entries
|
||||
} else {
|
||||
logEntries + entries
|
||||
}
|
||||
pageInfo = newPageInfo
|
||||
lastLogFileHash = newHash
|
||||
}
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onManualRefresh: () -> Unit = {
|
||||
loadPage(0, true)
|
||||
}
|
||||
|
||||
val loadNextPage: () -> Unit = {
|
||||
if (pageInfo.hasMore && !isLoading) {
|
||||
loadPage(pageInfo.currentPage + 1, false)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(5_000)
|
||||
if (!isLoading) {
|
||||
scope.launch {
|
||||
val hasNewLogs = checkForNewLogs(lastLogFileHash)
|
||||
if (hasNewLogs) {
|
||||
loadPage(0, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
loadPage(0, true)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
LogViewerTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
onBackClick = { navigator.navigateUp() },
|
||||
showSearchBar = showSearchBar,
|
||||
searchQuery = searchQuery,
|
||||
onSearchQueryChange = { searchQuery = it },
|
||||
onSearchToggle = { showSearchBar = !showSearchBar },
|
||||
onRefresh = onManualRefresh,
|
||||
onClearLogs = {
|
||||
scope.launch {
|
||||
val result = confirmDialog.awaitConfirm(
|
||||
title = context.getString(R.string.log_viewer_clear_logs),
|
||||
content = context.getString(R.string.log_viewer_clear_logs_confirm)
|
||||
)
|
||||
if (result == ConfirmResult.Confirmed) {
|
||||
loadingDialog.withLoading {
|
||||
clearLogs()
|
||||
loadPage(0, true)
|
||||
}
|
||||
snackBarHost.showSnackbar(context.getString(R.string.log_viewer_logs_cleared))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackBarHost) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
) {
|
||||
LogControlPanel(
|
||||
filterType = filterType,
|
||||
onFilterTypeSelected = { filterType = it },
|
||||
logCount = filteredEntries.size,
|
||||
totalCount = logEntries.size,
|
||||
pageInfo = pageInfo,
|
||||
excludedSubTypes = excludedSubTypes,
|
||||
onExcludeToggle = { excl ->
|
||||
excludedSubTypes = if (excl in excludedSubTypes)
|
||||
excludedSubTypes - excl
|
||||
else
|
||||
excludedSubTypes + excl
|
||||
}
|
||||
)
|
||||
|
||||
// 日志列表
|
||||
if (isLoading && logEntries.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (filteredEntries.isEmpty()) {
|
||||
EmptyLogState(
|
||||
hasLogs = logEntries.isNotEmpty(),
|
||||
onRefresh = onManualRefresh
|
||||
)
|
||||
} else {
|
||||
LogList(
|
||||
entries = filteredEntries,
|
||||
pageInfo = pageInfo,
|
||||
isLoading = isLoading,
|
||||
onLoadMore = loadNextPage,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogControlPanel(
|
||||
filterType: LogType?,
|
||||
onFilterTypeSelected: (LogType?) -> Unit,
|
||||
logCount: Int,
|
||||
totalCount: Int,
|
||||
pageInfo: LogPageInfo,
|
||||
excludedSubTypes: Set<LogExclType>,
|
||||
onExcludeToggle: (LogExclType) -> Unit
|
||||
) {
|
||||
var isExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
elevation = getCardElevation()
|
||||
) {
|
||||
Column {
|
||||
// 标题栏(点击展开/收起)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { isExpanded = !isExpanded }
|
||||
.padding(SPACING_LARGE),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = SPACING_LARGE)
|
||||
) {
|
||||
// 类型过滤
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_filter_type),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) {
|
||||
item {
|
||||
FilterChip(
|
||||
onClick = { onFilterTypeSelected(null) },
|
||||
label = { Text(stringResource(R.string.log_viewer_all_types)) },
|
||||
selected = filterType == null
|
||||
)
|
||||
}
|
||||
items(LogType.entries.toTypedArray()) { type ->
|
||||
FilterChip(
|
||||
onClick = { onFilterTypeSelected(if (filterType == type) null else type) },
|
||||
label = { Text(type.displayName) },
|
||||
selected = filterType == type,
|
||||
leadingIcon = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(type.color, RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
|
||||
// 排除子类型
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_exclude_subtypes),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)) {
|
||||
items(LogExclType.entries.toTypedArray()) { excl ->
|
||||
val label = if (excl == LogExclType.CURRENT_APP)
|
||||
stringResource(R.string.log_viewer_exclude_current_app)
|
||||
else excl.displayName
|
||||
|
||||
FilterChip(
|
||||
onClick = { onExcludeToggle(excl) },
|
||||
label = { Text(label) },
|
||||
selected = excl in excludedSubTypes,
|
||||
leadingIcon = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(excl.color, RoundedCornerShape(4.dp))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
|
||||
// 统计信息
|
||||
Column(verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)) {
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (pageInfo.totalPages > 0) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.log_viewer_page_info,
|
||||
pageInfo.currentPage + 1,
|
||||
pageInfo.totalPages,
|
||||
pageInfo.totalLogs
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) {
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_LARGE))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogList(
|
||||
entries: List<LogEntry>,
|
||||
pageInfo: LogPageInfo,
|
||||
isLoading: Boolean,
|
||||
onLoadMore: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||
verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)
|
||||
) {
|
||||
items(entries) { entry ->
|
||||
LogEntryCard(entry = entry)
|
||||
}
|
||||
|
||||
// 加载更多按钮或加载指示器
|
||||
if (pageInfo.hasMore) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(SPACING_LARGE),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = onLoadMore,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Text(stringResource(R.string.log_viewer_load_more))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (entries.isNotEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(SPACING_LARGE),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_all_logs_loaded),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogEntryCard(entry: LogEntry) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(SPACING_MEDIUM)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(entry.type.color, RoundedCornerShape(6.dp))
|
||||
)
|
||||
Text(
|
||||
text = entry.type.displayName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = entry.timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "UID: ${entry.uid}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "PID: ${entry.pid}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = entry.comm,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (entry.details.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
Text(
|
||||
text = entry.details,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_raw_log),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
Text(
|
||||
text = entry.rawLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLogState(
|
||||
hasLogs: Boolean,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(SPACING_LARGE)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (hasLogs) Icons.Filled.FilterList else Icons.Filled.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (hasLogs) R.string.log_viewer_no_matching_logs
|
||||
else R.string.log_viewer_no_logs
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Button(onClick = onRefresh) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Text(stringResource(R.string.log_viewer_refresh))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LogViewerTopBar(
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
onBackClick: () -> Unit,
|
||||
showSearchBar: Boolean,
|
||||
searchQuery: String,
|
||||
onSearchQueryChange: (String) -> Unit,
|
||||
onSearchToggle: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onClearLogs: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
||||
colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
colorScheme.background
|
||||
}
|
||||
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.log_viewer_title),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.log_viewer_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSearchToggle) {
|
||||
Icon(
|
||||
imageVector = if (showSearchBar) Icons.Filled.SearchOff else Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.log_viewer_search)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(R.string.log_viewer_refresh)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onClearLogs) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = stringResource(R.string.log_viewer_clear_logs)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showSearchBar,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = onSearchQueryChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||
placeholder = { Text(stringResource(R.string.log_viewer_search_placeholder)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { onSearchQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Clear,
|
||||
contentDescription = stringResource(R.string.log_viewer_clear_search)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkForNewLogs(
|
||||
lastHash: String
|
||||
): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val shell = getRootShell()
|
||||
val logPath = "/data/adb/ksu/log/sulog.log"
|
||||
|
||||
val result = runCmd(shell, "stat -c '%Y %s' $logPath 2>/dev/null || echo '0 0'")
|
||||
val currentHash = result.trim()
|
||||
|
||||
currentHash != lastHash && currentHash != "0 0"
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLogsWithPagination(
|
||||
page: Int,
|
||||
forceRefresh: Boolean,
|
||||
lastHash: String,
|
||||
onLoaded: (List<LogEntry>, LogPageInfo, String) -> Unit
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val shell = getRootShell()
|
||||
|
||||
// 获取文件信息
|
||||
val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'")
|
||||
val currentHash = statResult.trim()
|
||||
|
||||
if (!forceRefresh && currentHash == lastHash && currentHash != "0 0") {
|
||||
withContext(Dispatchers.Main) {
|
||||
onLoaded(emptyList(), LogPageInfo(), currentHash)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// 获取总行数
|
||||
val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'")
|
||||
val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0
|
||||
|
||||
if (totalLines == 0) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onLoaded(emptyList(), LogPageInfo(), currentHash)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// 限制最大日志数量
|
||||
val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS)
|
||||
val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE
|
||||
|
||||
// 计算要读取的行数范围
|
||||
val startLine = if (page == 0) {
|
||||
maxOf(1, totalLines - effectiveTotal + 1)
|
||||
} else {
|
||||
val skipLines = page * PAGE_SIZE
|
||||
maxOf(1, totalLines - effectiveTotal + 1 + skipLines)
|
||||
}
|
||||
|
||||
val endLine = minOf(startLine + PAGE_SIZE - 1, totalLines)
|
||||
|
||||
if (startLine > totalLines) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onLoaded(emptyList(), LogPageInfo(page, totalPages, effectiveTotal, false), currentHash)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val result = runCmd(shell, "sed -n '${startLine},${endLine}p' $LOGS_PATCH 2>/dev/null || echo ''")
|
||||
val entries = parseLogEntries(result)
|
||||
|
||||
val hasMore = endLine < totalLines
|
||||
val pageInfo = LogPageInfo(page, totalPages, effectiveTotal, hasMore)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onLoaded(entries, pageInfo, currentHash)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
onLoaded(emptyList(), LogPageInfo(), lastHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearLogs() {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val shell = getRootShell()
|
||||
runCmd(shell, "echo '' > $LOGS_PATCH")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLogEntries(logContent: String): List<LogEntry> {
|
||||
if (logContent.isBlank()) return emptyList()
|
||||
|
||||
val entries = logContent.lines()
|
||||
.filter { it.isNotBlank() && it.startsWith("[") }
|
||||
.mapNotNull { line ->
|
||||
try {
|
||||
parseLogLine(line)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return entries.reversed()
|
||||
}
|
||||
private fun utcToLocal(utc: String): String {
|
||||
return try {
|
||||
val instant = LocalDateTime.parse(utc, utcFormatter).atOffset(ZoneOffset.UTC).toInstant()
|
||||
val local = instant.atZone(ZoneId.systemDefault())
|
||||
local.format(localFormatter)
|
||||
} catch (_: Exception) {
|
||||
utc
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLogLine(line: String): LogEntry? {
|
||||
// 解析格式: [timestamp] TYPE: UID=xxx COMM=xxx ...
|
||||
val timestampRegex = """\[(.*?)]""".toRegex()
|
||||
val timestampMatch = timestampRegex.find(line) ?: return null
|
||||
val timestamp = utcToLocal(timestampMatch.groupValues[1])
|
||||
|
||||
val afterTimestamp = line.substring(timestampMatch.range.last + 1).trim()
|
||||
val parts = afterTimestamp.split(":")
|
||||
if (parts.size < 2) return null
|
||||
|
||||
val typeStr = parts[0].trim()
|
||||
val type = when (typeStr) {
|
||||
"SU_GRANT" -> LogType.SU_GRANT
|
||||
"SU_EXEC" -> LogType.SU_EXEC
|
||||
"PERM_CHECK" -> LogType.PERM_CHECK
|
||||
"SYSCALL" -> LogType.SYSCALL
|
||||
"MANAGER_OP" -> LogType.MANAGER_OP
|
||||
else -> LogType.UNKNOWN
|
||||
}
|
||||
|
||||
val details = parts[1].trim()
|
||||
val uid: String = extractValue(details, "UID") ?: ""
|
||||
val comm: String = extractValue(details, "COMM") ?: ""
|
||||
val pid: String = extractValue(details, "PID") ?: ""
|
||||
|
||||
// 构建详细信息字符串
|
||||
val detailsStr = when (type) {
|
||||
LogType.SU_GRANT -> {
|
||||
val method: String = extractValue(details, "METHOD") ?: ""
|
||||
"Method: $method"
|
||||
}
|
||||
LogType.SU_EXEC -> {
|
||||
val target: String = extractValue(details, "TARGET") ?: ""
|
||||
val result: String = extractValue(details, "RESULT") ?: ""
|
||||
"Target: $target, Result: $result"
|
||||
}
|
||||
LogType.PERM_CHECK -> {
|
||||
val result: String = extractValue(details, "RESULT") ?: ""
|
||||
"Result: $result"
|
||||
}
|
||||
LogType.SYSCALL -> {
|
||||
val syscall = extractValue(details, "SYSCALL") ?: ""
|
||||
val args = extractValue(details, "ARGS") ?: ""
|
||||
"Syscall: $syscall, Args: $args"
|
||||
}
|
||||
LogType.MANAGER_OP -> {
|
||||
val op: String = extractValue(details, "OP") ?: ""
|
||||
val managerUid: String = extractValue(details, "MANAGER_UID") ?: ""
|
||||
val targetUid: String = extractValue(details, "TARGET_UID") ?: ""
|
||||
"Operation: $op, Manager UID: $managerUid, Target UID: $targetUid"
|
||||
}
|
||||
else -> details
|
||||
}
|
||||
|
||||
return LogEntry(
|
||||
timestamp = timestamp,
|
||||
type = type,
|
||||
uid = uid,
|
||||
comm = comm,
|
||||
details = detailsStr,
|
||||
pid = pid,
|
||||
rawLine = line
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractValue(text: String, key: String): String? {
|
||||
val regex = """$key=(\S+)""".toRegex()
|
||||
return regex.find(text)?.groupValues?.get(1)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,65 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.add
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.captionBar
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material.icons.outlined.Fingerprint
|
||||
import androidx.compose.material.icons.outlined.Group
|
||||
import androidx.compose.material.icons.outlined.Shield
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.compose.dropUnlessResumed
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
@@ -35,27 +68,57 @@ import com.ramcosta.composedestinations.generated.destinations.TemplateEditorScr
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.ramcosta.composedestinations.result.ResultRecipient
|
||||
import com.ramcosta.composedestinations.result.getOr
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.DropdownItem
|
||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.FloatingActionButton
|
||||
import top.yukonga.miuix.kmp.basic.HorizontalDivider
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.ListPopup
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.basic.PullToRefresh
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Copy
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Refresh
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.utils.PressFeedbackType
|
||||
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/10/20.
|
||||
*/
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun AppProfileTemplateScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
resultRecipient: ResultRecipient<TemplateEditorScreenDestination, Boolean>
|
||||
) {
|
||||
val viewModel = viewModel<TemplateViewModel>()
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (viewModel.templateList.isEmpty()) {
|
||||
@@ -70,10 +133,45 @@ fun AppProfileTemplateScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
var fabVisible by remember { mutableStateOf(true) }
|
||||
var scrollDistance by remember { mutableFloatStateOf(0f) }
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val isScrolledToEnd =
|
||||
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == listState.layoutInfo.totalItemsCount - 1
|
||||
&& (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.size
|
||||
?: 0) < listState.layoutInfo.viewportEndOffset)
|
||||
val delta = available.y
|
||||
if (!isScrolledToEnd) {
|
||||
scrollDistance += delta
|
||||
if (scrollDistance < -50f) {
|
||||
if (fabVisible) fabVisible = false
|
||||
scrollDistance = 0f
|
||||
} else if (scrollDistance > 50f) {
|
||||
if (!fabVisible) fabVisible = true
|
||||
scrollDistance = 0f
|
||||
}
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
val offsetHeight by animateDpAsState(
|
||||
targetValue = if (fabVisible) 0.dp else 100.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(),
|
||||
animationSpec = tween(durationMillis = 350)
|
||||
)
|
||||
val hazeState = remember { HazeState() }
|
||||
val hazeStyle = HazeStyle(
|
||||
backgroundColor = colorScheme.background,
|
||||
tint = HazeTint(colorScheme.background.copy(0.8f))
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = context.getSystemService<ClipboardManager>()
|
||||
val showToast = fun(msg: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
@@ -85,20 +183,20 @@ fun AppProfileTemplateScreen(
|
||||
scope.launch { viewModel.fetchTemplates(true) }
|
||||
},
|
||||
onImport = {
|
||||
scope.launch {
|
||||
val clipboardText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString()
|
||||
if (clipboardText.isNullOrEmpty()) {
|
||||
clipboardManager.getText()?.text?.let {
|
||||
if (it.isEmpty()) {
|
||||
showToast(context.getString(R.string.app_profile_template_import_empty))
|
||||
return@launch
|
||||
return@let
|
||||
}
|
||||
scope.launch {
|
||||
viewModel.importTemplates(
|
||||
it, {
|
||||
showToast(context.getString(R.string.app_profile_template_import_success))
|
||||
viewModel.fetchTemplates(false)
|
||||
},
|
||||
showToast
|
||||
)
|
||||
}
|
||||
viewModel.importTemplates(
|
||||
clipboardText,
|
||||
{
|
||||
showToast(context.getString(R.string.app_profile_template_import_success))
|
||||
viewModel.fetchTemplates(false)
|
||||
},
|
||||
showToast
|
||||
)
|
||||
}
|
||||
},
|
||||
onExport = {
|
||||
@@ -107,176 +205,300 @@ fun AppProfileTemplateScreen(
|
||||
{
|
||||
showToast(context.getString(R.string.app_profile_template_export_empty))
|
||||
}
|
||||
) { text ->
|
||||
clipboardManager?.setPrimaryClip(ClipData.newPlainText("", text))
|
||||
) {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
}
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
scrollBehavior = scrollBehavior,
|
||||
hazeState = hazeState,
|
||||
hazeStyle = hazeStyle,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
FloatingActionButton(
|
||||
containerColor = colorScheme.primary,
|
||||
shadowElevation = 0.dp,
|
||||
onClick = {
|
||||
navigator.navigate(
|
||||
TemplateEditorScreenDestination(
|
||||
TemplateViewModel.TemplateInfo(),
|
||||
false
|
||||
)
|
||||
navigator.navigate(TemplateEditorScreenDestination(TemplateViewModel.TemplateInfo(), false)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.offset(y = offsetHeight)
|
||||
.padding(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding() + 20.dp,
|
||||
end = 20.dp
|
||||
)
|
||||
.border(0.05.dp, colorScheme.outline.copy(alpha = 0.5f), CircleShape),
|
||||
content = {
|
||||
Icon(
|
||||
Icons.Rounded.Add,
|
||||
null,
|
||||
Modifier.size(40.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
icon = { Icon(Icons.Filled.Add, null) },
|
||||
text = { Text(stringResource(id = R.string.app_profile_template_create)) },
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
popupHost = { },
|
||||
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
PullToRefreshBox(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
isRefreshing = viewModel.isRefreshing,
|
||||
onRefresh = {
|
||||
scope.launch { viewModel.fetchTemplates() }
|
||||
var isRefreshing by rememberSaveable { mutableStateOf(false) }
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
delay(350)
|
||||
viewModel.fetchTemplates()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
val refreshTexts = listOf(
|
||||
stringResource(R.string.refresh_pulling),
|
||||
stringResource(R.string.refresh_release),
|
||||
stringResource(R.string.refresh_refresh),
|
||||
stringResource(R.string.refresh_complete),
|
||||
)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
PullToRefresh(
|
||||
isRefreshing = isRefreshing,
|
||||
pullToRefreshState = pullToRefreshState,
|
||||
onRefresh = { isRefreshing = true },
|
||||
refreshTexts = refreshTexts,
|
||||
contentPadding = PaddingValues(
|
||||
top = innerPadding.calculateTopPadding() + 12.dp,
|
||||
start = innerPadding.calculateStartPadding(layoutDirection),
|
||||
end = innerPadding.calculateEndPadding(layoutDirection)
|
||||
),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
contentPadding = remember {
|
||||
PaddingValues(bottom = 16.dp + 56.dp + 16.dp /* Scaffold Fab Spacing + Fab container height */)
|
||||
}
|
||||
.height(getWindowSize().height.dp)
|
||||
.scrollEndHaptic()
|
||||
.overScrollVertical()
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.hazeSource(state = hazeState)
|
||||
.padding(horizontal = 12.dp),
|
||||
contentPadding = innerPadding,
|
||||
overscrollEffect = null
|
||||
) {
|
||||
item {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
items(viewModel.templateList, key = { it.id }) { app ->
|
||||
TemplateItem(navigator, app)
|
||||
}
|
||||
item {
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun TemplateItem(
|
||||
navigator: DestinationsNavigator,
|
||||
template: TemplateViewModel.TemplateInfo
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
navigator.navigate(TemplateEditorScreenDestination(template, !template.local))
|
||||
},
|
||||
headlineContent = { Text(template.name) },
|
||||
supportingContent = {
|
||||
Column {
|
||||
Card(
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onClick = {
|
||||
navigator.navigate(TemplateEditorScreenDestination(template, !template.local)) {
|
||||
popUpTo(TemplateEditorScreenDestination) {
|
||||
inclusive = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
showIndication = true,
|
||||
pressFeedbackType = PressFeedbackType.Sink
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "${template.id}${if (template.author.isEmpty()) "" else "@${template.author}"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
text = template.name,
|
||||
fontWeight = FontWeight(550),
|
||||
color = colorScheme.onSurface,
|
||||
)
|
||||
Text(template.description)
|
||||
FlowRow {
|
||||
LabelText(label = "UID: ${template.uid}")
|
||||
LabelText(label = "GID: ${template.gid}")
|
||||
LabelText(label = template.context)
|
||||
if (template.local) {
|
||||
LabelText(label = "local")
|
||||
} else {
|
||||
LabelText(label = "remote")
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (template.local) {
|
||||
Text(
|
||||
text = "LOCAL",
|
||||
color = colorScheme.onTertiaryContainer,
|
||||
fontWeight = FontWeight(750),
|
||||
style = MiuixTheme.textStyles.footnote1
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "REMOTE",
|
||||
color = colorScheme.onSurfaceSecondary,
|
||||
fontWeight = FontWeight(750),
|
||||
style = MiuixTheme.textStyles.footnote1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${template.id}${if (template.author.isEmpty()) "" else " by @${template.author}"}",
|
||||
modifier = Modifier.padding(top = 1.dp),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight(550),
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = template.description,
|
||||
fontSize = 14.sp,
|
||||
color = colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = colorScheme.outline.copy(alpha = 0.5f)
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
InfoChip(
|
||||
icon = Icons.Outlined.Fingerprint,
|
||||
text = "UID: ${template.uid}"
|
||||
)
|
||||
InfoChip(
|
||||
icon = Icons.Outlined.Group,
|
||||
text = "GID: ${template.gid}"
|
||||
)
|
||||
InfoChip(
|
||||
icon = Icons.Outlined.Shield,
|
||||
text = template.context
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoChip(icon: ImageVector, text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = colorScheme.onSurfaceSecondary.copy(alpha = 0.8f)
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
text = text,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight(550),
|
||||
color = colorScheme.onSurfaceSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
onBack: () -> Unit,
|
||||
onSync: () -> Unit = {},
|
||||
onImport: () -> Unit = {},
|
||||
onExport: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
scrollBehavior: ScrollBehavior,
|
||||
hazeState: HazeState,
|
||||
hazeStyle: HazeStyle,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
||||
colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
colorScheme.background
|
||||
}
|
||||
val cardAlpha = CardConfig.cardAlpha
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(stringResource(R.string.settings_profile_template))
|
||||
modifier = Modifier.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
color = Color.Transparent,
|
||||
title = stringResource(R.string.settings_profile_template),
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onSync) {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = onSync
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Sync,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_sync)
|
||||
imageVector = MiuixIcons.Useful.Refresh,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_sync),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = {
|
||||
showDropdown = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ImportExport,
|
||||
contentDescription = stringResource(id = R.string.app_profile_import_export)
|
||||
)
|
||||
|
||||
DropdownMenu(expanded = showDropdown, onDismissRequest = {
|
||||
showDropdown = false
|
||||
}) {
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id = R.string.app_profile_import_from_clipboard))
|
||||
}, onClick = {
|
||||
onImport()
|
||||
showDropdown = false
|
||||
})
|
||||
DropdownMenuItem(text = {
|
||||
Text(stringResource(id = R.string.app_profile_export_to_clipboard))
|
||||
}, onClick = {
|
||||
onExport()
|
||||
showDropdown = false
|
||||
})
|
||||
val showTopPopup = remember { mutableStateOf(false) }
|
||||
ListPopup(
|
||||
show = showTopPopup,
|
||||
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||
alignment = PopupPositionProvider.Align.TopRight,
|
||||
onDismissRequest = {
|
||||
showTopPopup.value = false
|
||||
}
|
||||
) {
|
||||
ListPopupColumn {
|
||||
val items = listOf(
|
||||
stringResource(id = R.string.app_profile_import_from_clipboard),
|
||||
stringResource(id = R.string.app_profile_export_to_clipboard)
|
||||
)
|
||||
items.forEachIndexed { index, text ->
|
||||
DropdownItem(
|
||||
text = text,
|
||||
optionSize = items.size,
|
||||
index = index,
|
||||
onSelectedIndexChange = { selectedIndex ->
|
||||
if (selectedIndex == 0) {
|
||||
onImport()
|
||||
} else {
|
||||
onExport()
|
||||
}
|
||||
showTopPopup.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = { showTopPopup.value = true },
|
||||
holdDownState = showTopPopup.value
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Copy,
|
||||
contentDescription = stringResource(id = R.string.app_profile_import_export),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LabelText(label: String) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp, end = 4.dp)
|
||||
.background(
|
||||
Color.Black,
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(vertical = 2.dp, horizontal = 5.dp),
|
||||
style = TextStyle(
|
||||
fontSize = 8.sp,
|
||||
color = Color.White,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,47 +2,75 @@ package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.add
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.captionBar
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.dropUnlessResumed
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.result.ResultBackNavigator
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.HazeStyle
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.EditText
|
||||
import com.sukisu.ultra.ui.component.profile.RootProfileConfig
|
||||
import com.sukisu.ultra.ui.util.deleteAppProfileTemplate
|
||||
import com.sukisu.ultra.ui.util.getAppProfileTemplate
|
||||
import com.sukisu.ultra.ui.util.setAppProfileTemplate
|
||||
import com.sukisu.ultra.ui.viewmodel.TemplateViewModel
|
||||
import com.sukisu.ultra.ui.viewmodel.toJSON
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Back
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Confirm
|
||||
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import top.yukonga.miuix.kmp.utils.getWindowSize
|
||||
import top.yukonga.miuix.kmp.utils.overScrollVertical
|
||||
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/10/20.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
@Destination<RootGraph>
|
||||
fun TemplateEditorScreen(
|
||||
navigator: ResultBackNavigator<Boolean>,
|
||||
initialTemplate: TemplateViewModel.TemplateInfo,
|
||||
@@ -56,7 +84,12 @@ fun TemplateEditorScreen(
|
||||
mutableStateOf(initialTemplate)
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val scrollBehavior = MiuixScrollBehavior()
|
||||
val hazeState = remember { HazeState() }
|
||||
val hazeStyle = HazeStyle(
|
||||
backgroundColor = colorScheme.background,
|
||||
tint = HazeTint(colorScheme.background.copy(0.8f))
|
||||
)
|
||||
|
||||
BackHandler {
|
||||
navigator.navigateBack(result = !readOnly)
|
||||
@@ -64,15 +97,9 @@ fun TemplateEditorScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val author =
|
||||
if (initialTemplate.author.isNotEmpty()) "@${initialTemplate.author}" else ""
|
||||
val readOnlyHint = if (readOnly) {
|
||||
" - ${stringResource(id = R.string.app_profile_template_readonly)}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val titleSummary = "${initialTemplate.id}$author$readOnlyHint"
|
||||
val saveTemplateFailed = stringResource(id = R.string.app_profile_template_save_failed)
|
||||
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
|
||||
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
|
||||
val context = LocalContext.current
|
||||
|
||||
TopBar(
|
||||
@@ -84,7 +111,6 @@ fun TemplateEditorScreen(
|
||||
stringResource(R.string.app_profile_template_edit)
|
||||
},
|
||||
readOnly = readOnly,
|
||||
summary = titleSummary,
|
||||
onBack = dropUnlessResumed { navigator.navigateBack(result = !readOnly) },
|
||||
onDelete = {
|
||||
if (deleteAppProfileTemplate(template.id)) {
|
||||
@@ -92,106 +118,156 @@ fun TemplateEditorScreen(
|
||||
}
|
||||
},
|
||||
onSave = {
|
||||
when (idCheck(template.id)) {
|
||||
0 -> Unit
|
||||
|
||||
1 -> {
|
||||
Toast.makeText(context, idConflictError, Toast.LENGTH_SHORT).show()
|
||||
return@TopBar
|
||||
}
|
||||
|
||||
2 -> {
|
||||
Toast.makeText(context, idInvalidError, Toast.LENGTH_SHORT).show()
|
||||
return@TopBar
|
||||
}
|
||||
}
|
||||
if (saveTemplate(template, isCreation)) {
|
||||
navigator.navigateBack(result = true)
|
||||
} else {
|
||||
Toast.makeText(context, saveTemplateFailed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
scrollBehavior = scrollBehavior,
|
||||
hazeState = hazeState,
|
||||
hazeStyle = hazeStyle,
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
popupHost = { },
|
||||
contentWindowInsets = WindowInsets.systemBars.add(WindowInsets.displayCutout).only(WindowInsetsSides.Horizontal)
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.height(getWindowSize().height.dp)
|
||||
.scrollEndHaptic()
|
||||
.overScrollVertical()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.hazeSource(state = hazeState)
|
||||
.pointerInteropFilter {
|
||||
// disable click and ripple if readOnly
|
||||
readOnly
|
||||
}
|
||||
},
|
||||
contentPadding = innerPadding,
|
||||
overscrollEffect = null
|
||||
) {
|
||||
if (isCreation) {
|
||||
var errorHint by remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val idConflictError = stringResource(id = R.string.app_profile_template_id_exist)
|
||||
val idInvalidError = stringResource(id = R.string.app_profile_template_id_invalid)
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_id),
|
||||
text = template.id,
|
||||
errorHint = errorHint,
|
||||
isError = errorHint.isNotEmpty()
|
||||
) { value ->
|
||||
errorHint = if (isTemplateExist(value)) {
|
||||
idConflictError
|
||||
} else if (!isValidTemplateId(value)) {
|
||||
idInvalidError
|
||||
} else {
|
||||
""
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
var errorHint by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
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
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_name),
|
||||
text = template.name
|
||||
) { value ->
|
||||
template.copy(name = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_description),
|
||||
text = template.description
|
||||
) { value ->
|
||||
template.copy(description = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_id),
|
||||
text = template.id,
|
||||
isError = errorHint
|
||||
) { value ->
|
||||
errorHint = if (value.isEmpty()) {
|
||||
false
|
||||
} else if (isTemplateExist(value)) {
|
||||
true
|
||||
} else if (!isValidTemplateId(value)) {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
template = template.copy(id = value)
|
||||
}
|
||||
TextEdit(
|
||||
label = stringResource(R.string.module_author),
|
||||
text = template.author
|
||||
) { value ->
|
||||
template.copy(author = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
|
||||
RootProfileConfig(fixedName = true,
|
||||
profile = toNativeProfile(template),
|
||||
onProfileChange = {
|
||||
template.copy(
|
||||
uid = it.uid,
|
||||
gid = it.gid,
|
||||
groups = it.groups,
|
||||
capabilities = it.capabilities,
|
||||
context = it.context,
|
||||
namespace = it.namespace,
|
||||
rules = it.rules.split("\n")
|
||||
).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
TextEdit(
|
||||
label = stringResource(id = R.string.app_profile_template_description),
|
||||
text = template.description
|
||||
) { value ->
|
||||
template.copy(description = value).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
|
||||
RootProfileConfig(
|
||||
fixedName = true,
|
||||
profile = toNativeProfile(template),
|
||||
onProfileChange = {
|
||||
template.copy(
|
||||
uid = it.uid,
|
||||
gid = it.gid,
|
||||
groups = it.groups,
|
||||
capabilities = it.capabilities,
|
||||
context = it.context,
|
||||
namespace = it.namespace,
|
||||
rules = it.rules.split("\n")
|
||||
).run {
|
||||
if (autoSave) {
|
||||
if (!saveTemplate(this)) {
|
||||
// failed
|
||||
return@run
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
}
|
||||
template = this
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
Spacer(
|
||||
Modifier.height(
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
WindowInsets.captionBar.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toNativeProfile(templateInfo: TemplateViewModel.TemplateInfo): Natives.Profile {
|
||||
return Natives.Profile().copy(rootTemplate = templateInfo.id,
|
||||
return Natives.Profile().copy(
|
||||
rootTemplate = templateInfo.id,
|
||||
uid = templateInfo.uid,
|
||||
gid = templateInfo.gid,
|
||||
groups = templateInfo.groups,
|
||||
@@ -213,6 +289,10 @@ fun isTemplateValid(template: TemplateViewModel.TemplateInfo): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
fun idCheck(value: String): Int {
|
||||
return if (value.isEmpty()) 0 else if (isTemplateExist(value)) 1 else if (!isValidTemplateId(value)) 2 else 0
|
||||
}
|
||||
|
||||
fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean = false): Boolean {
|
||||
if (!isTemplateValid(template)) {
|
||||
return false
|
||||
@@ -227,50 +307,62 @@ fun saveTemplate(template: TemplateViewModel.TemplateInfo, isCreation: Boolean =
|
||||
return setAppProfileTemplate(template.id, json.toString())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
title: String,
|
||||
readOnly: Boolean,
|
||||
summary: String = "",
|
||||
onBack: () -> Unit,
|
||||
onDelete: () -> Unit = {},
|
||||
onSave: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
scrollBehavior: ScrollBehavior,
|
||||
hazeState: HazeState,
|
||||
hazeStyle: HazeStyle,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(title)
|
||||
if (summary.isNotBlank()) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}, navigationIcon = {
|
||||
modifier = Modifier.hazeEffect(hazeState) {
|
||||
style = hazeStyle
|
||||
blurRadius = 30.dp
|
||||
noiseFactor = 0f
|
||||
},
|
||||
color = Color.Transparent,
|
||||
title = title,
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
onClick = onBack
|
||||
) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) }
|
||||
}, actions = {
|
||||
if (readOnly) {
|
||||
return@TopAppBar
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.DeleteForever,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_delete)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onSave) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_save)
|
||||
imageVector = MiuixIcons.Useful.Back,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (readOnly) {
|
||||
return@TopAppBar
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = onDelete
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Delete,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_delete),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
onClick = onSave
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MiuixIcons.Useful.Confirm,
|
||||
contentDescription = stringResource(id = R.string.app_profile_template_save),
|
||||
tint = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
@@ -279,35 +371,22 @@ private fun TopBar(
|
||||
private fun TextEdit(
|
||||
label: String,
|
||||
text: String,
|
||||
errorHint: String = "",
|
||||
isError: Boolean = false,
|
||||
onValueChange: (String) -> Unit = {}
|
||||
) {
|
||||
ListItem(headlineContent = {
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
suffix = {
|
||||
if (errorHint.isNotBlank()) {
|
||||
Text(
|
||||
text = if (isError) errorHint else "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
isError = isError,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
}),
|
||||
onValueChange = onValueChange
|
||||
)
|
||||
})
|
||||
val editText = remember { mutableStateOf(text) }
|
||||
EditText(
|
||||
title = label.uppercase(),
|
||||
textValue = editText,
|
||||
onTextValueChange = { newText ->
|
||||
editText.value = newText
|
||||
onValueChange(newText)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
),
|
||||
isError = isError,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isValidTemplateId(id: String): Boolean {
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
package com.sukisu.ultra.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
||||
import com.sukisu.ultra.ui.component.ConfirmResult
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
import com.sukisu.ultra.ui.theme.getCardColors
|
||||
import com.sukisu.ultra.ui.theme.getCardElevation
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val SPACING_SMALL = 3.dp
|
||||
private val SPACING_MEDIUM = 8.dp
|
||||
private val SPACING_LARGE = 16.dp
|
||||
|
||||
data class UmountPathEntry(
|
||||
val path: String,
|
||||
val flags: Int,
|
||||
val isDefault: Boolean
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun UmountManagerScreen(navigator: DestinationsNavigator) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val confirmDialog = rememberConfirmDialog()
|
||||
|
||||
var pathList by remember { mutableStateOf<List<UmountPathEntry>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var showAddDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun loadPaths() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
isLoading = true
|
||||
val result = listUmountPaths()
|
||||
val entries = parseUmountPaths(result)
|
||||
withContext(Dispatchers.Main) {
|
||||
pathList = entries
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
loadPaths()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.umount_path_manager)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.navigateUp() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { loadPaths() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(
|
||||
alpha = CardConfig.cardAlpha
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { showAddDialog = true }
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackBarHost) }
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(SPACING_LARGE),
|
||||
colors = getCardColors(MaterialTheme.colorScheme.primaryContainer),
|
||||
elevation = getCardElevation()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(SPACING_LARGE)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
Text(
|
||||
text = stringResource(R.string.umount_path_restart_notice),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||
verticalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
|
||||
) {
|
||||
items(pathList, key = { it.path }) { entry ->
|
||||
UmountPathCard(
|
||||
entry = entry,
|
||||
onDelete = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val success = removeUmountPath(entry.path)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.umount_path_removed)
|
||||
)
|
||||
loadPaths()
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.operation_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(SPACING_LARGE))
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = SPACING_LARGE),
|
||||
horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (confirmDialog.awaitConfirm(
|
||||
title = context.getString(R.string.confirm_action),
|
||||
content = context.getString(R.string.confirm_clear_custom_paths)
|
||||
) == ConfirmResult.Confirmed) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val success = clearCustomUmountPaths()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.custom_paths_cleared)
|
||||
)
|
||||
loadPaths()
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.operation_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.DeleteForever, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Text(stringResource(R.string.clear_custom_paths))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val success = applyUmountConfigToKernel()
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.config_applied)
|
||||
)
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.operation_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||
Text(stringResource(R.string.apply_config))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddDialog) {
|
||||
AddUmountPathDialog(
|
||||
onDismiss = { showAddDialog = false },
|
||||
onConfirm = { path, flags ->
|
||||
showAddDialog = false
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val success = addUmountPath(path, flags)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (success) {
|
||||
saveUmountConfig()
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.umount_path_added)
|
||||
)
|
||||
loadPaths()
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.operation_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UmountPathCard(
|
||||
entry: UmountPathEntry,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val confirmDialog = rememberConfirmDialog()
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
elevation = getCardElevation()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(SPACING_LARGE),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = if (entry.isDefault)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(SPACING_LARGE))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = entry.path,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||
Text(
|
||||
text = buildString {
|
||||
append(context.getString(R.string.flags))
|
||||
append(": ")
|
||||
append(entry.flags.toUmountFlagName(context))
|
||||
if (entry.isDefault) {
|
||||
append(" | ")
|
||||
append(context.getString(R.string.default_entry))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (!entry.isDefault) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (confirmDialog.awaitConfirm(
|
||||
title = context.getString(R.string.confirm_delete),
|
||||
content = context.getString(R.string.confirm_delete_umount_path, entry.path)
|
||||
) == ConfirmResult.Confirmed) {
|
||||
onDelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddUmountPathDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String, Int) -> Unit
|
||||
) {
|
||||
var path by rememberSaveable { mutableStateOf("") }
|
||||
var flags by rememberSaveable { mutableStateOf("-1") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.add_umount_path)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = path,
|
||||
onValueChange = { path = it },
|
||||
label = { Text(stringResource(R.string.mount_path)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||
|
||||
OutlinedTextField(
|
||||
value = flags,
|
||||
onValueChange = { flags = it },
|
||||
label = { Text(stringResource(R.string.umount_flags)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
supportingText = { Text(stringResource(R.string.umount_flags_hint)) }
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val flagsInt = flags.toIntOrNull() ?: -1
|
||||
onConfirm(path, flagsInt)
|
||||
},
|
||||
enabled = path.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUmountPaths(output: String): List<UmountPathEntry> {
|
||||
val lines = output.lines().filter { it.isNotBlank() }
|
||||
if (lines.size < 2) return emptyList()
|
||||
|
||||
return lines.drop(2).mapNotNull { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
if (parts.size >= 3) {
|
||||
UmountPathEntry(
|
||||
path = parts[0],
|
||||
flags = parts[1].toIntOrNull() ?: -1,
|
||||
isDefault = parts[2].equals("Yes", ignoreCase = true)
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toUmountFlagName(context: Context): String {
|
||||
return when (this) {
|
||||
-1 -> context.getString(R.string.mnt_detach)
|
||||
else -> this.toString()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,928 +0,0 @@
|
||||
package com.sukisu.ultra.ui.susfs.component
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.susfs.util.SuSFSManager
|
||||
import com.sukisu.ultra.ui.susfs.util.SuSFSManager.isSusVersion158
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
|
||||
/**
|
||||
* SUS路径内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun SusPathsContent(
|
||||
susPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddPath: () -> Unit,
|
||||
onAddAppPath: () -> Unit,
|
||||
onRemovePath: (String) -> Unit,
|
||||
onEditPath: ((String) -> Unit)? = null,
|
||||
forceRefreshApps: Boolean = false
|
||||
) {
|
||||
val superUserApps = SuperUserViewModel.apps
|
||||
val superUserIsRefreshing = remember { SuperUserViewModel().isRefreshing }
|
||||
|
||||
LaunchedEffect(superUserIsRefreshing, superUserApps.size) {
|
||||
if (!superUserIsRefreshing && superUserApps.isNotEmpty()) {
|
||||
AppInfoCache.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(forceRefreshApps) {
|
||||
if (forceRefreshApps) {
|
||||
AppInfoCache.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
val (appPathGroups, otherPaths) = remember(susPaths) {
|
||||
val appPathRegex = Regex(".*/Android/data/([^/]+)/?.*")
|
||||
val uidPathRegex = Regex("/sys/fs/cgroup/uid_([0-9]+)")
|
||||
val appPathMap = mutableMapOf<String, MutableList<String>>()
|
||||
val uidToPackageMap = mutableMapOf<String, String>()
|
||||
val others = mutableListOf<String>()
|
||||
|
||||
// 构建UID到包名的映射
|
||||
SuperUserViewModel.apps.forEach { app ->
|
||||
try {
|
||||
val uid = app.packageInfo.applicationInfo?.uid
|
||||
uidToPackageMap[uid.toString()] = app.packageName
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
susPaths.forEach { path ->
|
||||
val appDataMatch = appPathRegex.find(path)
|
||||
val uidMatch = uidPathRegex.find(path)
|
||||
|
||||
when {
|
||||
appDataMatch != null -> {
|
||||
val packageName = appDataMatch.groupValues[1]
|
||||
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
|
||||
}
|
||||
uidMatch != null -> {
|
||||
val uid = uidMatch.groupValues[1]
|
||||
val packageName = uidToPackageMap[uid]
|
||||
if (packageName != null) {
|
||||
appPathMap.getOrPut(packageName) { mutableListOf() }.add(path)
|
||||
} else {
|
||||
others.add(path)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
others.add(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sortedAppGroups = appPathMap.toList()
|
||||
.sortedBy { it.first }
|
||||
.map { (packageName, paths) -> packageName to paths.sorted() }
|
||||
|
||||
Pair(sortedAppGroups, others.sorted())
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 应用路径分组
|
||||
if (appPathGroups.isNotEmpty()) {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.app_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Apps,
|
||||
count = appPathGroups.size
|
||||
)
|
||||
}
|
||||
|
||||
items(appPathGroups) { (packageName, paths) ->
|
||||
AppPathGroupCard(
|
||||
packageName = packageName,
|
||||
paths = paths,
|
||||
onDeleteGroup = {
|
||||
paths.forEach { path -> onRemovePath(path) }
|
||||
},
|
||||
onEditGroup = if (onEditPath != null) {
|
||||
{
|
||||
onEditPath(paths.first())
|
||||
}
|
||||
} else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 其他路径
|
||||
if (otherPaths.isNotEmpty()) {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.other_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Folder,
|
||||
count = otherPaths.size
|
||||
)
|
||||
}
|
||||
|
||||
items(otherPaths) { path ->
|
||||
PathItemCard(
|
||||
path = path,
|
||||
icon = Icons.Default.Folder,
|
||||
onDelete = { onRemovePath(path) },
|
||||
onEdit = if (onEditPath != null) { { onEditPath(path) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (susPaths.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_paths_configured)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddPath,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add_custom_path))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onAddAppPath,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Apps,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add_app_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SUS循环路径内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun SusLoopPathsContent(
|
||||
susLoopPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddLoopPath: () -> Unit,
|
||||
onRemoveLoopPath: (String) -> Unit,
|
||||
onEditLoopPath: ((String) -> Unit)? = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 说明卡片
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.sus_loop_paths_description_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sus_loop_paths_description_text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_loop_path_restriction_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (susLoopPaths.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_loop_paths_configured)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.loop_paths_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Loop,
|
||||
count = susLoopPaths.size
|
||||
)
|
||||
}
|
||||
|
||||
items(susLoopPaths.toList()) { path ->
|
||||
PathItemCard(
|
||||
path = path,
|
||||
icon = Icons.Default.Loop,
|
||||
onDelete = { onRemoveLoopPath(path) },
|
||||
onEdit = if (onEditLoopPath != null) { { onEditLoopPath(path) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddLoopPath,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add_loop_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SUS Maps内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun SusMapsContent(
|
||||
susMaps: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddSusMap: () -> Unit,
|
||||
onRemoveSusMap: (String) -> Unit,
|
||||
onEditSusMap: ((String) -> Unit)? = null
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 说明卡片
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.sus_maps_description_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sus_maps_description_text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sus_maps_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sus_maps_debug_info),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (susMaps.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_sus_maps_configured)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.sus_maps_section),
|
||||
subtitle = null,
|
||||
icon = Icons.Default.Security,
|
||||
count = susMaps.size
|
||||
)
|
||||
}
|
||||
|
||||
items(susMaps.toList()) { map ->
|
||||
PathItemCard(
|
||||
path = map,
|
||||
icon = Icons.Default.Security,
|
||||
onDelete = { onRemoveSusMap(map) },
|
||||
onEdit = if (onEditSusMap != null) { { onEditSusMap(map) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddSusMap,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SUS挂载内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun SusMountsContent(
|
||||
susMounts: Set<String>,
|
||||
hideSusMountsForAllProcs: Boolean,
|
||||
isSusVersion158: Boolean,
|
||||
isLoading: Boolean,
|
||||
onAddMount: () -> Unit,
|
||||
onRemoveMount: (String) -> Unit,
|
||||
onEditMount: ((String) -> Unit)? = null,
|
||||
onToggleHideSusMountsForAllProcs: (Boolean) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (isSusVersion158) {
|
||||
item {
|
||||
SusMountHidingControlCard(
|
||||
hideSusMountsForAllProcs = hideSusMountsForAllProcs,
|
||||
isLoading = isLoading,
|
||||
onToggleHiding = onToggleHideSusMountsForAllProcs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (susMounts.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_mounts_configured)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(susMounts.toList()) { mount ->
|
||||
PathItemCard(
|
||||
path = mount,
|
||||
icon = Icons.Default.Storage,
|
||||
onDelete = { onRemoveMount(mount) },
|
||||
onEdit = if (onEditMount != null) { { onEditMount(mount) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddMount,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试卸载内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun TryUmountContent(
|
||||
tryUmounts: Set<String>,
|
||||
umountForZygoteIsoService: Boolean,
|
||||
isLoading: Boolean,
|
||||
onAddUmount: () -> Unit,
|
||||
onRemoveUmount: (String) -> Unit,
|
||||
onEditUmount: ((String) -> Unit)? = null,
|
||||
onToggleUmountForZygoteIsoService: (Boolean) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (isSusVersion158()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Security,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.umount_zygote_iso_service),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.umount_zygote_iso_service_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = umountForZygoteIsoService,
|
||||
onCheckedChange = onToggleUmountForZygoteIsoService,
|
||||
enabled = !isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tryUmounts.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_umounts_configured)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(tryUmounts.toList()) { umountEntry ->
|
||||
val parts = umountEntry.split("|")
|
||||
val path = if (parts.isNotEmpty()) parts[0] else umountEntry
|
||||
val mode = if (parts.size > 1) parts[1] else "0"
|
||||
val modeText = if (mode == "0")
|
||||
stringResource(R.string.susfs_umount_mode_normal_short)
|
||||
else
|
||||
stringResource(R.string.susfs_umount_mode_detach_short)
|
||||
|
||||
PathItemCard(
|
||||
path = path,
|
||||
icon = Icons.Default.Storage,
|
||||
additionalInfo = stringResource(R.string.susfs_umount_mode_display, modeText, mode),
|
||||
onDelete = { onRemoveUmount(umountEntry) },
|
||||
onEdit = if (onEditUmount != null) { { onEditUmount(umountEntry) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddUmount,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kstat配置内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun KstatConfigContent(
|
||||
kstatConfigs: Set<String>,
|
||||
addKstatPaths: Set<String>,
|
||||
isLoading: Boolean,
|
||||
onAddKstatStatically: () -> Unit,
|
||||
onAddKstat: () -> Unit,
|
||||
onRemoveKstatConfig: (String) -> Unit,
|
||||
onEditKstatConfig: ((String) -> Unit)? = null,
|
||||
onRemoveAddKstat: (String) -> Unit,
|
||||
onEditAddKstat: ((String) -> Unit)? = null,
|
||||
onUpdateKstat: (String) -> Unit,
|
||||
onUpdateKstatFullClone: (String) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_add_statically),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_add),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_update),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_config_description_update_full_clone),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kstatConfigs.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.static_kstat_config),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
items(kstatConfigs.toList()) { config ->
|
||||
KstatConfigItemCard(
|
||||
config = config,
|
||||
onDelete = { onRemoveKstatConfig(config) },
|
||||
onEdit = if (onEditKstatConfig != null) { { onEditKstatConfig(config) } } else null,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (addKstatPaths.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.kstat_path_management),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
items(addKstatPaths.toList()) { path ->
|
||||
AddKstatPathItemCard(
|
||||
path = path,
|
||||
onDelete = { onRemoveAddKstat(path) },
|
||||
onEdit = if (onEditAddKstat != null) { { onEditAddKstat(path) } } else null,
|
||||
onUpdate = { onUpdateKstat(path) },
|
||||
onUpdateFullClone = { onUpdateKstatFullClone(path) },
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (kstatConfigs.isEmpty() && addKstatPaths.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.no_kstat_config_message)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onAddKstat,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onAddKstatStatically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(R.string.add))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径设置内容组件
|
||||
*/
|
||||
@SuppressLint("SdCardPath")
|
||||
@Composable
|
||||
fun PathSettingsContent(
|
||||
androidDataPath: String,
|
||||
onAndroidDataPathChange: (String) -> Unit,
|
||||
sdcardPath: String,
|
||||
onSdcardPathChange: (String) -> Unit,
|
||||
isLoading: Boolean,
|
||||
onSetAndroidDataPath: () -> Unit,
|
||||
onSetSdcardPath: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = androidDataPath,
|
||||
onValueChange = onAndroidDataPathChange,
|
||||
label = { Text(stringResource(R.string.susfs_android_data_path_label)) },
|
||||
placeholder = { Text("/sdcard/Android/data") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onSetAndroidDataPath,
|
||||
enabled = !isLoading && androidDataPath.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.susfs_set_android_data_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = sdcardPath,
|
||||
onValueChange = onSdcardPathChange,
|
||||
label = { Text(stringResource(R.string.susfs_sdcard_path_label)) },
|
||||
placeholder = { Text("/sdcard") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isLoading,
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onSetSdcardPath,
|
||||
enabled = !isLoading && sdcardPath.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.susfs_set_sdcard_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用功能状态内容组件
|
||||
*/
|
||||
@Composable
|
||||
fun EnabledFeaturesContent(
|
||||
enabledFeatures: List<SuSFSManager.EnabledFeature>,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.susfs_enabled_features_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledFeatures.isEmpty()) {
|
||||
item {
|
||||
EmptyStateCard(
|
||||
message = stringResource(R.string.susfs_no_features_found)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(enabledFeatures) { feature ->
|
||||
FeatureStatusCard(
|
||||
feature = feature,
|
||||
onRefresh = onRefresh
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,555 +0,0 @@
|
||||
package com.sukisu.ultra.ui.susfs.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
|
||||
/**
|
||||
* Magisk模块脚本生成器
|
||||
* 用于生成各种启动脚本的内容
|
||||
*/
|
||||
object ScriptGenerator {
|
||||
|
||||
// 常量定义
|
||||
private const val DEFAULT_UNAME = "default"
|
||||
private const val DEFAULT_BUILD_TIME = "default"
|
||||
private const val LOG_DIR = "/data/adb/ksu/log"
|
||||
|
||||
/**
|
||||
* 生成所有脚本文件
|
||||
*/
|
||||
fun generateAllScripts(config: SuSFSManager.ModuleConfig): Map<String, String> {
|
||||
return mapOf(
|
||||
"service.sh" to generateServiceScript(config),
|
||||
"post-fs-data.sh" to generatePostFsDataScript(config),
|
||||
"post-mount.sh" to generatePostMountScript(config),
|
||||
"boot-completed.sh" to generateBootCompletedScript(config)
|
||||
)
|
||||
}
|
||||
|
||||
// 日志相关的通用脚本片段
|
||||
private fun generateLogSetup(logFileName: String): String = """
|
||||
# 日志目录
|
||||
LOG_DIR="$LOG_DIR"
|
||||
LOG_FILE="${'$'}LOG_DIR/$logFileName"
|
||||
|
||||
# 创建日志目录
|
||||
mkdir -p "${'$'}LOG_DIR"
|
||||
|
||||
# 获取当前时间
|
||||
get_current_time() {
|
||||
date '+%Y-%m-%d %H:%M:%S'
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// 二进制文件检查的通用脚本片段
|
||||
private fun generateBinaryCheck(targetPath: String): String = """
|
||||
# 检查SuSFS二进制文件
|
||||
SUSFS_BIN="$targetPath"
|
||||
if [ ! -f "${'$'}SUSFS_BIN" ]; then
|
||||
echo "$(get_current_time): SuSFS二进制文件未找到: ${'$'}SUSFS_BIN" >> "${'$'}LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
""".trimIndent()
|
||||
|
||||
/**
|
||||
* 生成service.sh脚本内容
|
||||
*/
|
||||
@SuppressLint("SdCardPath")
|
||||
private fun generateServiceScript(config: SuSFSManager.ModuleConfig): String {
|
||||
return buildString {
|
||||
appendLine("#!/system/bin/sh")
|
||||
appendLine("# SuSFS Service Script")
|
||||
appendLine("# 在系统服务启动后执行")
|
||||
appendLine()
|
||||
appendLine(generateLogSetup("susfs_service.log"))
|
||||
appendLine()
|
||||
appendLine(generateBinaryCheck(config.targetPath))
|
||||
appendLine()
|
||||
|
||||
if (shouldConfigureInService(config)) {
|
||||
// 添加SUS路径 (仅在不支持隐藏挂载时)
|
||||
if (!config.support158 && config.susPaths.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
|
||||
appendLine("sleep 45")
|
||||
generateSusPathsSection(config.susPaths)
|
||||
}
|
||||
|
||||
// 设置uname和构建时间
|
||||
generateUnameSection(config)
|
||||
|
||||
// 添加Kstat配置
|
||||
generateKstatSection(config.kstatConfigs, config.addKstatPaths)
|
||||
}
|
||||
|
||||
// 添加日志设置
|
||||
generateLogSettingSection(config.enableLog)
|
||||
|
||||
// 隐藏BL相关配置
|
||||
if (config.enableHideBl) {
|
||||
generateHideBlSection()
|
||||
}
|
||||
|
||||
// 清理工具残留
|
||||
if (config.enableCleanupResidue) {
|
||||
generateCleanupResidueSection()
|
||||
}
|
||||
|
||||
appendLine("echo \"$(get_current_time): Service脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要在service中配置
|
||||
*/
|
||||
private fun shouldConfigureInService(config: SuSFSManager.ModuleConfig): Boolean {
|
||||
return config.susPaths.isNotEmpty() ||
|
||||
config.susLoopPaths.isNotEmpty() ||
|
||||
config.kstatConfigs.isNotEmpty() ||
|
||||
config.addKstatPaths.isNotEmpty() ||
|
||||
(!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME))
|
||||
}
|
||||
|
||||
private fun StringBuilder.generateLogSettingSection(enableLog: Boolean) {
|
||||
appendLine("# 设置日志启用状态")
|
||||
val logValue = if (enableLog) 1 else 0
|
||||
appendLine("\"${'$'}SUSFS_BIN\" enable_log $logValue")
|
||||
appendLine("echo \"$(get_current_time): 日志功能设置为: ${if (enableLog) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.generateAvcLogSpoofingSection(enableAvcLogSpoofing: Boolean) {
|
||||
appendLine("# 设置AVC日志欺骗状态")
|
||||
val avcLogValue = if (enableAvcLogSpoofing) 1 else 0
|
||||
appendLine("\"${'$'}SUSFS_BIN\" enable_avc_log_spoofing $avcLogValue")
|
||||
appendLine("echo \"$(get_current_time): AVC日志欺骗功能设置为: ${if (enableAvcLogSpoofing) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
private fun StringBuilder.generateSusPathsSection(susPaths: Set<String>) {
|
||||
if (susPaths.isNotEmpty()) {
|
||||
appendLine("# 添加SUS路径")
|
||||
susPaths.forEach { path ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path '$path'")
|
||||
appendLine("echo \"$(get_current_time): 添加SUS路径: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.generateSusLoopPathsSection(susLoopPaths: Set<String>) {
|
||||
if (susLoopPaths.isNotEmpty()) {
|
||||
appendLine("# 添加SUS循环路径")
|
||||
susLoopPaths.forEach { path ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_path_loop '$path'")
|
||||
appendLine("echo \"$(get_current_time): 添加SUS循环路径: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SdCardPath")
|
||||
private fun StringBuilder.generateKstatSection(
|
||||
kstatConfigs: Set<String>,
|
||||
addKstatPaths: Set<String>
|
||||
) {
|
||||
// 添加Kstat路径
|
||||
if (addKstatPaths.isNotEmpty()) {
|
||||
appendLine("# 添加Kstat路径")
|
||||
addKstatPaths.forEach { path ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat '$path'")
|
||||
appendLine("echo \"$(get_current_time): 添加Kstat路径: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
|
||||
// 添加Kstat静态配置
|
||||
if (kstatConfigs.isNotEmpty()) {
|
||||
appendLine("# 添加Kstat静态配置")
|
||||
kstatConfigs.forEach { config ->
|
||||
val parts = config.split("|")
|
||||
if (parts.size >= 13) {
|
||||
val path = parts[0]
|
||||
val params = parts.drop(1).joinToString("' '", "'", "'")
|
||||
appendLine()
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_kstat_statically '$path' $params")
|
||||
appendLine("echo \"$(get_current_time): 添加Kstat静态配置: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
appendLine("\"${'$'}SUSFS_BIN\" update_sus_kstat '$path'")
|
||||
appendLine("echo \"$(get_current_time): 更新Kstat配置: $path\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.generateUnameSection(config: SuSFSManager.ModuleConfig) {
|
||||
if (!config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
|
||||
appendLine("# 设置uname和构建时间")
|
||||
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'")
|
||||
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.generateHideBlSection() {
|
||||
appendLine("# 隐藏BL 来自 Shamiko 脚本")
|
||||
appendLine(
|
||||
"""
|
||||
RESETPROP_BIN="/data/adb/ksu/bin/resetprop"
|
||||
|
||||
check_reset_prop() {
|
||||
local NAME=$1
|
||||
local EXPECTED=$2
|
||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
||||
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
||||
}
|
||||
|
||||
check_missing_prop() {
|
||||
local NAME=$1
|
||||
local EXPECTED=$2
|
||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
||||
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
||||
}
|
||||
|
||||
check_missing_match_prop() {
|
||||
local NAME=$1
|
||||
local EXPECTED=$2
|
||||
local VALUE=$("${'$'}RESETPROP_BIN" ${'$'}NAME)
|
||||
[ -z ${'$'}VALUE ] || [ ${'$'}VALUE = ${'$'}EXPECTED ] || "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
||||
[ -z ${'$'}VALUE ] && "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}EXPECTED
|
||||
}
|
||||
|
||||
contains_reset_prop() {
|
||||
local NAME=$1
|
||||
local CONTAINS=$2
|
||||
local NEWVAL=$3
|
||||
case "$("${'$'}RESETPROP_BIN" ${'$'}NAME)" in
|
||||
*"${'$'}CONTAINS"*) "${'$'}RESETPROP_BIN" ${'$'}NAME ${'$'}NEWVAL ;;
|
||||
esac
|
||||
}
|
||||
""".trimIndent())
|
||||
appendLine()
|
||||
appendLine("sleep 30")
|
||||
appendLine()
|
||||
appendLine("\"${'$'}RESETPROP_BIN\" -w sys.boot_completed 0")
|
||||
|
||||
// 添加所有系统属性重置
|
||||
val systemProps = listOf(
|
||||
"ro.boot.vbmeta.invalidate_on_error" to "yes",
|
||||
"ro.boot.vbmeta.avb_version" to "1.2",
|
||||
"ro.boot.vbmeta.hash_alg" to "sha256",
|
||||
"ro.boot.vbmeta.size" to "19968",
|
||||
"ro.boot.vbmeta.device_state" to "locked",
|
||||
"ro.boot.verifiedbootstate" to "green",
|
||||
"ro.boot.flash.locked" to "1",
|
||||
"ro.boot.veritymode" to "enforcing",
|
||||
"ro.boot.warranty_bit" to "0",
|
||||
"ro.warranty_bit" to "0",
|
||||
"ro.debuggable" to "0",
|
||||
"ro.force.debuggable" to "0",
|
||||
"ro.secure" to "1",
|
||||
"ro.adb.secure" to "1",
|
||||
"ro.build.type" to "user",
|
||||
"ro.build.tags" to "release-keys",
|
||||
"ro.vendor.boot.warranty_bit" to "0",
|
||||
"ro.vendor.warranty_bit" to "0",
|
||||
"vendor.boot.vbmeta.device_state" to "locked",
|
||||
"vendor.boot.verifiedbootstate" to "green",
|
||||
"sys.oem_unlock_allowed" to "0",
|
||||
"ro.secureboot.lockstate" to "locked",
|
||||
"ro.boot.realmebootstate" to "green",
|
||||
"ro.boot.realme.lockstate" to "1",
|
||||
"ro.crypto.state" to "encrypted"
|
||||
)
|
||||
|
||||
systemProps.forEach { (prop, value) ->
|
||||
when {
|
||||
prop.startsWith("ro.boot.vbmeta") && prop.endsWith("_on_error") ->
|
||||
appendLine("check_missing_prop \"$prop\" \"$value\"")
|
||||
prop.contains("device_state") || prop.contains("verifiedbootstate") ->
|
||||
appendLine("check_missing_match_prop \"$prop\" \"$value\"")
|
||||
else ->
|
||||
appendLine("check_reset_prop \"$prop\" \"$value\"")
|
||||
}
|
||||
}
|
||||
|
||||
appendLine()
|
||||
appendLine("# Hide adb debugging traces")
|
||||
appendLine("resetprop \"sys.usb.adb.disabled\" \" \"")
|
||||
appendLine()
|
||||
|
||||
appendLine("# Hide recovery boot mode")
|
||||
appendLine("contains_reset_prop \"ro.bootmode\" \"recovery\" \"unknown\"")
|
||||
appendLine("contains_reset_prop \"ro.boot.bootmode\" \"recovery\" \"unknown\"")
|
||||
appendLine("contains_reset_prop \"vendor.boot.bootmode\" \"recovery\" \"unknown\"")
|
||||
appendLine()
|
||||
|
||||
appendLine("# Hide cloudphone detection")
|
||||
appendLine("[ -n \"$(resetprop ro.kernel.qemu)\" ] && resetprop ro.kernel.qemu \"\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
// 清理残留脚本生成
|
||||
private fun StringBuilder.generateCleanupResidueSection() {
|
||||
appendLine("# 清理工具残留文件")
|
||||
appendLine("echo \"$(get_current_time): 开始清理工具残留\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
|
||||
// 定义清理函数
|
||||
appendLine("""
|
||||
cleanup_path() {
|
||||
local path="$1"
|
||||
local desc="$2"
|
||||
local current="$3"
|
||||
local total="$4"
|
||||
|
||||
if [ -n "${'$'}desc" ]; then
|
||||
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path (${'$'}desc)" >> "${'$'}LOG_FILE"
|
||||
else
|
||||
echo "$(get_current_time): [${'$'}current/${'$'}total] 清理: ${'$'}path" >> "${'$'}LOG_FILE"
|
||||
fi
|
||||
|
||||
if rm -rf "${'$'}path" 2>/dev/null; then
|
||||
echo "$(get_current_time): ✓ 成功清理: ${'$'}path" >> "${'$'}LOG_FILE"
|
||||
else
|
||||
echo "$(get_current_time): ✗ 清理失败或不存在: ${'$'}path" >> "${'$'}LOG_FILE"
|
||||
fi
|
||||
}
|
||||
""".trimIndent())
|
||||
|
||||
appendLine()
|
||||
appendLine("# 开始清理各种工具残留")
|
||||
appendLine("TOTAL=33")
|
||||
appendLine()
|
||||
|
||||
val cleanupPaths = listOf(
|
||||
"/data/local/stryker/" to "Stryker残留",
|
||||
"/data/system/AppRetention" to "AppRetention残留",
|
||||
"/data/local/tmp/luckys" to "Luck Tool残留",
|
||||
"/data/local/tmp/HyperCeiler" to "西米露残留",
|
||||
"/data/local/tmp/simpleHook" to "simple Hook残留",
|
||||
"/data/local/tmp/DisabledAllGoogleServices" to "谷歌省电模块残留",
|
||||
"/data/local/MIO" to "解包软件",
|
||||
"/data/DNA" to "解包软件",
|
||||
"/data/local/tmp/cleaner_starter" to "质感清理残留",
|
||||
"/data/local/tmp/byyang" to "",
|
||||
"/data/local/tmp/mount_mask" to "",
|
||||
"/data/local/tmp/mount_mark" to "",
|
||||
"/data/local/tmp/scriptTMP" to "",
|
||||
"/data/local/luckys" to "",
|
||||
"/data/local/tmp/horae_control.log" to "",
|
||||
"/data/gpu_freq_table.conf" to "",
|
||||
"/storage/emulated/0/Download/advanced/" to "",
|
||||
"/storage/emulated/0/Documents/advanced/" to "爱玩机",
|
||||
"/storage/emulated/0/Android/naki/" to "旧版asoulopt",
|
||||
"/data/swap_config.conf" to "scene附加模块2",
|
||||
"/data/local/tmp/resetprop" to "",
|
||||
"/dev/cpuset/AppOpt/" to "AppOpt模块",
|
||||
"/storage/emulated/0/Android/Clash/" to "Clash for Magisk模块",
|
||||
"/storage/emulated/0/Android/Yume-Yunyun/" to "网易云后台优化模块",
|
||||
"/data/local/tmp/Surfing_update" to "Surfing模块缓存",
|
||||
"/data/encore/custom_default_cpu_gov" to "encore模块",
|
||||
"/data/encore/default_cpu_gov" to "encore模块",
|
||||
"/data/local/tmp/yshell" to "",
|
||||
"/data/local/tmp/encore_logo.png" to "",
|
||||
"/storage/emulated/legacy/" to "",
|
||||
"/storage/emulated/elgg/" to "",
|
||||
"/data/system/junge/" to "",
|
||||
"/data/local/tmp/mount_namespace" to "挂载命名空间残留"
|
||||
)
|
||||
|
||||
cleanupPaths.forEachIndexed { index, (path, desc) ->
|
||||
val current = index + 1
|
||||
appendLine("cleanup_path '$path' '$desc' $current \$TOTAL")
|
||||
}
|
||||
|
||||
appendLine()
|
||||
appendLine("echo \"$(get_current_time): 工具残留清理完成\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成post-fs-data.sh脚本内容
|
||||
*/
|
||||
private fun generatePostFsDataScript(config: SuSFSManager.ModuleConfig): String {
|
||||
return buildString {
|
||||
appendLine("#!/system/bin/sh")
|
||||
appendLine("# SuSFS Post-FS-Data Script")
|
||||
appendLine("# 在文件系统挂载后但在系统完全启动前执行")
|
||||
appendLine()
|
||||
appendLine(generateLogSetup("susfs_post_fs_data.log"))
|
||||
appendLine()
|
||||
appendLine(generateBinaryCheck(config.targetPath))
|
||||
appendLine()
|
||||
appendLine("echo \"$(get_current_time): Post-FS-Data脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
|
||||
// 设置uname和构建时间 - 只有在选择在post-fs-data中执行时才执行
|
||||
if (config.executeInPostFsData && (config.unameValue != DEFAULT_UNAME || config.buildTimeValue != DEFAULT_BUILD_TIME)) {
|
||||
appendLine("# 设置uname和构建时间")
|
||||
appendLine("\"${'$'}SUSFS_BIN\" set_uname '${config.unameValue}' '${config.buildTimeValue}'")
|
||||
appendLine("echo \"$(get_current_time): 设置uname为: ${config.unameValue}, 构建时间为: ${config.buildTimeValue}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
generateUmountZygoteIsoServiceSection(config.umountForZygoteIsoService, config.support158)
|
||||
|
||||
// 添加AVC日志欺骗设置
|
||||
generateAvcLogSpoofingSection(config.enableAvcLogSpoofing)
|
||||
|
||||
appendLine("echo \"$(get_current_time): Post-FS-Data脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新的生成方法
|
||||
private fun StringBuilder.generateUmountZygoteIsoServiceSection(umountForZygoteIsoService: Boolean, support158: Boolean) {
|
||||
if (support158) {
|
||||
appendLine("# 设置Zygote隔离服务卸载状态")
|
||||
val umountValue = if (umountForZygoteIsoService) 1 else 0
|
||||
appendLine("\"${'$'}SUSFS_BIN\" umount_for_zygote_iso_service $umountValue")
|
||||
appendLine("echo \"$(get_current_time): Zygote隔离服务卸载设置为: ${if (umountForZygoteIsoService) "启用" else "禁用"}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成post-mount.sh脚本内容
|
||||
*/
|
||||
private fun generatePostMountScript(config: SuSFSManager.ModuleConfig): String {
|
||||
return buildString {
|
||||
appendLine("#!/system/bin/sh")
|
||||
appendLine("# SuSFS Post-Mount Script")
|
||||
appendLine("# 在所有分区挂载完成后执行")
|
||||
appendLine()
|
||||
appendLine(generateLogSetup("susfs_post_mount.log"))
|
||||
appendLine()
|
||||
appendLine("echo \"$(get_current_time): Post-Mount脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
appendLine(generateBinaryCheck(config.targetPath))
|
||||
appendLine()
|
||||
|
||||
// 添加SUS挂载
|
||||
if (config.susMounts.isNotEmpty()) {
|
||||
appendLine("# 添加SUS挂载")
|
||||
config.susMounts.forEach { mount ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_mount '$mount'")
|
||||
appendLine("echo \"$(get_current_time): 添加SUS挂载: $mount\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
|
||||
// 添加尝试卸载
|
||||
if (config.tryUmounts.isNotEmpty()) {
|
||||
appendLine("# 添加尝试卸载")
|
||||
config.tryUmounts.forEach { umount ->
|
||||
val parts = umount.split("|")
|
||||
if (parts.size == 2) {
|
||||
val path = parts[0]
|
||||
val mode = parts[1]
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_try_umount '$path' $mode")
|
||||
appendLine("echo \"$(get_current_time): 添加尝试卸载: $path (模式: $mode)\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
|
||||
appendLine("echo \"$(get_current_time): Post-Mount脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成boot-completed.sh脚本内容
|
||||
*/
|
||||
@SuppressLint("SdCardPath")
|
||||
private fun generateBootCompletedScript(config: SuSFSManager.ModuleConfig): String {
|
||||
return buildString {
|
||||
appendLine("#!/system/bin/sh")
|
||||
appendLine("# SuSFS Boot-Completed Script")
|
||||
appendLine("# 在系统完全启动后执行")
|
||||
appendLine()
|
||||
appendLine(generateLogSetup("susfs_boot_completed.log"))
|
||||
appendLine()
|
||||
appendLine("echo \"$(get_current_time): Boot-Completed脚本开始执行\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
appendLine(generateBinaryCheck(config.targetPath))
|
||||
appendLine()
|
||||
|
||||
// 仅在支持隐藏挂载功能时执行相关配置
|
||||
if (config.support158) {
|
||||
// SUS挂载隐藏控制
|
||||
val hideValue = if (config.hideSusMountsForAllProcs) 1 else 0
|
||||
appendLine("# 设置SUS挂载隐藏控制")
|
||||
appendLine("\"${'$'}SUSFS_BIN\" hide_sus_mnts_for_all_procs $hideValue")
|
||||
appendLine("echo \"$(get_current_time): SUS挂载隐藏控制设置为: ${if (config.hideSusMountsForAllProcs) "对所有进程隐藏" else "仅对非KSU进程隐藏"}\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
|
||||
// 路径设置和SUS路径设置
|
||||
if (config.susPaths.isNotEmpty() || config.susLoopPaths.isNotEmpty()) {
|
||||
generatePathSettingSection(config.androidDataPath, config.sdcardPath)
|
||||
appendLine()
|
||||
|
||||
// 添加普通SUS路径
|
||||
if (config.susPaths.isNotEmpty()) {
|
||||
generateSusPathsSection(config.susPaths)
|
||||
}
|
||||
|
||||
// 添加循环SUS路径
|
||||
if (config.susLoopPaths.isNotEmpty()) {
|
||||
generateSusLoopPathsSection(config.susLoopPaths)
|
||||
}
|
||||
|
||||
if (config.susMaps.isNotEmpty()) {
|
||||
generateSusMapsSection(config.susMaps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendLine("echo \"$(get_current_time): Boot-Completed脚本执行完成\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.generateSusMapsSection(susMaps: Set<String>) {
|
||||
if (susMaps.isNotEmpty()) {
|
||||
appendLine("# 添加SUS映射")
|
||||
susMaps.forEach { map ->
|
||||
appendLine("\"${'$'}SUSFS_BIN\" add_sus_map '$map'")
|
||||
appendLine("echo \"$(get_current_time): 添加SUS映射: $map\" >> \"${'$'}LOG_FILE\"")
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SdCardPath")
|
||||
private fun StringBuilder.generatePathSettingSection(androidDataPath: String, sdcardPath: String) {
|
||||
appendLine("# 路径配置")
|
||||
appendLine("# 设置Android Data路径")
|
||||
appendLine("until [ -d \"/sdcard/Android\" ]; do sleep 1; done")
|
||||
appendLine("sleep 60")
|
||||
appendLine()
|
||||
appendLine("\"${'$'}SUSFS_BIN\" set_android_data_root_path '$androidDataPath'")
|
||||
appendLine("echo \"$(get_current_time): Android Data路径设置为: $androidDataPath\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
appendLine("# 设置SD卡路径")
|
||||
appendLine("\"${'$'}SUSFS_BIN\" set_sdcard_root_path '$sdcardPath'")
|
||||
appendLine("echo \"$(get_current_time): SD卡路径设置为: $sdcardPath\" >> \"${'$'}LOG_FILE\"")
|
||||
appendLine()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成module.prop文件内容
|
||||
*/
|
||||
fun generateModuleProp(moduleId: String): String {
|
||||
val moduleVersion = "v1.0.2"
|
||||
val moduleVersionCode = "1002"
|
||||
|
||||
return """
|
||||
id=$moduleId
|
||||
name=SuSFS Manager
|
||||
version=$moduleVersion
|
||||
versionCode=$moduleVersionCode
|
||||
author=ShirkNeko
|
||||
description=SuSFS Manager Auto Configuration Module (自动生成请不要手动卸载或删除该模块! / Automatically generated Please do not manually uninstall or delete the module!)
|
||||
updateJson=
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package com.sukisu.ultra.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Stable
|
||||
object CardConfig {
|
||||
// 卡片透明度
|
||||
var cardAlpha by mutableFloatStateOf(1f)
|
||||
internal set
|
||||
// 卡片亮度
|
||||
var cardDim by mutableFloatStateOf(0f)
|
||||
internal set
|
||||
// 卡片阴影
|
||||
var cardElevation by mutableStateOf(0.dp)
|
||||
internal set
|
||||
|
||||
// 功能开关
|
||||
var isShadowEnabled by mutableStateOf(true)
|
||||
internal set
|
||||
var isCustomBackgroundEnabled by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
var isCustomAlphaSet by mutableStateOf(false)
|
||||
internal set
|
||||
var isCustomDimSet by mutableStateOf(false)
|
||||
internal set
|
||||
var isUserDarkModeEnabled by mutableStateOf(false)
|
||||
internal set
|
||||
var isUserLightModeEnabled by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
// 配置键名
|
||||
private object Keys {
|
||||
const val CARD_ALPHA = "card_alpha"
|
||||
const val CARD_DIM = "card_dim"
|
||||
const val CUSTOM_BACKGROUND_ENABLED = "custom_background_enabled"
|
||||
const val IS_SHADOW_ENABLED = "is_shadow_enabled"
|
||||
const val IS_CUSTOM_ALPHA_SET = "is_custom_alpha_set"
|
||||
const val IS_CUSTOM_DIM_SET = "is_custom_dim_set"
|
||||
const val IS_USER_DARK_MODE_ENABLED = "is_user_dark_mode_enabled"
|
||||
const val IS_USER_LIGHT_MODE_ENABLED = "is_user_light_mode_enabled"
|
||||
}
|
||||
|
||||
fun updateAlpha(alpha: Float, isCustom: Boolean = true) {
|
||||
cardAlpha = alpha.coerceIn(0f, 1f)
|
||||
if (isCustom) isCustomAlphaSet = true
|
||||
}
|
||||
|
||||
fun updateDim(dim: Float, isCustom: Boolean = true) {
|
||||
cardDim = dim.coerceIn(0f, 1f)
|
||||
if (isCustom) isCustomDimSet = true
|
||||
}
|
||||
|
||||
fun updateShadow(enabled: Boolean, elevation: Dp = cardElevation) {
|
||||
isShadowEnabled = enabled
|
||||
cardElevation = if (enabled) elevation else cardElevation
|
||||
}
|
||||
|
||||
fun updateBackground(enabled: Boolean) {
|
||||
isCustomBackgroundEnabled = enabled
|
||||
// 自定义背景时自动禁用阴影以获得更好的视觉效果
|
||||
if (enabled) {
|
||||
updateShadow(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateThemePreference(darkMode: Boolean?, lightMode: Boolean?) {
|
||||
isUserDarkModeEnabled = darkMode ?: false
|
||||
isUserLightModeEnabled = lightMode ?: false
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
cardAlpha = 1f
|
||||
cardDim = 0f
|
||||
cardElevation = 0.dp
|
||||
isShadowEnabled = true
|
||||
isCustomBackgroundEnabled = false
|
||||
isCustomAlphaSet = false
|
||||
isCustomDimSet = false
|
||||
isUserDarkModeEnabled = false
|
||||
isUserLightModeEnabled = false
|
||||
}
|
||||
|
||||
fun setThemeDefaults(isDarkMode: Boolean) {
|
||||
if (!isCustomAlphaSet) {
|
||||
updateAlpha(if (isDarkMode) 0.88f else 1f, false)
|
||||
}
|
||||
if (!isCustomDimSet) {
|
||||
updateDim(if (isDarkMode) 0.25f else 0f, false)
|
||||
}
|
||||
// 暗色模式下默认启用轻微阴影
|
||||
if (isDarkMode && !isCustomBackgroundEnabled) {
|
||||
updateShadow(true, 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
fun save(context: Context) {
|
||||
val prefs = context.getSharedPreferences("card_settings", Context.MODE_PRIVATE)
|
||||
prefs.edit().apply {
|
||||
putFloat(Keys.CARD_ALPHA, cardAlpha)
|
||||
putFloat(Keys.CARD_DIM, cardDim)
|
||||
putBoolean(Keys.CUSTOM_BACKGROUND_ENABLED, isCustomBackgroundEnabled)
|
||||
putBoolean(Keys.IS_SHADOW_ENABLED, isShadowEnabled)
|
||||
putBoolean(Keys.IS_CUSTOM_ALPHA_SET, isCustomAlphaSet)
|
||||
putBoolean(Keys.IS_CUSTOM_DIM_SET, isCustomDimSet)
|
||||
putBoolean(Keys.IS_USER_DARK_MODE_ENABLED, isUserDarkModeEnabled)
|
||||
putBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, isUserLightModeEnabled)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun load(context: Context) {
|
||||
val prefs = context.getSharedPreferences("card_settings", Context.MODE_PRIVATE)
|
||||
cardAlpha = prefs.getFloat(Keys.CARD_ALPHA, 1f).coerceIn(0f, 1f)
|
||||
cardDim = prefs.getFloat(Keys.CARD_DIM, 0f).coerceIn(0f, 1f)
|
||||
isCustomBackgroundEnabled = prefs.getBoolean(Keys.CUSTOM_BACKGROUND_ENABLED, false)
|
||||
isShadowEnabled = prefs.getBoolean(Keys.IS_SHADOW_ENABLED, true)
|
||||
isCustomAlphaSet = prefs.getBoolean(Keys.IS_CUSTOM_ALPHA_SET, false)
|
||||
isCustomDimSet = prefs.getBoolean(Keys.IS_CUSTOM_DIM_SET, false)
|
||||
isUserDarkModeEnabled = prefs.getBoolean(Keys.IS_USER_DARK_MODE_ENABLED, false)
|
||||
isUserLightModeEnabled = prefs.getBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, false)
|
||||
|
||||
// 应用阴影设置
|
||||
updateShadow(isShadowEnabled, if (isShadowEnabled) cardElevation else 0.dp)
|
||||
}
|
||||
|
||||
@Deprecated("使用 updateShadow 替代", ReplaceWith("updateShadow(enabled)"))
|
||||
fun updateShadowEnabled(enabled: Boolean) {
|
||||
updateShadow(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
object CardStyleProvider {
|
||||
|
||||
@Composable
|
||||
fun getCardColors(originalColor: Color) = CardDefaults.cardColors(
|
||||
containerColor = originalColor.copy(alpha = CardConfig.cardAlpha),
|
||||
contentColor = determineContentColor(originalColor),
|
||||
disabledContainerColor = originalColor.copy(alpha = CardConfig.cardAlpha * 0.38f),
|
||||
disabledContentColor = determineContentColor(originalColor).copy(alpha = 0.38f)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun getCardElevation() = CardDefaults.cardElevation(
|
||||
defaultElevation = CardConfig.cardElevation,
|
||||
pressedElevation = if (CardConfig.isShadowEnabled) {
|
||||
(CardConfig.cardElevation.value + 0).dp
|
||||
} else 0.dp,
|
||||
focusedElevation = if (CardConfig.isShadowEnabled) {
|
||||
(CardConfig.cardElevation.value + 0).dp
|
||||
} else 0.dp,
|
||||
hoveredElevation = if (CardConfig.isShadowEnabled) {
|
||||
(CardConfig.cardElevation.value + 0).dp
|
||||
} else 0.dp,
|
||||
draggedElevation = if (CardConfig.isShadowEnabled) {
|
||||
(CardConfig.cardElevation.value + 0).dp
|
||||
} else 0.dp,
|
||||
disabledElevation = 0.dp
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun determineContentColor(originalColor: Color): Color {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
return when {
|
||||
ThemeConfig.isThemeChanging -> {
|
||||
if (isDarkTheme) Color.White else Color.Black
|
||||
}
|
||||
CardConfig.isUserLightModeEnabled -> Color.Black
|
||||
CardConfig.isUserDarkModeEnabled -> Color.White
|
||||
else -> {
|
||||
val luminance = originalColor.luminance()
|
||||
val threshold = if (isDarkTheme) 0.4f else 0.6f
|
||||
if (luminance > threshold) Color.Black else Color.White
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容
|
||||
@Composable
|
||||
fun getCardColors(originalColor: Color) = CardStyleProvider.getCardColors(originalColor)
|
||||
|
||||
@Composable
|
||||
fun getCardElevation() = CardStyleProvider.getCardElevation()
|
||||
@@ -1,615 +0,0 @@
|
||||
package com.sukisu.ultra.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
sealed class ThemeColors {
|
||||
// 浅色
|
||||
abstract val primaryLight: Color
|
||||
abstract val onPrimaryLight: Color
|
||||
abstract val primaryContainerLight: Color
|
||||
abstract val onPrimaryContainerLight: Color
|
||||
abstract val secondaryLight: Color
|
||||
abstract val onSecondaryLight: Color
|
||||
abstract val secondaryContainerLight: Color
|
||||
abstract val onSecondaryContainerLight: Color
|
||||
abstract val tertiaryLight: Color
|
||||
abstract val onTertiaryLight: Color
|
||||
abstract val tertiaryContainerLight: Color
|
||||
abstract val onTertiaryContainerLight: Color
|
||||
abstract val errorLight: Color
|
||||
abstract val onErrorLight: Color
|
||||
abstract val errorContainerLight: Color
|
||||
abstract val onErrorContainerLight: Color
|
||||
abstract val backgroundLight: Color
|
||||
abstract val onBackgroundLight: Color
|
||||
abstract val surfaceLight: Color
|
||||
abstract val onSurfaceLight: Color
|
||||
abstract val surfaceVariantLight: Color
|
||||
abstract val onSurfaceVariantLight: Color
|
||||
abstract val outlineLight: Color
|
||||
abstract val outlineVariantLight: Color
|
||||
abstract val scrimLight: Color
|
||||
abstract val inverseSurfaceLight: Color
|
||||
abstract val inverseOnSurfaceLight: Color
|
||||
abstract val inversePrimaryLight: Color
|
||||
abstract val surfaceDimLight: Color
|
||||
abstract val surfaceBrightLight: Color
|
||||
abstract val surfaceContainerLowestLight: Color
|
||||
abstract val surfaceContainerLowLight: Color
|
||||
abstract val surfaceContainerLight: Color
|
||||
abstract val surfaceContainerHighLight: Color
|
||||
abstract val surfaceContainerHighestLight: Color
|
||||
// 深色
|
||||
abstract val primaryDark: Color
|
||||
abstract val onPrimaryDark: Color
|
||||
abstract val primaryContainerDark: Color
|
||||
abstract val onPrimaryContainerDark: Color
|
||||
abstract val secondaryDark: Color
|
||||
abstract val onSecondaryDark: Color
|
||||
abstract val secondaryContainerDark: Color
|
||||
abstract val onSecondaryContainerDark: Color
|
||||
abstract val tertiaryDark: Color
|
||||
abstract val onTertiaryDark: Color
|
||||
abstract val tertiaryContainerDark: Color
|
||||
abstract val onTertiaryContainerDark: Color
|
||||
abstract val errorDark: Color
|
||||
abstract val onErrorDark: Color
|
||||
abstract val errorContainerDark: Color
|
||||
abstract val onErrorContainerDark: Color
|
||||
abstract val backgroundDark: Color
|
||||
abstract val onBackgroundDark: Color
|
||||
abstract val surfaceDark: Color
|
||||
abstract val onSurfaceDark: Color
|
||||
abstract val surfaceVariantDark: Color
|
||||
abstract val onSurfaceVariantDark: Color
|
||||
abstract val outlineDark: Color
|
||||
abstract val outlineVariantDark: Color
|
||||
abstract val scrimDark: Color
|
||||
abstract val inverseSurfaceDark: Color
|
||||
abstract val inverseOnSurfaceDark: Color
|
||||
abstract val inversePrimaryDark: Color
|
||||
abstract val surfaceDimDark: Color
|
||||
abstract val surfaceBrightDark: Color
|
||||
abstract val surfaceContainerLowestDark: Color
|
||||
abstract val surfaceContainerLowDark: Color
|
||||
abstract val surfaceContainerDark: Color
|
||||
abstract val surfaceContainerHighDark: Color
|
||||
abstract val surfaceContainerHighestDark: Color
|
||||
|
||||
// 默认主题 (蓝色)
|
||||
object Default : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF415F91)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFD6E3FF)
|
||||
override val onPrimaryContainerLight = Color(0xFF284777)
|
||||
override val secondaryLight = Color(0xFF565F71)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFDAE2F9)
|
||||
override val onSecondaryContainerLight = Color(0xFF3E4759)
|
||||
override val tertiaryLight = Color(0xFF705575)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFFAD8FD)
|
||||
override val onTertiaryContainerLight = Color(0xFF573E5C)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFF9F9FF)
|
||||
override val onBackgroundLight = Color(0xFF191C20)
|
||||
override val surfaceLight = Color(0xFFF9F9FF)
|
||||
override val onSurfaceLight = Color(0xFF191C20)
|
||||
override val surfaceVariantLight = Color(0xFFE0E2EC)
|
||||
override val onSurfaceVariantLight = Color(0xFF44474E)
|
||||
override val outlineLight = Color(0xFF74777F)
|
||||
override val outlineVariantLight = Color(0xFFC4C6D0)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF2E3036)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF0F0F7)
|
||||
override val inversePrimaryLight = Color(0xFFAAC7FF)
|
||||
override val surfaceDimLight = Color(0xFFD9D9E0)
|
||||
override val surfaceBrightLight = Color(0xFFF9F9FF)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFF3F3FA)
|
||||
override val surfaceContainerLight = Color(0xFFEDEDF4)
|
||||
override val surfaceContainerHighLight = Color(0xFFE7E8EE)
|
||||
override val surfaceContainerHighestLight = Color(0xFFE2E2E9)
|
||||
|
||||
override val primaryDark = Color(0xFFAAC7FF)
|
||||
override val onPrimaryDark = Color(0xFF0A305F)
|
||||
override val primaryContainerDark = Color(0xFF284777)
|
||||
override val onPrimaryContainerDark = Color(0xFFD6E3FF)
|
||||
override val secondaryDark = Color(0xFFBEC6DC)
|
||||
override val onSecondaryDark = Color(0xFF283141)
|
||||
override val secondaryContainerDark = Color(0xFF3E4759)
|
||||
override val onSecondaryContainerDark = Color(0xFFDAE2F9)
|
||||
override val tertiaryDark = Color(0xFFDDBCE0)
|
||||
override val onTertiaryDark = Color(0xFF3F2844)
|
||||
override val tertiaryContainerDark = Color(0xFF573E5C)
|
||||
override val onTertiaryContainerDark = Color(0xFFFAD8FD)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF111318)
|
||||
override val onBackgroundDark = Color(0xFFE2E2E9)
|
||||
override val surfaceDark = Color(0xFF111318)
|
||||
override val onSurfaceDark = Color(0xFFE2E2E9)
|
||||
override val surfaceVariantDark = Color(0xFF44474E)
|
||||
override val onSurfaceVariantDark = Color(0xFFC4C6D0)
|
||||
override val outlineDark = Color(0xFF8E9099)
|
||||
override val outlineVariantDark = Color(0xFF44474E)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFE2E2E9)
|
||||
override val inverseOnSurfaceDark = Color(0xFF2E3036)
|
||||
override val inversePrimaryDark = Color(0xFF415F91)
|
||||
override val surfaceDimDark = Color(0xFF111318)
|
||||
override val surfaceBrightDark = Color(0xFF37393E)
|
||||
override val surfaceContainerLowestDark = Color(0xFF0C0E13)
|
||||
override val surfaceContainerLowDark = Color(0xFF191C20)
|
||||
override val surfaceContainerDark = Color(0xFF1D2024)
|
||||
override val surfaceContainerHighDark = Color(0xFF282A2F)
|
||||
override val surfaceContainerHighestDark = Color(0xFF33353A)
|
||||
}
|
||||
|
||||
// 绿色主题
|
||||
object Green : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF4C662B)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFCDEDA3)
|
||||
override val onPrimaryContainerLight = Color(0xFF354E16)
|
||||
override val secondaryLight = Color(0xFF586249)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFDCE7C8)
|
||||
override val onSecondaryContainerLight = Color(0xFF404A33)
|
||||
override val tertiaryLight = Color(0xFF386663)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFBCECE7)
|
||||
override val onTertiaryContainerLight = Color(0xFF1F4E4B)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFF9FAEF)
|
||||
override val onBackgroundLight = Color(0xFF1A1C16)
|
||||
override val surfaceLight = Color(0xFFF9FAEF)
|
||||
override val onSurfaceLight = Color(0xFF1A1C16)
|
||||
override val surfaceVariantLight = Color(0xFFE1E4D5)
|
||||
override val onSurfaceVariantLight = Color(0xFF44483D)
|
||||
override val outlineLight = Color(0xFF75796C)
|
||||
override val outlineVariantLight = Color(0xFFC5C8BA)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF2F312A)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF1F2E6)
|
||||
override val inversePrimaryLight = Color(0xFFB1D18A)
|
||||
override val surfaceDimLight = Color(0xFFDADBD0)
|
||||
override val surfaceBrightLight = Color(0xFFF9FAEF)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFF3F4E9)
|
||||
override val surfaceContainerLight = Color(0xFFEEEFE3)
|
||||
override val surfaceContainerHighLight = Color(0xFFE8E9DE)
|
||||
override val surfaceContainerHighestLight = Color(0xFFE2E3D8)
|
||||
|
||||
override val primaryDark = Color(0xFFB1D18A)
|
||||
override val onPrimaryDark = Color(0xFF1F3701)
|
||||
override val primaryContainerDark = Color(0xFF354E16)
|
||||
override val onPrimaryContainerDark = Color(0xFFCDEDA3)
|
||||
override val secondaryDark = Color(0xFFBFCBAD)
|
||||
override val onSecondaryDark = Color(0xFF2A331E)
|
||||
override val secondaryContainerDark = Color(0xFF404A33)
|
||||
override val onSecondaryContainerDark = Color(0xFFDCE7C8)
|
||||
override val tertiaryDark = Color(0xFFA0D0CB)
|
||||
override val onTertiaryDark = Color(0xFF003735)
|
||||
override val tertiaryContainerDark = Color(0xFF1F4E4B)
|
||||
override val onTertiaryContainerDark = Color(0xFFBCECE7)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF12140E)
|
||||
override val onBackgroundDark = Color(0xFFE2E3D8)
|
||||
override val surfaceDark = Color(0xFF12140E)
|
||||
override val onSurfaceDark = Color(0xFFE2E3D8)
|
||||
override val surfaceVariantDark = Color(0xFF44483D)
|
||||
override val onSurfaceVariantDark = Color(0xFFC5C8BA)
|
||||
override val outlineDark = Color(0xFF8F9285)
|
||||
override val outlineVariantDark = Color(0xFF44483D)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFE2E3D8)
|
||||
override val inverseOnSurfaceDark = Color(0xFF2F312A)
|
||||
override val inversePrimaryDark = Color(0xFF4C662B)
|
||||
override val surfaceDimDark = Color(0xFF12140E)
|
||||
override val surfaceBrightDark = Color(0xFF383A32)
|
||||
override val surfaceContainerLowestDark = Color(0xFF0C0F09)
|
||||
override val surfaceContainerLowDark = Color(0xFF1A1C16)
|
||||
override val surfaceContainerDark = Color(0xFF1E201A)
|
||||
override val surfaceContainerHighDark = Color(0xFF282B24)
|
||||
override val surfaceContainerHighestDark = Color(0xFF33362E)
|
||||
}
|
||||
|
||||
// 紫色主题
|
||||
object Purple : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF7C4E7E)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFFFD6FC)
|
||||
override val onPrimaryContainerLight = Color(0xFF623765)
|
||||
override val secondaryLight = Color(0xFF6C586B)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFF5DBF1)
|
||||
override val onSecondaryContainerLight = Color(0xFF534152)
|
||||
override val tertiaryLight = Color(0xFF825249)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFFFDAD4)
|
||||
override val onTertiaryContainerLight = Color(0xFF673B33)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFFF7FA)
|
||||
override val onBackgroundLight = Color(0xFF1F1A1F)
|
||||
override val surfaceLight = Color(0xFFFFF7FA)
|
||||
override val onSurfaceLight = Color(0xFF1F1A1F)
|
||||
override val surfaceVariantLight = Color(0xFFEDDFE8)
|
||||
override val onSurfaceVariantLight = Color(0xFF4D444C)
|
||||
override val outlineLight = Color(0xFF7F747C)
|
||||
override val outlineVariantLight = Color(0xFFD0C3CC)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF352F34)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF9EEF4)
|
||||
override val inversePrimaryLight = Color(0xFFECB4EC)
|
||||
override val surfaceDimLight = Color(0xFFE2D7DE)
|
||||
override val surfaceBrightLight = Color(0xFFFFF7FA)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFFCF0F7)
|
||||
override val surfaceContainerLight = Color(0xFFF6EBF2)
|
||||
override val surfaceContainerHighLight = Color(0xFFF0E5EC)
|
||||
override val surfaceContainerHighestLight = Color(0xFFEBDFE6)
|
||||
|
||||
override val primaryDark = Color(0xFFECB4EC)
|
||||
override val onPrimaryDark = Color(0xFF49204D)
|
||||
override val primaryContainerDark = Color(0xFF623765)
|
||||
override val onPrimaryContainerDark = Color(0xFFFFD6FC)
|
||||
override val secondaryDark = Color(0xFFD8BFD5)
|
||||
override val onSecondaryDark = Color(0xFF3B2B3B)
|
||||
override val secondaryContainerDark = Color(0xFF534152)
|
||||
override val onSecondaryContainerDark = Color(0xFFF5DBF1)
|
||||
override val tertiaryDark = Color(0xFFF6B8AD)
|
||||
override val onTertiaryDark = Color(0xFF4C251F)
|
||||
override val tertiaryContainerDark = Color(0xFF673B33)
|
||||
override val onTertiaryContainerDark = Color(0xFFFFDAD4)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF171216)
|
||||
override val onBackgroundDark = Color(0xFFEBDFE6)
|
||||
override val surfaceDark = Color(0xFF171216)
|
||||
override val onSurfaceDark = Color(0xFFEBDFE6)
|
||||
override val surfaceVariantDark = Color(0xFF4D444C)
|
||||
override val onSurfaceVariantDark = Color(0xFFD0C3CC)
|
||||
override val outlineDark = Color(0xFF998D96)
|
||||
override val outlineVariantDark = Color(0xFF4D444C)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFEBDFE6)
|
||||
override val inverseOnSurfaceDark = Color(0xFF352F34)
|
||||
override val inversePrimaryDark = Color(0xFF7C4E7E)
|
||||
override val surfaceDimDark = Color(0xFF171216)
|
||||
override val surfaceBrightDark = Color(0xFF3E373D)
|
||||
override val surfaceContainerLowestDark = Color(0xFF110D11)
|
||||
override val surfaceContainerLowDark = Color(0xFF1F1A1F)
|
||||
override val surfaceContainerDark = Color(0xFF231E23)
|
||||
override val surfaceContainerHighDark = Color(0xFF2E282D)
|
||||
override val surfaceContainerHighestDark = Color(0xFF393338)
|
||||
}
|
||||
|
||||
// 橙色主题
|
||||
object Orange : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF8B4F24)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFFFDCC7)
|
||||
override val onPrimaryContainerLight = Color(0xFF6E390E)
|
||||
override val secondaryLight = Color(0xFF755846)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFFFDCC7)
|
||||
override val onSecondaryContainerLight = Color(0xFF5B4130)
|
||||
override val tertiaryLight = Color(0xFF865219)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFFFDCBF)
|
||||
override val onTertiaryContainerLight = Color(0xFF6A3B01)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFFF8F5)
|
||||
override val onBackgroundLight = Color(0xFF221A15)
|
||||
override val surfaceLight = Color(0xFFFFF8F5)
|
||||
override val onSurfaceLight = Color(0xFF221A15)
|
||||
override val surfaceVariantLight = Color(0xFFF4DED3)
|
||||
override val onSurfaceVariantLight = Color(0xFF52443C)
|
||||
override val outlineLight = Color(0xFF84746A)
|
||||
override val outlineVariantLight = Color(0xFFD7C3B8)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF382E29)
|
||||
override val inverseOnSurfaceLight = Color(0xFFFFEDE5)
|
||||
override val inversePrimaryLight = Color(0xFFFFB787)
|
||||
override val surfaceDimLight = Color(0xFFE7D7CE)
|
||||
override val surfaceBrightLight = Color(0xFFFFF8F5)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFFFF1EA)
|
||||
override val surfaceContainerLight = Color(0xFFFCEBE2)
|
||||
override val surfaceContainerHighLight = Color(0xFFF6E5DC)
|
||||
override val surfaceContainerHighestLight = Color(0xFFF0DFD7)
|
||||
|
||||
override val primaryDark = Color(0xFFFFB787)
|
||||
override val onPrimaryDark = Color(0xFF502400)
|
||||
override val primaryContainerDark = Color(0xFF6E390E)
|
||||
override val onPrimaryContainerDark = Color(0xFFFFDCC7)
|
||||
override val secondaryDark = Color(0xFFE5BFA8)
|
||||
override val onSecondaryDark = Color(0xFF422B1B)
|
||||
override val secondaryContainerDark = Color(0xFF5B4130)
|
||||
override val onSecondaryContainerDark = Color(0xFFFFDCC7)
|
||||
override val tertiaryDark = Color(0xFFFDB876)
|
||||
override val onTertiaryDark = Color(0xFF4B2800)
|
||||
override val tertiaryContainerDark = Color(0xFF6A3B01)
|
||||
override val onTertiaryContainerDark = Color(0xFFFFDCBF)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF19120D)
|
||||
override val onBackgroundDark = Color(0xFFF0DFD7)
|
||||
override val surfaceDark = Color(0xFF19120D)
|
||||
override val onSurfaceDark = Color(0xFFF0DFD7)
|
||||
override val surfaceVariantDark = Color(0xFF52443C)
|
||||
override val onSurfaceVariantDark = Color(0xFFD7C3B8)
|
||||
override val outlineDark = Color(0xFF9F8D83)
|
||||
override val outlineVariantDark = Color(0xFF52443C)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFF0DFD7)
|
||||
override val inverseOnSurfaceDark = Color(0xFF382E29)
|
||||
override val inversePrimaryDark = Color(0xFF8B4F24)
|
||||
override val surfaceDimDark = Color(0xFF19120D)
|
||||
override val surfaceBrightDark = Color(0xFF413731)
|
||||
override val surfaceContainerLowestDark = Color(0xFF140D08)
|
||||
override val surfaceContainerLowDark = Color(0xFF221A15)
|
||||
override val surfaceContainerDark = Color(0xFF261E19)
|
||||
override val surfaceContainerHighDark = Color(0xFF312823)
|
||||
override val surfaceContainerHighestDark = Color(0xFF3D332D)
|
||||
}
|
||||
|
||||
// 粉色主题
|
||||
object Pink : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF8C4A60)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFFFD9E2)
|
||||
override val onPrimaryContainerLight = Color(0xFF703348)
|
||||
override val secondaryLight = Color(0xFF8B4A62)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFFFD9E3)
|
||||
override val onSecondaryContainerLight = Color(0xFF6F334B)
|
||||
override val tertiaryLight = Color(0xFF8B4A62)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFFFD9E3)
|
||||
override val onTertiaryContainerLight = Color(0xFF6F334B)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFFF8F8)
|
||||
override val onBackgroundLight = Color(0xFF22191B)
|
||||
override val surfaceLight = Color(0xFFFFF8F8)
|
||||
override val onSurfaceLight = Color(0xFF22191B)
|
||||
override val surfaceVariantLight = Color(0xFFF2DDE1)
|
||||
override val onSurfaceVariantLight = Color(0xFF514346)
|
||||
override val outlineLight = Color(0xFF837377)
|
||||
override val outlineVariantLight = Color(0xFFD5C2C5)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF372E30)
|
||||
override val inverseOnSurfaceLight = Color(0xFFFDEDEF)
|
||||
override val inversePrimaryLight = Color(0xFFFFB1C7)
|
||||
override val surfaceDimLight = Color(0xFFE6D6D9)
|
||||
override val surfaceBrightLight = Color(0xFFFFF8F8)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFFFF0F2)
|
||||
override val surfaceContainerLight = Color(0xFFFBEAED)
|
||||
override val surfaceContainerHighLight = Color(0xFFF5E4E7)
|
||||
override val surfaceContainerHighestLight = Color(0xFFEFDFE1)
|
||||
|
||||
override val primaryDark = Color(0xFFFFB1C7)
|
||||
override val onPrimaryDark = Color(0xFF541D32)
|
||||
override val primaryContainerDark = Color(0xFF703348)
|
||||
override val onPrimaryContainerDark = Color(0xFFFFD9E2)
|
||||
override val secondaryDark = Color(0xFFFFB0CB)
|
||||
override val onSecondaryDark = Color(0xFF541D34)
|
||||
override val secondaryContainerDark = Color(0xFF6F334B)
|
||||
override val onSecondaryContainerDark = Color(0xFFFFD9E3)
|
||||
override val tertiaryDark = Color(0xFFFFB0CB)
|
||||
override val onTertiaryDark = Color(0xFF541D34)
|
||||
override val tertiaryContainerDark = Color(0xFF6F334B)
|
||||
override val onTertiaryContainerDark = Color(0xFFFFD9E3)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF191113)
|
||||
override val onBackgroundDark = Color(0xFFEFDFE1)
|
||||
override val surfaceDark = Color(0xFF191113)
|
||||
override val onSurfaceDark = Color(0xFFEFDFE1)
|
||||
override val surfaceVariantDark = Color(0xFF514346)
|
||||
override val onSurfaceVariantDark = Color(0xFFD5C2C5)
|
||||
override val outlineDark = Color(0xFF9E8C90)
|
||||
override val outlineVariantDark = Color(0xFF514346)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFEFDFE1)
|
||||
override val inverseOnSurfaceDark = Color(0xFF372E30)
|
||||
override val inversePrimaryDark = Color(0xFF8C4A60)
|
||||
override val surfaceDimDark = Color(0xFF191113)
|
||||
override val surfaceBrightDark = Color(0xFF413739)
|
||||
override val surfaceContainerLowestDark = Color(0xFF140C0E)
|
||||
override val surfaceContainerLowDark = Color(0xFF22191B)
|
||||
override val surfaceContainerDark = Color(0xFF261D1F)
|
||||
override val surfaceContainerHighDark = Color(0xFF31282A)
|
||||
override val surfaceContainerHighestDark = Color(0xFF3C3234)
|
||||
}
|
||||
|
||||
// 灰色主题
|
||||
object Gray : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF5B5C5C)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFF747474)
|
||||
override val onPrimaryContainerLight = Color(0xFFFEFCFC)
|
||||
override val secondaryLight = Color(0xFF5F5E5E)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFE4E2E1)
|
||||
override val onSecondaryContainerLight = Color(0xFF656464)
|
||||
override val tertiaryLight = Color(0xFF5E5B5D)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFF777375)
|
||||
override val onTertiaryContainerLight = Color(0xFFFFFBFF)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFCF8F8)
|
||||
override val onBackgroundLight = Color(0xFF1C1B1B)
|
||||
override val surfaceLight = Color(0xFFFCF8F8)
|
||||
override val onSurfaceLight = Color(0xFF1C1B1B)
|
||||
override val surfaceVariantLight = Color(0xFFE0E3E3)
|
||||
override val onSurfaceVariantLight = Color(0xFF444748)
|
||||
override val outlineLight = Color(0xFF747878)
|
||||
override val outlineVariantLight = Color(0xFFC4C7C7)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF313030)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF4F0EF)
|
||||
override val inversePrimaryLight = Color(0xFFC7C6C6)
|
||||
override val surfaceDimLight = Color(0xFFDDD9D8)
|
||||
override val surfaceBrightLight = Color(0xFFFCF8F8)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFF7F3F2)
|
||||
override val surfaceContainerLight = Color(0xFFF1EDEC)
|
||||
override val surfaceContainerHighLight = Color(0xFFEBE7E7)
|
||||
override val surfaceContainerHighestLight = Color(0xFFE5E2E1)
|
||||
|
||||
override val primaryDark = Color(0xFFC7C6C6)
|
||||
override val onPrimaryDark = Color(0xFF303031)
|
||||
override val primaryContainerDark = Color(0xFF919190)
|
||||
override val onPrimaryContainerDark = Color(0xFF161718)
|
||||
override val secondaryDark = Color(0xFFC8C6C5)
|
||||
override val onSecondaryDark = Color(0xFF303030)
|
||||
override val secondaryContainerDark = Color(0xFF474746)
|
||||
override val onSecondaryContainerDark = Color(0xFFB7B5B4)
|
||||
override val tertiaryDark = Color(0xFFCAC5C7)
|
||||
override val onTertiaryDark = Color(0xFF323031)
|
||||
override val tertiaryContainerDark = Color(0xFF948F91)
|
||||
override val onTertiaryContainerDark = Color(0xFF181718)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF141313)
|
||||
override val onBackgroundDark = Color(0xFFE5E2E1)
|
||||
override val surfaceDark = Color(0xFF141313)
|
||||
override val onSurfaceDark = Color(0xFFE5E2E1)
|
||||
override val surfaceVariantDark = Color(0xFF444748)
|
||||
override val onSurfaceVariantDark = Color(0xFFC4C7C7)
|
||||
override val outlineDark = Color(0xFF8E9192)
|
||||
override val outlineVariantDark = Color(0xFF444748)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFE5E2E1)
|
||||
override val inverseOnSurfaceDark = Color(0xFF313030)
|
||||
override val inversePrimaryDark = Color(0xFF5E5E5E)
|
||||
override val surfaceDimDark = Color(0xFF141313)
|
||||
override val surfaceBrightDark = Color(0xFF3A3939)
|
||||
override val surfaceContainerLowestDark = Color(0xFF0E0E0E)
|
||||
override val surfaceContainerLowDark = Color(0xFF1C1B1B)
|
||||
override val surfaceContainerDark = Color(0xFF201F1F)
|
||||
override val surfaceContainerHighDark = Color(0xFF2A2A2A)
|
||||
override val surfaceContainerHighestDark = Color(0xFF353434)
|
||||
}
|
||||
|
||||
// 黄色主题
|
||||
object Yellow : ThemeColors() {
|
||||
override val primaryLight = Color(0xFF6D5E0F)
|
||||
override val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
override val primaryContainerLight = Color(0xFFF8E288)
|
||||
override val onPrimaryContainerLight = Color(0xFF534600)
|
||||
override val secondaryLight = Color(0xFF6D5E0F)
|
||||
override val onSecondaryLight = Color(0xFFFFFFFF)
|
||||
override val secondaryContainerLight = Color(0xFFF7E388)
|
||||
override val onSecondaryContainerLight = Color(0xFF534600)
|
||||
override val tertiaryLight = Color(0xFF685F13)
|
||||
override val onTertiaryLight = Color(0xFFFFFFFF)
|
||||
override val tertiaryContainerLight = Color(0xFFF1E58A)
|
||||
override val onTertiaryContainerLight = Color(0xFF4F4800)
|
||||
override val errorLight = Color(0xFFBA1A1A)
|
||||
override val onErrorLight = Color(0xFFFFFFFF)
|
||||
override val errorContainerLight = Color(0xFFFFDAD6)
|
||||
override val onErrorContainerLight = Color(0xFF93000A)
|
||||
override val backgroundLight = Color(0xFFFFF9ED)
|
||||
override val onBackgroundLight = Color(0xFF1E1C13)
|
||||
override val surfaceLight = Color(0xFFFFF9ED)
|
||||
override val onSurfaceLight = Color(0xFF1E1C13)
|
||||
override val surfaceVariantLight = Color(0xFFE9E2D0)
|
||||
override val onSurfaceVariantLight = Color(0xFF4B4739)
|
||||
override val outlineLight = Color(0xFF7C7768)
|
||||
override val outlineVariantLight = Color(0xFFCDC6B4)
|
||||
override val scrimLight = Color(0xFF000000)
|
||||
override val inverseSurfaceLight = Color(0xFF333027)
|
||||
override val inverseOnSurfaceLight = Color(0xFFF7F0E2)
|
||||
override val inversePrimaryLight = Color(0xFFDAC66F)
|
||||
override val surfaceDimLight = Color(0xFFE0D9CC)
|
||||
override val surfaceBrightLight = Color(0xFFFFF9ED)
|
||||
override val surfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||
override val surfaceContainerLowLight = Color(0xFFFAF3E5)
|
||||
override val surfaceContainerLight = Color(0xFFF4EDDF)
|
||||
override val surfaceContainerHighLight = Color(0xFFEEE8DA)
|
||||
override val surfaceContainerHighestLight = Color(0xFFE8E2D4)
|
||||
|
||||
override val primaryDark = Color(0xFFDAC66F)
|
||||
override val onPrimaryDark = Color(0xFF393000)
|
||||
override val primaryContainerDark = Color(0xFF534600)
|
||||
override val onPrimaryContainerDark = Color(0xFFF8E288)
|
||||
override val secondaryDark = Color(0xFFDAC76F)
|
||||
override val onSecondaryDark = Color(0xFF393000)
|
||||
override val secondaryContainerDark = Color(0xFF534600)
|
||||
override val onSecondaryContainerDark = Color(0xFFF7E388)
|
||||
override val tertiaryDark = Color(0xFFD4C871)
|
||||
override val onTertiaryDark = Color(0xFF363100)
|
||||
override val tertiaryContainerDark = Color(0xFF4F4800)
|
||||
override val onTertiaryContainerDark = Color(0xFFF1E58A)
|
||||
override val errorDark = Color(0xFFFFB4AB)
|
||||
override val onErrorDark = Color(0xFF690005)
|
||||
override val errorContainerDark = Color(0xFF93000A)
|
||||
override val onErrorContainerDark = Color(0xFFFFDAD6)
|
||||
override val backgroundDark = Color(0xFF15130B)
|
||||
override val onBackgroundDark = Color(0xFFE8E2D4)
|
||||
override val surfaceDark = Color(0xFF15130B)
|
||||
override val onSurfaceDark = Color(0xFFE8E2D4)
|
||||
override val surfaceVariantDark = Color(0xFF4B4739)
|
||||
override val onSurfaceVariantDark = Color(0xFFCDC6B4)
|
||||
override val outlineDark = Color(0xFF969080)
|
||||
override val outlineVariantDark = Color(0xFF4B4739)
|
||||
override val scrimDark = Color(0xFF000000)
|
||||
override val inverseSurfaceDark = Color(0xFFE8E2D4)
|
||||
override val inverseOnSurfaceDark = Color(0xFF333027)
|
||||
override val inversePrimaryDark = Color(0xFF6D5E0F)
|
||||
override val surfaceDimDark = Color(0xFF15130B)
|
||||
override val surfaceBrightDark = Color(0xFF3C3930)
|
||||
override val surfaceContainerLowestDark = Color(0xFF100E07)
|
||||
override val surfaceContainerLowDark = Color(0xFF1E1C13)
|
||||
override val surfaceContainerDark = Color(0xFF222017)
|
||||
override val surfaceContainerHighDark = Color(0xFF2C2A21)
|
||||
override val surfaceContainerHighestDark = Color(0xFF37352B)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromName(name: String): ThemeColors = when (name.lowercase()) {
|
||||
"green" -> Green
|
||||
"purple" -> Purple
|
||||
"orange" -> Orange
|
||||
"pink" -> Pink
|
||||
"gray" -> Gray
|
||||
"yellow" -> Yellow
|
||||
else -> Default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,593 +1,22 @@
|
||||
package com.sukisu.ultra.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.paint
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.sukisu.ultra.ui.theme.util.BackgroundTransformation
|
||||
import com.sukisu.ultra.ui.theme.util.saveTransformedBackground
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@Stable
|
||||
object ThemeConfig {
|
||||
// 主题状态
|
||||
var customBackgroundUri by mutableStateOf<Uri?>(null)
|
||||
var forceDarkMode by mutableStateOf<Boolean?>(null)
|
||||
var currentTheme by mutableStateOf<ThemeColors>(ThemeColors.Default)
|
||||
var useDynamicColor by mutableStateOf(false)
|
||||
|
||||
// 背景状态
|
||||
var backgroundImageLoaded by mutableStateOf(false)
|
||||
var isThemeChanging by mutableStateOf(false)
|
||||
var preventBackgroundRefresh by mutableStateOf(false)
|
||||
|
||||
// 主题变化检测
|
||||
private var lastDarkModeState: Boolean? = null
|
||||
|
||||
fun detectThemeChange(currentDarkMode: Boolean): Boolean {
|
||||
val hasChanged = lastDarkModeState != null && lastDarkModeState != currentDarkMode
|
||||
lastDarkModeState = currentDarkMode
|
||||
return hasChanged
|
||||
}
|
||||
|
||||
fun resetBackgroundState() {
|
||||
if (!preventBackgroundRefresh) {
|
||||
backgroundImageLoaded = false
|
||||
}
|
||||
isThemeChanging = true
|
||||
}
|
||||
|
||||
fun updateTheme(
|
||||
theme: ThemeColors? = null,
|
||||
dynamicColor: Boolean? = null,
|
||||
darkMode: Boolean? = null
|
||||
) {
|
||||
theme?.let { currentTheme = it }
|
||||
dynamicColor?.let { useDynamicColor = it }
|
||||
darkMode?.let { forceDarkMode = it }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
customBackgroundUri = null
|
||||
forceDarkMode = null
|
||||
currentTheme = ThemeColors.Default
|
||||
useDynamicColor = false
|
||||
backgroundImageLoaded = false
|
||||
isThemeChanging = false
|
||||
preventBackgroundRefresh = false
|
||||
lastDarkModeState = null
|
||||
}
|
||||
}
|
||||
|
||||
object ThemeManager {
|
||||
private const val PREFS_NAME = "theme_prefs"
|
||||
|
||||
fun saveThemeMode(context: Context, forceDark: Boolean?) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
||||
putString("theme_mode", when (forceDark) {
|
||||
true -> "dark"
|
||||
false -> "light"
|
||||
null -> "system"
|
||||
})
|
||||
}
|
||||
ThemeConfig.forceDarkMode = forceDark
|
||||
}
|
||||
|
||||
fun loadThemeMode(context: Context) {
|
||||
val mode = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString("theme_mode", "system")
|
||||
|
||||
ThemeConfig.forceDarkMode = when (mode) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveThemeColors(context: Context, themeName: String) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
||||
putString("theme_colors", themeName)
|
||||
}
|
||||
ThemeConfig.currentTheme = ThemeColors.fromName(themeName)
|
||||
}
|
||||
|
||||
fun loadThemeColors(context: Context) {
|
||||
val themeName = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString("theme_colors", "default") ?: "default"
|
||||
ThemeConfig.currentTheme = ThemeColors.fromName(themeName)
|
||||
}
|
||||
|
||||
fun saveDynamicColorState(context: Context, enabled: Boolean) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
||||
putBoolean("use_dynamic_color", enabled)
|
||||
}
|
||||
ThemeConfig.useDynamicColor = enabled
|
||||
}
|
||||
|
||||
|
||||
fun loadDynamicColorState(context: Context) {
|
||||
val enabled = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean("use_dynamic_color", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
ThemeConfig.useDynamicColor = enabled
|
||||
}
|
||||
}
|
||||
|
||||
object BackgroundManager {
|
||||
private const val TAG = "BackgroundManager"
|
||||
|
||||
fun saveAndApplyCustomBackground(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
transformation: BackgroundTransformation? = null
|
||||
) {
|
||||
try {
|
||||
val finalUri = if (transformation != null) {
|
||||
context.saveTransformedBackground(uri, transformation)
|
||||
} else {
|
||||
copyImageToInternalStorage(context, uri)
|
||||
}
|
||||
|
||||
saveBackgroundUri(context, finalUri)
|
||||
ThemeConfig.customBackgroundUri = finalUri
|
||||
CardConfig.updateBackground(true)
|
||||
resetBackgroundState(context)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "保存背景失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCustomBackground(context: Context) {
|
||||
saveBackgroundUri(context, null)
|
||||
ThemeConfig.customBackgroundUri = null
|
||||
CardConfig.updateBackground(false)
|
||||
resetBackgroundState(context)
|
||||
}
|
||||
|
||||
fun loadCustomBackground(context: Context) {
|
||||
val uriString = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getString("custom_background", null)
|
||||
|
||||
val newUri = uriString?.toUri()
|
||||
val preventRefresh = context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("prevent_background_refresh", false)
|
||||
|
||||
ThemeConfig.preventBackgroundRefresh = preventRefresh
|
||||
|
||||
if (!preventRefresh || ThemeConfig.customBackgroundUri?.toString() != newUri?.toString()) {
|
||||
Log.d(TAG, "加载自定义背景: $uriString")
|
||||
ThemeConfig.customBackgroundUri = newUri
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
CardConfig.updateBackground(newUri != null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBackgroundUri(context: Context, uri: Uri?) {
|
||||
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
|
||||
putString("custom_background", uri?.toString())
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetBackgroundState(context: Context) {
|
||||
ThemeConfig.backgroundImageLoaded = false
|
||||
ThemeConfig.preventBackgroundRefresh = false
|
||||
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyImageToInternalStorage(context: Context, uri: Uri): Uri? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
|
||||
val fileName = "custom_background_${System.currentTimeMillis()}.jpg"
|
||||
val file = File(context.filesDir, fileName)
|
||||
|
||||
FileOutputStream(file).use { outputStream ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
outputStream.write(buffer, 0, read)
|
||||
}
|
||||
outputStream.flush()
|
||||
}
|
||||
inputStream.close()
|
||||
|
||||
Uri.fromFile(file)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "复制图片失败: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
import androidx.compose.runtime.Composable
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.darkColorScheme
|
||||
import top.yukonga.miuix.kmp.theme.lightColorScheme
|
||||
|
||||
@Composable
|
||||
fun KernelSUTheme(
|
||||
darkTheme: Boolean = when(ThemeConfig.forceDarkMode) {
|
||||
true -> true
|
||||
false -> false
|
||||
null -> isSystemInDarkTheme()
|
||||
},
|
||||
dynamicColor: Boolean = ThemeConfig.useDynamicColor,
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val systemIsDark = isSystemInDarkTheme()
|
||||
|
||||
// 初始化主题
|
||||
ThemeInitializer(context = context, systemIsDark = systemIsDark)
|
||||
|
||||
// 创建颜色方案
|
||||
val colorScheme = createColorScheme(context, darkTheme, dynamicColor)
|
||||
|
||||
// 系统栏样式
|
||||
SystemBarController(darkTheme)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 背景层
|
||||
BackgroundLayer(darkTheme)
|
||||
// 内容层
|
||||
Box(modifier = Modifier.fillMaxSize().zIndex(1f)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
val colorScheme = when {
|
||||
darkTheme -> darkColorScheme()
|
||||
else -> lightColorScheme()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeInitializer(context: Context, systemIsDark: Boolean) {
|
||||
val themeChanged = ThemeConfig.detectThemeChange(systemIsDark)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 处理系统主题变化
|
||||
LaunchedEffect(systemIsDark, themeChanged) {
|
||||
if (ThemeConfig.forceDarkMode == null && themeChanged) {
|
||||
Log.d("ThemeSystem", "系统主题变化: $systemIsDark")
|
||||
ThemeConfig.resetBackgroundState()
|
||||
|
||||
if (!ThemeConfig.preventBackgroundRefresh) {
|
||||
BackgroundManager.loadCustomBackground(context)
|
||||
}
|
||||
|
||||
CardConfig.apply {
|
||||
load(context)
|
||||
setThemeDefaults(systemIsDark)
|
||||
save(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载配置
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
ThemeManager.loadThemeMode(context)
|
||||
ThemeManager.loadThemeColors(context)
|
||||
ThemeManager.loadDynamicColorState(context)
|
||||
CardConfig.load(context)
|
||||
|
||||
if (!ThemeConfig.backgroundImageLoaded && !ThemeConfig.preventBackgroundRefresh) {
|
||||
BackgroundManager.loadCustomBackground(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundLayer(darkTheme: Boolean) {
|
||||
val backgroundUri = rememberSaveable { mutableStateOf(ThemeConfig.customBackgroundUri) }
|
||||
|
||||
LaunchedEffect(ThemeConfig.customBackgroundUri) {
|
||||
backgroundUri.value = ThemeConfig.customBackgroundUri
|
||||
}
|
||||
|
||||
// 默认背景
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(-2f)
|
||||
.background(
|
||||
if (CardConfig.isCustomBackgroundEnabled) {
|
||||
MaterialTheme.colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
MaterialTheme.colorScheme.background
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 自定义背景
|
||||
backgroundUri.value?.let { uri ->
|
||||
CustomBackgroundLayer(uri = uri, darkTheme = darkTheme)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomBackgroundLayer(uri: Uri, darkTheme: Boolean) {
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = uri,
|
||||
onError = { error ->
|
||||
Log.e("ThemeSystem", "背景加载失败: ${error.result.throwable.message}")
|
||||
ThemeConfig.customBackgroundUri = null
|
||||
},
|
||||
onSuccess = {
|
||||
Log.d("ThemeSystem", "背景加载成功")
|
||||
ThemeConfig.backgroundImageLoaded = true
|
||||
ThemeConfig.isThemeChanging = false
|
||||
}
|
||||
)
|
||||
|
||||
val transition = updateTransition(
|
||||
targetState = ThemeConfig.backgroundImageLoaded,
|
||||
label = "backgroundTransition"
|
||||
)
|
||||
|
||||
val alpha by transition.animateFloat(
|
||||
label = "backgroundAlpha",
|
||||
transitionSpec = {
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
}
|
||||
) { loaded -> if (loaded) 1f else 0f }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(-1f)
|
||||
.alpha(alpha)
|
||||
) {
|
||||
// 背景图片
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.paint(painter = painter, contentScale = ContentScale.Crop)
|
||||
.graphicsLayer {
|
||||
this.alpha = (painter.state as? AsyncImagePainter.State.Success)?.let { 1f } ?: 0f
|
||||
}
|
||||
)
|
||||
|
||||
// 遮罩层
|
||||
BackgroundOverlay(darkTheme = darkTheme)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundOverlay(darkTheme: Boolean) {
|
||||
val dimFactor = CardConfig.cardDim
|
||||
|
||||
// 主要遮罩层
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (darkTheme) {
|
||||
Color.Black.copy(alpha = 0.3f + dimFactor * 0.4f)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.05f + dimFactor * 0.3f)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// 边缘渐变遮罩
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
if (darkTheme) {
|
||||
Color.Black.copy(alpha = 0.2f + dimFactor * 0.2f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.05f + dimFactor * 0.1f)
|
||||
}
|
||||
),
|
||||
radius = 1000f
|
||||
)
|
||||
)
|
||||
MiuixTheme(
|
||||
colors = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun createColorScheme(
|
||||
context: Context,
|
||||
darkTheme: Boolean,
|
||||
dynamicColor: Boolean
|
||||
): ColorScheme {
|
||||
return when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkTheme) createDynamicDarkColorScheme(context)
|
||||
else createDynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> createDarkColorScheme()
|
||||
else -> createLightColorScheme()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemBarController(darkMode: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as ComponentActivity
|
||||
|
||||
SideEffect {
|
||||
activity.enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
Color.Transparent.toArgb(),
|
||||
Color.Transparent.toArgb(),
|
||||
) { darkMode },
|
||||
navigationBarStyle = if (darkMode) {
|
||||
SystemBarStyle.dark(Color.Transparent.toArgb())
|
||||
} else {
|
||||
SystemBarStyle.light(
|
||||
Color.Transparent.toArgb(),
|
||||
Color.Transparent.toArgb()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
private fun createDynamicDarkColorScheme(context: Context): ColorScheme {
|
||||
val scheme = dynamicDarkColorScheme(context)
|
||||
return scheme.copy(
|
||||
background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.background,
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.surface,
|
||||
onBackground = scheme.onBackground,
|
||||
onSurface = scheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
private fun createDynamicLightColorScheme(context: Context): ColorScheme {
|
||||
val scheme = dynamicLightColorScheme(context)
|
||||
return scheme.copy(
|
||||
background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.background,
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else scheme.surface,
|
||||
onBackground = scheme.onBackground,
|
||||
onSurface = scheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun createDarkColorScheme() = darkColorScheme(
|
||||
primary = ThemeConfig.currentTheme.primaryDark,
|
||||
onPrimary = ThemeConfig.currentTheme.onPrimaryDark,
|
||||
primaryContainer = ThemeConfig.currentTheme.primaryContainerDark,
|
||||
onPrimaryContainer = ThemeConfig.currentTheme.onPrimaryContainerDark,
|
||||
secondary = ThemeConfig.currentTheme.secondaryDark,
|
||||
onSecondary = ThemeConfig.currentTheme.onSecondaryDark,
|
||||
secondaryContainer = ThemeConfig.currentTheme.secondaryContainerDark,
|
||||
onSecondaryContainer = ThemeConfig.currentTheme.onSecondaryContainerDark,
|
||||
tertiary = ThemeConfig.currentTheme.tertiaryDark,
|
||||
onTertiary = ThemeConfig.currentTheme.onTertiaryDark,
|
||||
tertiaryContainer = ThemeConfig.currentTheme.tertiaryContainerDark,
|
||||
onTertiaryContainer = ThemeConfig.currentTheme.onTertiaryContainerDark,
|
||||
error = ThemeConfig.currentTheme.errorDark,
|
||||
onError = ThemeConfig.currentTheme.onErrorDark,
|
||||
errorContainer = ThemeConfig.currentTheme.errorContainerDark,
|
||||
onErrorContainer = ThemeConfig.currentTheme.onErrorContainerDark,
|
||||
background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.backgroundDark,
|
||||
onBackground = ThemeConfig.currentTheme.onBackgroundDark,
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.surfaceDark,
|
||||
onSurface = ThemeConfig.currentTheme.onSurfaceDark,
|
||||
surfaceVariant = ThemeConfig.currentTheme.surfaceVariantDark,
|
||||
onSurfaceVariant = ThemeConfig.currentTheme.onSurfaceVariantDark,
|
||||
outline = ThemeConfig.currentTheme.outlineDark,
|
||||
outlineVariant = ThemeConfig.currentTheme.outlineVariantDark,
|
||||
scrim = ThemeConfig.currentTheme.scrimDark,
|
||||
inverseSurface = ThemeConfig.currentTheme.inverseSurfaceDark,
|
||||
inverseOnSurface = ThemeConfig.currentTheme.inverseOnSurfaceDark,
|
||||
inversePrimary = ThemeConfig.currentTheme.inversePrimaryDark,
|
||||
surfaceDim = ThemeConfig.currentTheme.surfaceDimDark,
|
||||
surfaceBright = ThemeConfig.currentTheme.surfaceBrightDark,
|
||||
surfaceContainerLowest = ThemeConfig.currentTheme.surfaceContainerLowestDark,
|
||||
surfaceContainerLow = ThemeConfig.currentTheme.surfaceContainerLowDark,
|
||||
surfaceContainer = ThemeConfig.currentTheme.surfaceContainerDark,
|
||||
surfaceContainerHigh = ThemeConfig.currentTheme.surfaceContainerHighDark,
|
||||
surfaceContainerHighest = ThemeConfig.currentTheme.surfaceContainerHighestDark,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun createLightColorScheme() = lightColorScheme(
|
||||
primary = ThemeConfig.currentTheme.primaryLight,
|
||||
onPrimary = ThemeConfig.currentTheme.onPrimaryLight,
|
||||
primaryContainer = ThemeConfig.currentTheme.primaryContainerLight,
|
||||
onPrimaryContainer = ThemeConfig.currentTheme.onPrimaryContainerLight,
|
||||
secondary = ThemeConfig.currentTheme.secondaryLight,
|
||||
onSecondary = ThemeConfig.currentTheme.onSecondaryLight,
|
||||
secondaryContainer = ThemeConfig.currentTheme.secondaryContainerLight,
|
||||
onSecondaryContainer = ThemeConfig.currentTheme.onSecondaryContainerLight,
|
||||
tertiary = ThemeConfig.currentTheme.tertiaryLight,
|
||||
onTertiary = ThemeConfig.currentTheme.onTertiaryLight,
|
||||
tertiaryContainer = ThemeConfig.currentTheme.tertiaryContainerLight,
|
||||
onTertiaryContainer = ThemeConfig.currentTheme.onTertiaryContainerLight,
|
||||
error = ThemeConfig.currentTheme.errorLight,
|
||||
onError = ThemeConfig.currentTheme.onErrorLight,
|
||||
errorContainer = ThemeConfig.currentTheme.errorContainerLight,
|
||||
onErrorContainer = ThemeConfig.currentTheme.onErrorContainerLight,
|
||||
background = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.backgroundLight,
|
||||
onBackground = ThemeConfig.currentTheme.onBackgroundLight,
|
||||
surface = if (CardConfig.isCustomBackgroundEnabled) Color.Transparent else ThemeConfig.currentTheme.surfaceLight,
|
||||
onSurface = ThemeConfig.currentTheme.onSurfaceLight,
|
||||
surfaceVariant = ThemeConfig.currentTheme.surfaceVariantLight,
|
||||
onSurfaceVariant = ThemeConfig.currentTheme.onSurfaceVariantLight,
|
||||
outline = ThemeConfig.currentTheme.outlineLight,
|
||||
outlineVariant = ThemeConfig.currentTheme.outlineVariantLight,
|
||||
scrim = ThemeConfig.currentTheme.scrimLight,
|
||||
inverseSurface = ThemeConfig.currentTheme.inverseSurfaceLight,
|
||||
inverseOnSurface = ThemeConfig.currentTheme.inverseOnSurfaceLight,
|
||||
inversePrimary = ThemeConfig.currentTheme.inversePrimaryLight,
|
||||
surfaceDim = ThemeConfig.currentTheme.surfaceDimLight,
|
||||
surfaceBright = ThemeConfig.currentTheme.surfaceBrightLight,
|
||||
surfaceContainerLowest = ThemeConfig.currentTheme.surfaceContainerLowestLight,
|
||||
surfaceContainerLow = ThemeConfig.currentTheme.surfaceContainerLowLight,
|
||||
surfaceContainer = ThemeConfig.currentTheme.surfaceContainerLight,
|
||||
surfaceContainerHigh = ThemeConfig.currentTheme.surfaceContainerHighLight,
|
||||
surfaceContainerHighest = ThemeConfig.currentTheme.surfaceContainerHighestLight,
|
||||
)
|
||||
|
||||
// 向后兼容
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun Context.saveAndApplyCustomBackground(uri: Uri, transformation: BackgroundTransformation? = null) {
|
||||
kotlinx.coroutines.GlobalScope.launch {
|
||||
BackgroundManager.saveAndApplyCustomBackground(this@saveAndApplyCustomBackground, uri, transformation)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.saveCustomBackground(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
saveAndApplyCustomBackground(uri)
|
||||
} else {
|
||||
BackgroundManager.clearCustomBackground(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.saveThemeMode(forceDark: Boolean?) {
|
||||
ThemeManager.saveThemeMode(this, forceDark)
|
||||
}
|
||||
|
||||
|
||||
fun Context.saveThemeColors(themeName: String) {
|
||||
ThemeManager.saveThemeColors(this, themeName)
|
||||
}
|
||||
|
||||
|
||||
fun Context.saveDynamicColorState(enabled: Boolean) {
|
||||
ThemeManager.saveDynamicColorState(this, enabled)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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
|
||||
)
|
||||
)
|
||||
@@ -1,411 +0,0 @@
|
||||
package com.sukisu.ultra.ui.theme.component
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.theme.util.BackgroundTransformation
|
||||
import com.sukisu.ultra.ui.theme.util.saveTransformedBackground
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
fun ImageEditorDialog(
|
||||
imageUri: Uri,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Uri) -> Unit
|
||||
) {
|
||||
// 图像变换状态
|
||||
val transformState = remember { ImageTransformState() }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 尺寸状态
|
||||
var imageSize by remember { mutableStateOf(Size.Zero) }
|
||||
var screenSize by remember { mutableStateOf(Size.Zero) }
|
||||
|
||||
// 动画状态
|
||||
val animationSpec = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = transformState.scale,
|
||||
animationSpec = animationSpec,
|
||||
label = "ScaleAnimation"
|
||||
)
|
||||
|
||||
val animatedOffsetX by animateFloatAsState(
|
||||
targetValue = transformState.offsetX,
|
||||
animationSpec = animationSpec,
|
||||
label = "OffsetXAnimation"
|
||||
)
|
||||
|
||||
val animatedOffsetY by animateFloatAsState(
|
||||
targetValue = transformState.offsetY,
|
||||
animationSpec = animationSpec,
|
||||
label = "OffsetYAnimation"
|
||||
)
|
||||
|
||||
// 工具函数
|
||||
val scaleToFullScreen = remember {
|
||||
{
|
||||
if (imageSize.height > 0 && screenSize.height > 0) {
|
||||
val newScale = screenSize.height / imageSize.height
|
||||
transformState.updateTransform(newScale, 0f, 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val saveImage: () -> Unit = remember {
|
||||
{
|
||||
scope.launch {
|
||||
try {
|
||||
val transformation = BackgroundTransformation(
|
||||
transformState.scale,
|
||||
transformState.offsetX,
|
||||
transformState.offsetY
|
||||
)
|
||||
val savedUri = context.saveTransformedBackground(imageUri, transformation)
|
||||
savedUri?.let { onConfirm(it) }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.9f),
|
||||
Color.Black.copy(alpha = 0.95f)
|
||||
),
|
||||
radius = 800f
|
||||
)
|
||||
)
|
||||
.onSizeChanged { size ->
|
||||
screenSize = Size(size.width.toFloat(), size.height.toFloat())
|
||||
}
|
||||
) {
|
||||
// 图像显示区域
|
||||
ImageDisplayArea(
|
||||
imageUri = imageUri,
|
||||
animatedScale = animatedScale,
|
||||
animatedOffsetX = animatedOffsetX,
|
||||
animatedOffsetY = animatedOffsetY,
|
||||
transformState = transformState,
|
||||
onImageSizeChanged = { imageSize = it },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// 顶部工具栏
|
||||
TopToolbar(
|
||||
onDismiss = onDismiss,
|
||||
onFullscreen = scaleToFullScreen,
|
||||
onConfirm = saveImage,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
|
||||
// 底部提示信息
|
||||
BottomHintCard(
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图像变换状态管理类
|
||||
*/
|
||||
private class ImageTransformState {
|
||||
var scale by mutableFloatStateOf(1f)
|
||||
var offsetX by mutableFloatStateOf(0f)
|
||||
var offsetY by mutableFloatStateOf(0f)
|
||||
|
||||
private var lastScale = 1f
|
||||
private var lastOffsetX = 0f
|
||||
private var lastOffsetY = 0f
|
||||
|
||||
fun updateTransform(newScale: Float, newOffsetX: Float, newOffsetY: Float) {
|
||||
val scaleDiff = abs(newScale - lastScale)
|
||||
val offsetXDiff = abs(newOffsetX - lastOffsetX)
|
||||
val offsetYDiff = abs(newOffsetY - lastOffsetY)
|
||||
|
||||
if (scaleDiff > 0.01f || offsetXDiff > 1f || offsetYDiff > 1f) {
|
||||
scale = newScale
|
||||
offsetX = newOffsetX
|
||||
offsetY = newOffsetY
|
||||
lastScale = newScale
|
||||
lastOffsetX = newOffsetX
|
||||
lastOffsetY = newOffsetY
|
||||
}
|
||||
}
|
||||
|
||||
fun resetToLast() {
|
||||
scale = lastScale
|
||||
offsetX = lastOffsetX
|
||||
offsetY = lastOffsetY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图像显示区域组件
|
||||
*/
|
||||
@Composable
|
||||
private fun ImageDisplayArea(
|
||||
imageUri: Uri,
|
||||
animatedScale: Float,
|
||||
animatedOffsetX: Float,
|
||||
animatedOffsetY: Float,
|
||||
transformState: ImageTransformState,
|
||||
onImageSizeChanged: (Size) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(imageUri)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = stringResource(R.string.settings_custom_background),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier
|
||||
.graphicsLayer(
|
||||
scaleX = animatedScale,
|
||||
scaleY = animatedScale,
|
||||
translationX = animatedOffsetX,
|
||||
translationY = animatedOffsetY
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scope.launch {
|
||||
try {
|
||||
val newScale = (transformState.scale * zoom).coerceIn(0.5f, 3f)
|
||||
val maxOffsetX = max(0f, size.width * (newScale - 1) / 2)
|
||||
val maxOffsetY = max(0f, size.height * (newScale - 1) / 2)
|
||||
|
||||
val newOffsetX = if (maxOffsetX > 0) {
|
||||
(transformState.offsetX + pan.x).coerceIn(-maxOffsetX, maxOffsetX)
|
||||
} else 0f
|
||||
|
||||
val newOffsetY = if (maxOffsetY > 0) {
|
||||
(transformState.offsetY + pan.y).coerceIn(-maxOffsetY, maxOffsetY)
|
||||
} else 0f
|
||||
|
||||
transformState.updateTransform(newScale, newOffsetX, newOffsetY)
|
||||
} catch (_: Exception) {
|
||||
transformState.resetToLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onSizeChanged { size ->
|
||||
onImageSizeChanged(Size(size.width.toFloat(), size.height.toFloat()))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部工具栏组件
|
||||
*/
|
||||
@Composable
|
||||
private fun TopToolbar(
|
||||
onDismiss: () -> Unit,
|
||||
onFullscreen: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// 关闭按钮
|
||||
ActionButton(
|
||||
onClick = onDismiss,
|
||||
icon = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.cancel),
|
||||
backgroundColor = MaterialTheme.colorScheme.error.copy(alpha = 0.9f)
|
||||
)
|
||||
|
||||
// 全屏按钮
|
||||
ActionButton(
|
||||
onClick = onFullscreen,
|
||||
icon = Icons.Default.Fullscreen,
|
||||
contentDescription = stringResource(R.string.reprovision),
|
||||
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
|
||||
)
|
||||
|
||||
// 确认按钮
|
||||
ActionButton(
|
||||
onClick = onConfirm,
|
||||
icon = Icons.Default.Check,
|
||||
contentDescription = stringResource(R.string.confirm),
|
||||
backgroundColor = Color(0xFF4CAF50).copy(alpha = 0.9f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作按钮组件
|
||||
*/
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
onClick: () -> Unit,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isPressed by remember { mutableStateOf(false) }
|
||||
|
||||
val buttonScale by animateFloatAsState(
|
||||
targetValue = if (isPressed) 0.85f else 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessHigh
|
||||
),
|
||||
label = "ButtonScale"
|
||||
)
|
||||
|
||||
val buttonAlpha by animateFloatAsState(
|
||||
targetValue = if (isPressed) 0.8f else 1f,
|
||||
animationSpec = tween(100),
|
||||
label = "ButtonAlpha"
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
isPressed = true
|
||||
onClick()
|
||||
},
|
||||
modifier = modifier
|
||||
.size(64.dp)
|
||||
.graphicsLayer(
|
||||
scaleX = buttonScale,
|
||||
scaleY = buttonScale,
|
||||
alpha = buttonAlpha
|
||||
),
|
||||
shape = CircleShape,
|
||||
color = backgroundColor,
|
||||
shadowElevation = 8.dp
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPressed) {
|
||||
if (isPressed) {
|
||||
kotlinx.coroutines.delay(150)
|
||||
isPressed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部提示卡片组件
|
||||
*/
|
||||
@Composable
|
||||
private fun BottomHintCard(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isVisible by remember { mutableStateOf(true) }
|
||||
|
||||
val cardAlpha by animateFloatAsState(
|
||||
targetValue = if (isVisible) 1f else 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 500,
|
||||
easing = EaseInOutCubic
|
||||
),
|
||||
label = "HintAlpha"
|
||||
)
|
||||
|
||||
val cardTranslationY by animateFloatAsState(
|
||||
targetValue = if (isVisible) 0f else 100f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 500,
|
||||
easing = EaseInOutCubic
|
||||
),
|
||||
label = "HintTranslation"
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(4000)
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp)
|
||||
.alpha(cardAlpha)
|
||||
.graphicsLayer {
|
||||
translationY = cardTranslationY
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.Black.copy(alpha = 0.85f)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.image_editor_hint),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.padding(20.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.sukisu.ultra.ui.theme.util
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.graphics.createBitmap
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
data class BackgroundTransformation(
|
||||
val scale: Float = 1f,
|
||||
val offsetX: Float = 0f,
|
||||
val offsetY: Float = 0f
|
||||
)
|
||||
|
||||
fun Context.getImageBitmap(uri: Uri): Bitmap? {
|
||||
return try {
|
||||
val contentResolver: ContentResolver = contentResolver
|
||||
val inputStream: InputStream = contentResolver.openInputStream(uri) ?: return null
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
inputStream.close()
|
||||
bitmap
|
||||
} catch (e: Exception) {
|
||||
Log.e("BackgroundUtils", "Failed to get image bitmap: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.applyTransformationToBitmap(bitmap: Bitmap, transformation: BackgroundTransformation): Bitmap {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
|
||||
// 创建与屏幕比例相同的目标位图
|
||||
val displayMetrics = resources.displayMetrics
|
||||
val screenWidth = displayMetrics.widthPixels
|
||||
val screenHeight = displayMetrics.heightPixels
|
||||
val screenRatio = screenHeight.toFloat() / screenWidth.toFloat()
|
||||
|
||||
// 计算目标宽高
|
||||
val targetWidth: Int
|
||||
val targetHeight: Int
|
||||
if (width.toFloat() / height.toFloat() > screenRatio) {
|
||||
targetHeight = height
|
||||
targetWidth = (height / screenRatio).toInt()
|
||||
} else {
|
||||
targetWidth = width
|
||||
targetHeight = (width * screenRatio).toInt()
|
||||
}
|
||||
|
||||
// 创建与目标相同大小的位图
|
||||
val scaledBitmap = createBitmap(targetWidth, targetHeight)
|
||||
val canvas = Canvas(scaledBitmap)
|
||||
|
||||
val matrix = Matrix()
|
||||
|
||||
// 确保缩放值有效
|
||||
val safeScale = maxOf(0.1f, transformation.scale)
|
||||
matrix.postScale(safeScale, safeScale)
|
||||
|
||||
// 计算偏移量,确保不会出现负最大值的问题
|
||||
val widthDiff = (bitmap.width * safeScale - targetWidth)
|
||||
val heightDiff = (bitmap.height * safeScale - targetHeight)
|
||||
|
||||
// 安全计算偏移量边界
|
||||
val maxOffsetX = maxOf(0f, widthDiff / 2)
|
||||
val maxOffsetY = maxOf(0f, heightDiff / 2)
|
||||
|
||||
// 限制偏移范围
|
||||
val safeOffsetX = if (maxOffsetX > 0)
|
||||
transformation.offsetX.coerceIn(-maxOffsetX, maxOffsetX) else 0f
|
||||
val safeOffsetY = if (maxOffsetY > 0)
|
||||
transformation.offsetY.coerceIn(-maxOffsetY, maxOffsetY) else 0f
|
||||
|
||||
// 应用偏移量到矩阵
|
||||
val translationX = -widthDiff / 2 + safeOffsetX
|
||||
val translationY = -heightDiff / 2 + safeOffsetY
|
||||
|
||||
matrix.postTranslate(translationX, translationY)
|
||||
|
||||
// 将原始位图绘制到新位图上
|
||||
canvas.drawBitmap(bitmap, matrix, null)
|
||||
|
||||
return scaledBitmap
|
||||
}
|
||||
|
||||
fun Context.saveTransformedBackground(uri: Uri, transformation: BackgroundTransformation): Uri? {
|
||||
try {
|
||||
val bitmap = getImageBitmap(uri) ?: return null
|
||||
val transformedBitmap = applyTransformationToBitmap(bitmap, transformation)
|
||||
|
||||
val fileName = "custom_background_transformed.jpg"
|
||||
val file = File(filesDir, fileName)
|
||||
val outputStream = FileOutputStream(file)
|
||||
|
||||
transformedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
|
||||
return Uri.fromFile(file)
|
||||
} catch (e: Exception) {
|
||||
Log.e("BackgroundUtils", "Failed to save transformed image: ${e.message}", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -7,23 +7,12 @@ 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.ksuApp
|
||||
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
|
||||
@@ -36,10 +25,8 @@ fun download(
|
||||
fileName: String,
|
||||
description: String,
|
||||
onDownloaded: (Uri) -> Unit = {},
|
||||
onDownloading: () -> Unit = {},
|
||||
onError: (String) -> Unit = {}
|
||||
onDownloading: () -> Unit = {}
|
||||
) {
|
||||
Log.d(TAG, "Start Download: $url")
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
val query = DownloadManager.Query()
|
||||
@@ -55,21 +42,14 @@ fun download(
|
||||
onDownloading()
|
||||
return
|
||||
} else if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
onDownloaded(localUri.toUri())
|
||||
onDownloaded(Uri.parse(localUri))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val downloadFile = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
fileName
|
||||
)
|
||||
if (downloadFile.exists()) {
|
||||
downloadFile.delete()
|
||||
}
|
||||
|
||||
val request = DownloadManager.Request(url.toUri())
|
||||
val request = DownloadManager.Request(Uri.parse(url))
|
||||
.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
fileName
|
||||
@@ -78,204 +58,48 @@ fun download(
|
||||
.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
|
||||
)
|
||||
downloadManager.enqueue(request)
|
||||
}
|
||||
|
||||
fun checkNewVersion(): LatestVersionInfo {
|
||||
val url = "https://api.github.com/repos/ShirkNeko/SukiSU-Ultra/releases/latest"
|
||||
val url = "https://api.github.com/repos/tiann/KernelSU/releases/latest"
|
||||
// default null value if failed
|
||||
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
|
||||
runCatching {
|
||||
ksuApp.okhttpClient.newCall(okhttp3.Request.Builder().url(url).build()).execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return defaultValue
|
||||
}
|
||||
val body = response.body?.string() ?: return defaultValue
|
||||
val json = org.json.JSONObject(body)
|
||||
val changelog = json.optString("body")
|
||||
|
||||
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("v(.+?)_(\\d+)-")
|
||||
val matchResult = regex.find(name) ?: continue
|
||||
val versionName = matchResult.groupValues[1]
|
||||
val versionCode = matchResult.groupValues[2].toInt()
|
||||
val downloadUrl = asset.getString("browser_download_url")
|
||||
|
||||
return LatestVersionInfo(
|
||||
versionCode,
|
||||
downloadUrl,
|
||||
changelog
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -300,7 +124,7 @@ fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) {
|
||||
val uri = cursor.getString(
|
||||
cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
||||
)
|
||||
onDownloaded(uri.toUri())
|
||||
onDownloaded(Uri.parse(uri))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,4 +140,4 @@ fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) {
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
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
|
||||
*/
|
||||
public class HanziToPinyin {
|
||||
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 = {
|
||||
'\u963f', '\u54ce', '\u5b89', '\u80ae', '\u51f9', '\u516b',
|
||||
'\u6300', '\u6273', '\u90a6', '\u52f9', '\u9642', '\u5954',
|
||||
'\u4f3b', '\u5c44', '\u8fb9', '\u706c', '\u618b', '\u6c43',
|
||||
'\u51ab', '\u7676', '\u5cec', '\u5693', '\u5072', '\u53c2',
|
||||
'\u4ed3', '\u64a1', '\u518a', '\u5d7e', '\u66fd', '\u66fe',
|
||||
'\u5c64', '\u53c9', '\u8286', '\u8fbf', '\u4f25', '\u6284',
|
||||
'\u8f66', '\u62bb', '\u6c88', '\u6c89', '\u9637', '\u5403',
|
||||
'\u5145', '\u62bd', '\u51fa', '\u6b3b', '\u63e3', '\u5ddb',
|
||||
'\u5205', '\u5439', '\u65fe', '\u9034', '\u5472', '\u5306',
|
||||
'\u51d1', '\u7c97', '\u6c46', '\u5d14', '\u90a8', '\u6413',
|
||||
'\u5491', '\u5446', '\u4e39', '\u5f53', '\u5200', '\u561a',
|
||||
'\u6265', '\u706f', '\u6c10', '\u55f2', '\u7538', '\u5201',
|
||||
'\u7239', '\u4e01', '\u4e1f', '\u4e1c', '\u543a', '\u53be',
|
||||
'\u8011', '\u8968', '\u5428', '\u591a', '\u59b8', '\u8bf6',
|
||||
'\u5940', '\u97a5', '\u513f', '\u53d1', '\u5e06', '\u531a',
|
||||
'\u98de', '\u5206', '\u4e30', '\u8985', '\u4ecf', '\u7d11',
|
||||
'\u4f15', '\u65ee', '\u4f85', '\u7518', '\u5188', '\u768b',
|
||||
'\u6208', '\u7ed9', '\u6839', '\u522f', '\u5de5', '\u52fe',
|
||||
'\u4f30', '\u74dc', '\u4e56', '\u5173', '\u5149', '\u5f52',
|
||||
'\u4e28', '\u5459', '\u54c8', '\u548d', '\u4f44', '\u592f',
|
||||
'\u8320', '\u8bc3', '\u9ed2', '\u62eb', '\u4ea8', '\u5677',
|
||||
'\u53ff', '\u9f41', '\u4e6f', '\u82b1', '\u6000', '\u72bf',
|
||||
'\u5ddf', '\u7070', '\u660f', '\u5419', '\u4e0c', '\u52a0',
|
||||
'\u620b', '\u6c5f', '\u827d', '\u9636', '\u5dfe', '\u5755',
|
||||
'\u5182', '\u4e29', '\u51e5', '\u59e2', '\u5658', '\u519b',
|
||||
'\u5494', '\u5f00', '\u520a', '\u5ffc', '\u5c3b', '\u533c',
|
||||
'\u808e', '\u52a5', '\u7a7a', '\u62a0', '\u625d', '\u5938',
|
||||
'\u84af', '\u5bbd', '\u5321', '\u4e8f', '\u5764', '\u6269',
|
||||
'\u5783', '\u6765', '\u5170', '\u5577', '\u635e', '\u808b',
|
||||
'\u52d2', '\u5d1a', '\u5215', '\u4fe9', '\u5941', '\u826f',
|
||||
'\u64a9', '\u5217', '\u62ce', '\u5222', '\u6e9c', '\u56d6',
|
||||
'\u9f99', '\u779c', '\u565c', '\u5a08', '\u7567', '\u62a1',
|
||||
'\u7f57', '\u5463', '\u5988', '\u57cb', '\u5ada', '\u7264',
|
||||
'\u732b', '\u4e48', '\u5445', '\u95e8', '\u753f', '\u54aa',
|
||||
'\u5b80', '\u55b5', '\u4e5c', '\u6c11', '\u540d', '\u8c2c',
|
||||
'\u6478', '\u54de', '\u6bea', '\u55ef', '\u62cf', '\u8149',
|
||||
'\u56e1', '\u56d4', '\u5b6c', '\u7592', '\u5a1e', '\u6041',
|
||||
'\u80fd', '\u59ae', '\u62c8', '\u5b22', '\u9e1f', '\u634f',
|
||||
'\u56dc', '\u5b81', '\u599e', '\u519c', '\u7fba', '\u5974',
|
||||
'\u597b', '\u759f', '\u9ec1', '\u90cd', '\u5594', '\u8bb4',
|
||||
'\u5991', '\u62cd', '\u7705', '\u4e53', '\u629b', '\u5478',
|
||||
'\u55b7', '\u5309', '\u4e15', '\u56e8', '\u527d', '\u6c15',
|
||||
'\u59d8', '\u4e52', '\u948b', '\u5256', '\u4ec6', '\u4e03',
|
||||
'\u6390', '\u5343', '\u545b', '\u6084', '\u767f', '\u4eb2',
|
||||
'\u72c5', '\u828e', '\u4e18', '\u533a', '\u5cd1', '\u7f3a',
|
||||
'\u590b', '\u5465', '\u7a63', '\u5a06', '\u60f9', '\u4eba',
|
||||
'\u6254', '\u65e5', '\u8338', '\u53b9', '\u909a', '\u633c',
|
||||
'\u5827', '\u5a51', '\u77a4', '\u637c', '\u4ee8', '\u6be2',
|
||||
'\u4e09', '\u6852', '\u63bb', '\u95aa', '\u68ee', '\u50e7',
|
||||
'\u6740', '\u7b5b', '\u5c71', '\u4f24', '\u5f30', '\u5962',
|
||||
'\u7533', '\u8398', '\u6552', '\u5347', '\u5c38', '\u53ce',
|
||||
'\u4e66', '\u5237', '\u8870', '\u95e9', '\u53cc', '\u8c01',
|
||||
'\u542e', '\u8bf4', '\u53b6', '\u5fea', '\u635c', '\u82cf',
|
||||
'\u72fb', '\u590a', '\u5b59', '\u5506', '\u4ed6', '\u56fc',
|
||||
'\u574d', '\u6c64', '\u5932', '\u5fd1', '\u71a5', '\u5254',
|
||||
'\u5929', '\u65eb', '\u5e16', '\u5385', '\u56f2', '\u5077',
|
||||
'\u51f8', '\u6e4d', '\u63a8', '\u541e', '\u4e47', '\u7a75',
|
||||
'\u6b6a', '\u5f2f', '\u5c23', '\u5371', '\u6637', '\u7fc1',
|
||||
'\u631d', '\u4e4c', '\u5915', '\u8672', '\u4eda', '\u4e61',
|
||||
'\u7071', '\u4e9b', '\u5fc3', '\u661f', '\u51f6', '\u4f11',
|
||||
'\u5401', '\u5405', '\u524a', '\u5743', '\u4e2b', '\u6079',
|
||||
'\u592e', '\u5e7a', '\u503b', '\u4e00', '\u56d9', '\u5e94',
|
||||
'\u54df', '\u4f63', '\u4f18', '\u625c', '\u56e6', '\u66f0',
|
||||
'\u6655', '\u7b60', '\u7b7c', '\u5e00', '\u707d', '\u5142',
|
||||
'\u5328', '\u50ae', '\u5219', '\u8d3c', '\u600e', '\u5897',
|
||||
'\u624e', '\u635a', '\u6cbe', '\u5f20', '\u957f', '\u9577',
|
||||
'\u4f4b', '\u8707', '\u8d1e', '\u4e89', '\u4e4b', '\u5cd9',
|
||||
'\u5ea2', '\u4e2d', '\u5dde', '\u6731', '\u6293', '\u62fd',
|
||||
'\u4e13', '\u5986', '\u96b9', '\u5b92', '\u5353', '\u4e72',
|
||||
'\u5b97', '\u90b9', '\u79df', '\u94bb', '\u539c', '\u5c0a',
|
||||
'\u6628', '\u5159', '\u9fc3', '\u9fc4',};
|
||||
|
||||
/**
|
||||
* 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 = "\u963F";
|
||||
private static final String LAST_PINYIN_UNIHAN = "\u9FFF";
|
||||
|
||||
private static final Collator COLLATOR = Collator.getInstance(Locale.CHINA);
|
||||
|
||||
private static HanziToPinyin sInstance;
|
||||
private final boolean mHasChinaCollator;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
protected HanziToPinyin(boolean hasChinaCollator) {
|
||||
mHasChinaCollator = hasChinaCollator;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
package com.sukisu.ultra.ui.util
|
||||
|
||||
/*
|
||||
* Copyright (C) 2009 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
|
||||
class HanziToPinyin private constructor(val hasChinaCollator: Boolean) {
|
||||
|
||||
class Token(
|
||||
var type: Int = 0,
|
||||
var source: String = "",
|
||||
var target: String = ""
|
||||
) {
|
||||
companion object {
|
||||
const val LATIN = 1
|
||||
const val PINYIN = 2
|
||||
const val UNKNOWN = 3
|
||||
}
|
||||
}
|
||||
|
||||
private fun getToken(character: Char): Token {
|
||||
val token = Token()
|
||||
val letter = character.toString()
|
||||
token.source = letter
|
||||
var offset = -1
|
||||
var cmp: Int
|
||||
|
||||
if (character < 256.toChar()) {
|
||||
token.type = Token.LATIN
|
||||
token.target = letter
|
||||
return token
|
||||
} else {
|
||||
cmp = COLLATOR.compare(letter, FIRST_PINYIN_UNIHAN)
|
||||
if (cmp < 0) {
|
||||
token.type = Token.UNKNOWN
|
||||
token.target = letter
|
||||
return token
|
||||
} else if (cmp == 0) {
|
||||
token.type = Token.PINYIN
|
||||
offset = 0
|
||||
} else {
|
||||
cmp = COLLATOR.compare(letter, LAST_PINYIN_UNIHAN)
|
||||
if (cmp > 0) {
|
||||
token.type = Token.UNKNOWN
|
||||
token.target = letter
|
||||
return token
|
||||
} else if (cmp == 0) {
|
||||
token.type = Token.PINYIN
|
||||
offset = UNIHANS.size - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
token.type = Token.PINYIN
|
||||
if (offset < 0) {
|
||||
var begin = 0
|
||||
var end = UNIHANS.size - 1
|
||||
while (begin <= end) {
|
||||
offset = (begin + end) / 2
|
||||
val unihan = UNIHANS[offset].toString()
|
||||
cmp = COLLATOR.compare(letter, unihan)
|
||||
when {
|
||||
cmp == 0 -> break
|
||||
cmp > 0 -> begin = offset + 1
|
||||
else -> end = offset - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cmp < 0) {
|
||||
offset--
|
||||
}
|
||||
|
||||
val pinyin = StringBuilder()
|
||||
for (j in PINYINS[offset].indices) {
|
||||
if (PINYINS[offset][j] == 0.toByte()) break
|
||||
pinyin.append(PINYINS[offset][j].toInt().toChar())
|
||||
}
|
||||
token.target = pinyin.toString()
|
||||
if (TextUtils.isEmpty(token.target)) {
|
||||
token.type = Token.UNKNOWN
|
||||
token.target = token.source
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
fun get(input: String?): ArrayList<Token> {
|
||||
val tokens = ArrayList<Token>()
|
||||
if (!hasChinaCollator || TextUtils.isEmpty(input)) {
|
||||
return tokens
|
||||
}
|
||||
|
||||
val inputLength = input!!.length
|
||||
val sb = StringBuilder()
|
||||
var tokenType = Token.LATIN
|
||||
|
||||
for (i in 0 until inputLength) {
|
||||
val character = input[i]
|
||||
when {
|
||||
character == ' ' -> {
|
||||
if (sb.isNotEmpty()) {
|
||||
addToken(sb, tokens, tokenType)
|
||||
}
|
||||
}
|
||||
character < 256.toChar() -> {
|
||||
if (tokenType != Token.LATIN && sb.isNotEmpty()) {
|
||||
addToken(sb, tokens, tokenType)
|
||||
}
|
||||
tokenType = Token.LATIN
|
||||
sb.append(character)
|
||||
}
|
||||
else -> {
|
||||
val t = getToken(character)
|
||||
if (t.type == Token.PINYIN) {
|
||||
if (sb.isNotEmpty()) {
|
||||
addToken(sb, tokens, tokenType)
|
||||
}
|
||||
tokens.add(t)
|
||||
tokenType = Token.PINYIN
|
||||
} else {
|
||||
if (tokenType != t.type && sb.isNotEmpty()) {
|
||||
addToken(sb, tokens, tokenType)
|
||||
}
|
||||
tokenType = t.type
|
||||
sb.append(character)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sb.isNotEmpty()) {
|
||||
addToken(sb, tokens, tokenType)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private fun addToken(sb: StringBuilder, tokens: ArrayList<Token>, tokenType: Int) {
|
||||
val str = sb.toString()
|
||||
tokens.add(Token(tokenType, str, str))
|
||||
sb.setLength(0)
|
||||
}
|
||||
|
||||
fun toPinyinString(string: String?): String? {
|
||||
if (string == null) {
|
||||
return null
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
val tokens = get(string)
|
||||
for (token in tokens) {
|
||||
sb.append(token.target)
|
||||
}
|
||||
return sb.toString().lowercase()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HanziToPinyin"
|
||||
private const val DEBUG = false
|
||||
|
||||
val UNIHANS = charArrayOf(
|
||||
'阿', '哎', '安', '肮', '凹', '八',
|
||||
'挀', '扳', '邦', '勹', '陂', '奔',
|
||||
'伻', '屄', '边', '灬', '憋', '汃',
|
||||
'冫', '癶', '峬', '嚓', '偲', '参',
|
||||
'仓', '撡', '冊', '嵾', '曽', '曾',
|
||||
'層', '叉', '芆', '辿', '伥', '抄',
|
||||
'车', '抻', '沈', '沉', '阷', '吃',
|
||||
'充', '抽', '出', '欻', '揣', '巛',
|
||||
'刅', '吹', '旾', '逴', '呲', '匆',
|
||||
'凑', '粗', '汆', '崔', '邨', '搓',
|
||||
'咑', '呆', '丹', '当', '刀', '嘚',
|
||||
'扥', '灯', '氐', '嗲', '甸', '刁',
|
||||
'爹', '丁', '丟', '东', '吺', '厾',
|
||||
'耑', '襨', '吨', '多', '妸', '诶',
|
||||
'奀', '鞥', '儿', '发', '帆', '匚',
|
||||
'飞', '分', '丰', '覅', '仏', '紑',
|
||||
'伕', '旮', '侅', '甘', '冈', '皋',
|
||||
'戈', '给', '根', '刯', '工', '勾',
|
||||
'估', '瓜', '乖', '关', '光', '归',
|
||||
'丨', '呙', '哈', '咍', '佄', '夯',
|
||||
'茠', '诃', '黒', '拫', '亨', '噷',
|
||||
'叿', '齁', '乯', '花', '怀', '犿',
|
||||
'巟', '灰', '昏', '吙', '丌', '加',
|
||||
'戋', '江', '艽', '阶', '巾', '坕',
|
||||
'冂', '丩', '凥', '姢', '噘', '军',
|
||||
'咔', '开', '刊', '忼', '尻', '匼',
|
||||
'肎', '劥', '空', '抠', '扝', '夸',
|
||||
'蒯', '宽', '匡', '亏', '坤', '扩',
|
||||
'垃', '来', '兰', '啷', '捞', '肋',
|
||||
'勒', '崚', '刕', '俩', '奁', '良',
|
||||
'撩', '列', '拎', '刢', '溜', '囖',
|
||||
'龙', '瞜', '噜', '娈', '畧', '抡',
|
||||
'罗', '呣', '妈', '埋', '嫚', '牤',
|
||||
'猫', '么', '呅', '门', '甿', '咪',
|
||||
'宀', '喵', '乜', '民', '名', '谬',
|
||||
'摸', '哞', '毪', '嗯', '拏', '腉',
|
||||
'囡', '囔', '孬', '疒', '娞', '恁',
|
||||
'能', '妮', '拈', '嬢', '鸟', '捏',
|
||||
'囜', '宁', '妞', '农', '羺', '奴',
|
||||
'奻', '疟', '黁', '郍', '喔', '讴',
|
||||
'妑', '拍', '眅', '乓', '抛', '呸',
|
||||
'喷', '匉', '丕', '囨', '剽', '氕',
|
||||
'姘', '乒', '钋', '剖', '仆', '七',
|
||||
'掐', '千', '呛', '悄', '癿', '亲',
|
||||
'狅', '芎', '丘', '区', '峑', '缺',
|
||||
'夋', '呥', '穣', '娆', '惹', '人',
|
||||
'扔', '日', '茸', '厹', '邚', '挼',
|
||||
'堧', '婑', '瞤', '捼', '仨', '毢',
|
||||
'三', '桒', '掻', '閪', '森', '僧',
|
||||
'杀', '筛', '山', '伤', '弰', '奢',
|
||||
'申', '莘', '敒', '升', '尸', '収',
|
||||
'书', '刷', '衰', '闩', '双', '谁',
|
||||
'吮', '说', '厶', '忪', '捜', '苏',
|
||||
'狻', '夊', '孙', '唆', '他', '囼',
|
||||
'坍', '汤', '夲', '忑', '熥', '剔',
|
||||
'天', '旫', '帖', '厅', '囲', '偷',
|
||||
'凸', '湍', '推', '吞', '乇', '穵',
|
||||
'歪', '弯', '尣', '危', '昷', '翁',
|
||||
'挝', '乌', '夕', '虲', '仚', '乡',
|
||||
'灱', '些', '心', '星', '凶', '休',
|
||||
'吁', '吅', '削', '坃', '丫', '恹',
|
||||
'央', '幺', '倻', '一', '囙', '应',
|
||||
'哟', '佣', '优', '扜', '囦', '曰',
|
||||
'晕', '筠', '筼', '帀', '災', '兂',
|
||||
'匨', '傮', '则', '贼', '怎', '増',
|
||||
'扎', '捚', '沾', '张', '长', '長',
|
||||
'佋', '蜇', '贞', '争', '之', '峙',
|
||||
'庢', '中', '州', '朱', '抓', '拽',
|
||||
'专', '妆', '隹', '宒', '卓', '乲',
|
||||
'宗', '邹', '租', '钻', '厜', '尊',
|
||||
'昨', '兙', '鿃', '鿄'
|
||||
)
|
||||
|
||||
val PINYINS = arrayOf(
|
||||
byteArrayOf(65, 0, 0, 0, 0, 0), byteArrayOf(65, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(65, 78, 0, 0, 0, 0), byteArrayOf(65, 78, 71, 0, 0, 0),
|
||||
byteArrayOf(65, 79, 0, 0, 0, 0), byteArrayOf(66, 65, 0, 0, 0, 0),
|
||||
byteArrayOf(66, 65, 73, 0, 0, 0), byteArrayOf(66, 65, 78, 0, 0, 0),
|
||||
byteArrayOf(66, 65, 78, 71, 0, 0), byteArrayOf(66, 65, 79, 0, 0, 0),
|
||||
byteArrayOf(66, 69, 73, 0, 0, 0), byteArrayOf(66, 69, 78, 0, 0, 0),
|
||||
byteArrayOf(66, 69, 78, 71, 0, 0), byteArrayOf(66, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(66, 73, 65, 78, 0, 0), byteArrayOf(66, 73, 65, 79, 0, 0),
|
||||
byteArrayOf(66, 73, 69, 0, 0, 0), byteArrayOf(66, 73, 78, 0, 0, 0),
|
||||
byteArrayOf(66, 73, 78, 71, 0, 0), byteArrayOf(66, 79, 0, 0, 0, 0),
|
||||
byteArrayOf(66, 85, 0, 0, 0, 0), byteArrayOf(67, 65, 0, 0, 0, 0),
|
||||
byteArrayOf(67, 65, 73, 0, 0, 0), byteArrayOf(67, 65, 78, 0, 0, 0),
|
||||
byteArrayOf(67, 65, 78, 71, 0, 0), byteArrayOf(67, 65, 79, 0, 0, 0),
|
||||
byteArrayOf(67, 69, 0, 0, 0, 0), byteArrayOf(67, 69, 78, 0, 0, 0),
|
||||
byteArrayOf(67, 69, 78, 71, 0, 0), byteArrayOf(90, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(67, 69, 78, 71, 0, 0), byteArrayOf(67, 72, 65, 0, 0, 0),
|
||||
byteArrayOf(67, 72, 65, 73, 0, 0), byteArrayOf(67, 72, 65, 78, 0, 0),
|
||||
byteArrayOf(67, 72, 65, 78, 71, 0), byteArrayOf(67, 72, 65, 79, 0, 0),
|
||||
byteArrayOf(67, 72, 69, 0, 0, 0), byteArrayOf(67, 72, 69, 78, 0, 0),
|
||||
byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(67, 72, 69, 78, 0, 0),
|
||||
byteArrayOf(67, 72, 69, 78, 71, 0), byteArrayOf(67, 72, 73, 0, 0, 0),
|
||||
byteArrayOf(67, 72, 79, 78, 71, 0), byteArrayOf(67, 72, 79, 85, 0, 0),
|
||||
byteArrayOf(67, 72, 85, 0, 0, 0), byteArrayOf(67, 72, 85, 65, 0, 0),
|
||||
byteArrayOf(67, 72, 85, 65, 73, 0), byteArrayOf(67, 72, 85, 65, 78, 0),
|
||||
byteArrayOf(67, 72, 85, 65, 78, 71), byteArrayOf(67, 72, 85, 73, 0, 0),
|
||||
byteArrayOf(67, 72, 85, 78, 0, 0), byteArrayOf(67, 72, 85, 79, 0, 0),
|
||||
byteArrayOf(67, 73, 0, 0, 0, 0), byteArrayOf(67, 79, 78, 71, 0, 0),
|
||||
byteArrayOf(67, 79, 85, 0, 0, 0), byteArrayOf(67, 85, 0, 0, 0, 0),
|
||||
byteArrayOf(67, 85, 65, 78, 0, 0), byteArrayOf(67, 85, 73, 0, 0, 0),
|
||||
byteArrayOf(67, 85, 78, 0, 0, 0), byteArrayOf(67, 85, 79, 0, 0, 0),
|
||||
byteArrayOf(68, 65, 0, 0, 0, 0), byteArrayOf(68, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(68, 65, 78, 0, 0, 0), byteArrayOf(68, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(68, 65, 79, 0, 0, 0), byteArrayOf(68, 69, 0, 0, 0, 0),
|
||||
byteArrayOf(68, 69, 78, 0, 0, 0), byteArrayOf(68, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(68, 73, 0, 0, 0, 0), byteArrayOf(68, 73, 65, 0, 0, 0),
|
||||
byteArrayOf(68, 73, 65, 78, 0, 0), byteArrayOf(68, 73, 65, 79, 0, 0),
|
||||
byteArrayOf(68, 73, 69, 0, 0, 0), byteArrayOf(68, 73, 78, 71, 0, 0),
|
||||
byteArrayOf(68, 73, 85, 0, 0, 0), byteArrayOf(68, 79, 78, 71, 0, 0),
|
||||
byteArrayOf(68, 79, 85, 0, 0, 0), byteArrayOf(68, 85, 0, 0, 0, 0),
|
||||
byteArrayOf(68, 85, 65, 78, 0, 0), byteArrayOf(68, 85, 73, 0, 0, 0),
|
||||
byteArrayOf(68, 85, 78, 0, 0, 0), byteArrayOf(68, 85, 79, 0, 0, 0),
|
||||
byteArrayOf(69, 0, 0, 0, 0, 0), byteArrayOf(69, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(69, 78, 0, 0, 0, 0), byteArrayOf(69, 78, 71, 0, 0, 0),
|
||||
byteArrayOf(69, 82, 0, 0, 0, 0), byteArrayOf(70, 65, 0, 0, 0, 0),
|
||||
byteArrayOf(70, 65, 78, 0, 0, 0), byteArrayOf(70, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(70, 69, 73, 0, 0, 0), byteArrayOf(70, 69, 78, 0, 0, 0),
|
||||
byteArrayOf(70, 69, 78, 71, 0, 0), byteArrayOf(70, 73, 65, 79, 0, 0),
|
||||
byteArrayOf(70, 79, 0, 0, 0, 0), byteArrayOf(70, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(70, 85, 0, 0, 0, 0), byteArrayOf(71, 65, 0, 0, 0, 0),
|
||||
byteArrayOf(71, 65, 73, 0, 0, 0), byteArrayOf(71, 65, 78, 0, 0, 0),
|
||||
byteArrayOf(71, 65, 78, 71, 0, 0), byteArrayOf(71, 65, 79, 0, 0, 0),
|
||||
byteArrayOf(71, 69, 0, 0, 0, 0), byteArrayOf(71, 69, 73, 0, 0, 0),
|
||||
byteArrayOf(71, 69, 78, 0, 0, 0), byteArrayOf(71, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(71, 79, 78, 71, 0, 0), byteArrayOf(71, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(71, 85, 0, 0, 0, 0), byteArrayOf(71, 85, 65, 0, 0, 0),
|
||||
byteArrayOf(71, 85, 65, 73, 0, 0), byteArrayOf(71, 85, 65, 78, 0, 0),
|
||||
byteArrayOf(71, 85, 65, 78, 71, 0), byteArrayOf(71, 85, 73, 0, 0, 0),
|
||||
byteArrayOf(71, 85, 78, 0, 0, 0), byteArrayOf(71, 85, 79, 0, 0, 0),
|
||||
byteArrayOf(72, 65, 0, 0, 0, 0), byteArrayOf(72, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(72, 65, 78, 0, 0, 0), byteArrayOf(72, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(72, 65, 79, 0, 0, 0), byteArrayOf(72, 69, 0, 0, 0, 0),
|
||||
byteArrayOf(72, 69, 73, 0, 0, 0), byteArrayOf(72, 69, 78, 0, 0, 0),
|
||||
byteArrayOf(72, 69, 78, 71, 0, 0), byteArrayOf(72, 77, 0, 0, 0, 0),
|
||||
byteArrayOf(72, 79, 78, 71, 0, 0), byteArrayOf(72, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(72, 85, 0, 0, 0, 0), byteArrayOf(72, 85, 65, 0, 0, 0),
|
||||
byteArrayOf(72, 85, 65, 73, 0, 0), byteArrayOf(72, 85, 65, 78, 0, 0),
|
||||
byteArrayOf(72, 85, 65, 78, 71, 0), byteArrayOf(72, 85, 73, 0, 0, 0),
|
||||
byteArrayOf(72, 85, 78, 0, 0, 0), byteArrayOf(72, 85, 79, 0, 0, 0),
|
||||
byteArrayOf(74, 73, 0, 0, 0, 0), byteArrayOf(74, 73, 65, 0, 0, 0),
|
||||
byteArrayOf(74, 73, 65, 78, 0, 0), byteArrayOf(74, 73, 65, 78, 71, 0),
|
||||
byteArrayOf(74, 73, 65, 79, 0, 0), byteArrayOf(74, 73, 69, 0, 0, 0),
|
||||
byteArrayOf(74, 73, 78, 0, 0, 0), byteArrayOf(74, 73, 78, 71, 0, 0),
|
||||
byteArrayOf(74, 73, 79, 78, 71, 0), byteArrayOf(74, 73, 85, 0, 0, 0),
|
||||
byteArrayOf(74, 85, 0, 0, 0, 0), byteArrayOf(74, 85, 65, 78, 0, 0),
|
||||
byteArrayOf(74, 85, 69, 0, 0, 0), byteArrayOf(74, 85, 78, 0, 0, 0),
|
||||
byteArrayOf(75, 65, 0, 0, 0, 0), byteArrayOf(75, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(75, 65, 78, 0, 0, 0), byteArrayOf(75, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(75, 65, 79, 0, 0, 0), byteArrayOf(75, 69, 0, 0, 0, 0),
|
||||
byteArrayOf(75, 69, 78, 0, 0, 0), byteArrayOf(75, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(75, 79, 78, 71, 0, 0), byteArrayOf(75, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(75, 85, 0, 0, 0, 0), byteArrayOf(75, 85, 65, 0, 0, 0),
|
||||
byteArrayOf(75, 85, 65, 73, 0, 0), byteArrayOf(75, 85, 65, 78, 0, 0),
|
||||
byteArrayOf(75, 85, 65, 78, 71, 0), byteArrayOf(75, 85, 73, 0, 0, 0),
|
||||
byteArrayOf(75, 85, 78, 0, 0, 0), byteArrayOf(75, 85, 79, 0, 0, 0),
|
||||
byteArrayOf(76, 65, 0, 0, 0, 0), byteArrayOf(76, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(76, 65, 78, 0, 0, 0), byteArrayOf(76, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(76, 65, 79, 0, 0, 0), byteArrayOf(76, 69, 0, 0, 0, 0),
|
||||
byteArrayOf(76, 69, 73, 0, 0, 0), byteArrayOf(76, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(76, 73, 0, 0, 0, 0), byteArrayOf(76, 73, 65, 0, 0, 0),
|
||||
byteArrayOf(76, 73, 65, 78, 0, 0), byteArrayOf(76, 73, 65, 78, 71, 0),
|
||||
byteArrayOf(76, 73, 65, 79, 0, 0), byteArrayOf(76, 73, 69, 0, 0, 0),
|
||||
byteArrayOf(76, 73, 78, 0, 0, 0), byteArrayOf(76, 73, 78, 71, 0, 0),
|
||||
byteArrayOf(76, 73, 85, 0, 0, 0), byteArrayOf(76, 79, 0, 0, 0, 0),
|
||||
byteArrayOf(76, 79, 78, 71, 0, 0), byteArrayOf(76, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(76, 85, 0, 0, 0, 0), byteArrayOf(76, 85, 65, 78, 0, 0),
|
||||
byteArrayOf(76, 85, 69, 0, 0, 0), byteArrayOf(76, 85, 78, 0, 0, 0),
|
||||
byteArrayOf(76, 85, 79, 0, 0, 0), byteArrayOf(77, 0, 0, 0, 0, 0),
|
||||
byteArrayOf(77, 65, 0, 0, 0, 0), byteArrayOf(77, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(77, 65, 78, 0, 0, 0), byteArrayOf(77, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(77, 65, 79, 0, 0, 0), byteArrayOf(77, 69, 0, 0, 0, 0),
|
||||
byteArrayOf(77, 69, 73, 0, 0, 0), byteArrayOf(77, 69, 78, 0, 0, 0),
|
||||
byteArrayOf(77, 69, 78, 71, 0, 0), byteArrayOf(77, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(77, 73, 65, 78, 0, 0), byteArrayOf(77, 73, 65, 79, 0, 0),
|
||||
byteArrayOf(77, 73, 69, 0, 0, 0), byteArrayOf(77, 73, 78, 0, 0, 0),
|
||||
byteArrayOf(77, 73, 78, 71, 0, 0), byteArrayOf(77, 73, 85, 0, 0, 0),
|
||||
byteArrayOf(77, 79, 0, 0, 0, 0), byteArrayOf(77, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(77, 85, 0, 0, 0, 0), byteArrayOf(78, 0, 0, 0, 0, 0),
|
||||
byteArrayOf(78, 65, 0, 0, 0, 0), byteArrayOf(78, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(78, 65, 78, 0, 0, 0), byteArrayOf(78, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(78, 65, 79, 0, 0, 0), byteArrayOf(78, 69, 0, 0, 0, 0),
|
||||
byteArrayOf(78, 69, 73, 0, 0, 0), byteArrayOf(78, 69, 78, 0, 0, 0),
|
||||
byteArrayOf(78, 69, 78, 71, 0, 0), byteArrayOf(78, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(78, 73, 65, 78, 0, 0), byteArrayOf(78, 73, 65, 78, 71, 0),
|
||||
byteArrayOf(78, 73, 65, 79, 0, 0), byteArrayOf(78, 73, 69, 0, 0, 0),
|
||||
byteArrayOf(78, 73, 78, 0, 0, 0), byteArrayOf(78, 73, 78, 71, 0, 0),
|
||||
byteArrayOf(78, 73, 85, 0, 0, 0), byteArrayOf(78, 79, 78, 71, 0, 0),
|
||||
byteArrayOf(78, 79, 85, 0, 0, 0), byteArrayOf(78, 85, 0, 0, 0, 0),
|
||||
byteArrayOf(78, 85, 65, 78, 0, 0), byteArrayOf(78, 85, 69, 0, 0, 0),
|
||||
byteArrayOf(78, 85, 78, 0, 0, 0), byteArrayOf(78, 85, 79, 0, 0, 0),
|
||||
byteArrayOf(79, 0, 0, 0, 0, 0), byteArrayOf(79, 85, 0, 0, 0, 0),
|
||||
byteArrayOf(80, 65, 0, 0, 0, 0), byteArrayOf(80, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(80, 65, 78, 0, 0, 0), byteArrayOf(80, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(80, 65, 79, 0, 0, 0), byteArrayOf(80, 69, 73, 0, 0, 0),
|
||||
byteArrayOf(80, 69, 78, 0, 0, 0), byteArrayOf(80, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(80, 73, 0, 0, 0, 0), byteArrayOf(80, 73, 65, 78, 0, 0),
|
||||
byteArrayOf(80, 73, 65, 79, 0, 0), byteArrayOf(80, 73, 69, 0, 0, 0),
|
||||
byteArrayOf(80, 73, 78, 0, 0, 0), byteArrayOf(80, 73, 78, 71, 0, 0),
|
||||
byteArrayOf(80, 79, 0, 0, 0, 0), byteArrayOf(80, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(80, 85, 0, 0, 0, 0), byteArrayOf(81, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(81, 73, 65, 0, 0, 0), byteArrayOf(81, 73, 65, 78, 0, 0),
|
||||
byteArrayOf(81, 73, 65, 78, 71, 0), byteArrayOf(81, 73, 65, 79, 0, 0),
|
||||
byteArrayOf(81, 73, 69, 0, 0, 0), byteArrayOf(81, 73, 78, 0, 0, 0),
|
||||
byteArrayOf(81, 73, 78, 71, 0, 0), byteArrayOf(81, 73, 79, 78, 71, 0),
|
||||
byteArrayOf(81, 73, 85, 0, 0, 0), byteArrayOf(81, 85, 0, 0, 0, 0),
|
||||
byteArrayOf(81, 85, 65, 78, 0, 0), byteArrayOf(81, 85, 69, 0, 0, 0),
|
||||
byteArrayOf(81, 85, 78, 0, 0, 0), byteArrayOf(82, 65, 78, 0, 0, 0),
|
||||
byteArrayOf(82, 65, 78, 71, 0, 0), byteArrayOf(82, 65, 79, 0, 0, 0),
|
||||
byteArrayOf(82, 69, 0, 0, 0, 0), byteArrayOf(82, 69, 78, 0, 0, 0),
|
||||
byteArrayOf(82, 69, 78, 71, 0, 0), byteArrayOf(82, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(82, 79, 78, 71, 0, 0), byteArrayOf(82, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(82, 85, 0, 0, 0, 0), byteArrayOf(82, 85, 65, 0, 0, 0),
|
||||
byteArrayOf(82, 85, 65, 78, 0, 0), byteArrayOf(82, 85, 73, 0, 0, 0),
|
||||
byteArrayOf(82, 85, 78, 0, 0, 0), byteArrayOf(82, 85, 79, 0, 0, 0),
|
||||
byteArrayOf(83, 65, 0, 0, 0, 0), byteArrayOf(83, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(83, 65, 78, 0, 0, 0), byteArrayOf(83, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(83, 65, 79, 0, 0, 0), byteArrayOf(83, 69, 0, 0, 0, 0),
|
||||
byteArrayOf(83, 69, 78, 0, 0, 0), byteArrayOf(83, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(83, 72, 65, 0, 0, 0), byteArrayOf(83, 72, 65, 73, 0, 0),
|
||||
byteArrayOf(83, 72, 65, 78, 0, 0), byteArrayOf(83, 72, 65, 78, 71, 0),
|
||||
byteArrayOf(83, 72, 65, 79, 0, 0), byteArrayOf(83, 72, 69, 0, 0, 0),
|
||||
byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(88, 73, 78, 0, 0, 0),
|
||||
byteArrayOf(83, 72, 69, 78, 0, 0), byteArrayOf(83, 72, 69, 78, 71, 0),
|
||||
byteArrayOf(83, 72, 73, 0, 0, 0), byteArrayOf(83, 72, 79, 85, 0, 0),
|
||||
byteArrayOf(83, 72, 85, 0, 0, 0), byteArrayOf(83, 72, 85, 65, 0, 0),
|
||||
byteArrayOf(83, 72, 85, 65, 73, 0), byteArrayOf(83, 72, 85, 65, 78, 0),
|
||||
byteArrayOf(83, 72, 85, 65, 78, 71), byteArrayOf(83, 72, 85, 73, 0, 0),
|
||||
byteArrayOf(83, 72, 85, 78, 0, 0), byteArrayOf(83, 72, 85, 79, 0, 0),
|
||||
byteArrayOf(83, 73, 0, 0, 0, 0), byteArrayOf(83, 79, 78, 71, 0, 0),
|
||||
byteArrayOf(83, 79, 85, 0, 0, 0), byteArrayOf(83, 85, 0, 0, 0, 0),
|
||||
byteArrayOf(83, 85, 65, 78, 0, 0), byteArrayOf(83, 85, 73, 0, 0, 0),
|
||||
byteArrayOf(83, 85, 78, 0, 0, 0), byteArrayOf(83, 85, 79, 0, 0, 0),
|
||||
byteArrayOf(84, 65, 0, 0, 0, 0), byteArrayOf(84, 65, 73, 0, 0, 0),
|
||||
byteArrayOf(84, 65, 78, 0, 0, 0), byteArrayOf(84, 65, 78, 71, 0, 0),
|
||||
byteArrayOf(84, 65, 79, 0, 0, 0), byteArrayOf(84, 69, 0, 0, 0, 0),
|
||||
byteArrayOf(84, 69, 78, 71, 0, 0), byteArrayOf(84, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(84, 73, 65, 78, 0, 0), byteArrayOf(84, 73, 65, 79, 0, 0),
|
||||
byteArrayOf(84, 73, 69, 0, 0, 0), byteArrayOf(84, 73, 78, 71, 0, 0),
|
||||
byteArrayOf(84, 79, 78, 71, 0, 0), byteArrayOf(84, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(84, 85, 0, 0, 0, 0), byteArrayOf(84, 85, 65, 78, 0, 0),
|
||||
byteArrayOf(84, 85, 73, 0, 0, 0), byteArrayOf(84, 85, 78, 0, 0, 0),
|
||||
byteArrayOf(84, 85, 79, 0, 0, 0), byteArrayOf(87, 65, 0, 0, 0, 0),
|
||||
byteArrayOf(87, 65, 73, 0, 0, 0), byteArrayOf(87, 65, 78, 0, 0, 0),
|
||||
byteArrayOf(87, 65, 78, 71, 0, 0), byteArrayOf(87, 69, 73, 0, 0, 0),
|
||||
byteArrayOf(87, 69, 78, 0, 0, 0), byteArrayOf(87, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(87, 79, 0, 0, 0, 0), byteArrayOf(87, 85, 0, 0, 0, 0),
|
||||
byteArrayOf(88, 73, 0, 0, 0, 0), byteArrayOf(88, 73, 65, 0, 0, 0),
|
||||
byteArrayOf(88, 73, 65, 78, 0, 0), byteArrayOf(88, 73, 65, 78, 71, 0),
|
||||
byteArrayOf(88, 73, 65, 79, 0, 0), byteArrayOf(88, 73, 69, 0, 0, 0),
|
||||
byteArrayOf(88, 73, 78, 0, 0, 0), byteArrayOf(88, 73, 78, 71, 0, 0),
|
||||
byteArrayOf(88, 73, 79, 78, 71, 0), byteArrayOf(88, 73, 85, 0, 0, 0),
|
||||
byteArrayOf(88, 85, 0, 0, 0, 0), byteArrayOf(88, 85, 65, 78, 0, 0),
|
||||
byteArrayOf(88, 85, 69, 0, 0, 0), byteArrayOf(88, 85, 78, 0, 0, 0),
|
||||
byteArrayOf(89, 65, 0, 0, 0, 0), byteArrayOf(89, 65, 78, 0, 0, 0),
|
||||
byteArrayOf(89, 65, 78, 71, 0, 0), byteArrayOf(89, 65, 79, 0, 0, 0),
|
||||
byteArrayOf(89, 69, 0, 0, 0, 0), byteArrayOf(89, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(89, 73, 78, 0, 0, 0), byteArrayOf(89, 73, 78, 71, 0, 0),
|
||||
byteArrayOf(89, 79, 0, 0, 0, 0), byteArrayOf(89, 79, 78, 71, 0, 0),
|
||||
byteArrayOf(89, 79, 85, 0, 0, 0), byteArrayOf(89, 85, 0, 0, 0, 0),
|
||||
byteArrayOf(89, 85, 65, 78, 0, 0), byteArrayOf(89, 85, 69, 0, 0, 0),
|
||||
byteArrayOf(89, 85, 78, 0, 0, 0), byteArrayOf(74, 85, 78, 0, 0, 0),
|
||||
byteArrayOf(89, 85, 78, 0, 0, 0), byteArrayOf(90, 65, 0, 0, 0, 0),
|
||||
byteArrayOf(90, 65, 73, 0, 0, 0), byteArrayOf(90, 65, 78, 0, 0, 0),
|
||||
byteArrayOf(90, 65, 78, 71, 0, 0), byteArrayOf(90, 65, 79, 0, 0, 0),
|
||||
byteArrayOf(90, 69, 0, 0, 0, 0), byteArrayOf(90, 69, 73, 0, 0, 0),
|
||||
byteArrayOf(90, 69, 78, 0, 0, 0), byteArrayOf(90, 69, 78, 71, 0, 0),
|
||||
byteArrayOf(90, 72, 65, 0, 0, 0), byteArrayOf(90, 72, 65, 73, 0, 0),
|
||||
byteArrayOf(90, 72, 65, 78, 0, 0), byteArrayOf(90, 72, 65, 78, 71, 0),
|
||||
byteArrayOf(67, 72, 65, 78, 71, 0), byteArrayOf(90, 72, 65, 78, 71, 0),
|
||||
byteArrayOf(90, 72, 65, 79, 0, 0), byteArrayOf(90, 72, 69, 0, 0, 0),
|
||||
byteArrayOf(90, 72, 69, 78, 0, 0), byteArrayOf(90, 72, 69, 78, 71, 0),
|
||||
byteArrayOf(90, 72, 73, 0, 0, 0), byteArrayOf(83, 72, 73, 0, 0, 0),
|
||||
byteArrayOf(90, 72, 73, 0, 0, 0), byteArrayOf(90, 72, 79, 78, 71, 0),
|
||||
byteArrayOf(90, 72, 79, 85, 0, 0), byteArrayOf(90, 72, 85, 0, 0, 0),
|
||||
byteArrayOf(90, 72, 85, 65, 0, 0), byteArrayOf(90, 72, 85, 65, 73, 0),
|
||||
byteArrayOf(90, 72, 85, 65, 78, 0), byteArrayOf(90, 72, 85, 65, 78, 71),
|
||||
byteArrayOf(90, 72, 85, 73, 0, 0), byteArrayOf(90, 72, 85, 78, 0, 0),
|
||||
byteArrayOf(90, 72, 85, 79, 0, 0), byteArrayOf(90, 73, 0, 0, 0, 0),
|
||||
byteArrayOf(90, 79, 78, 71, 0, 0), byteArrayOf(90, 79, 85, 0, 0, 0),
|
||||
byteArrayOf(90, 85, 0, 0, 0, 0), byteArrayOf(90, 85, 65, 78, 0, 0),
|
||||
byteArrayOf(90, 85, 73, 0, 0, 0), byteArrayOf(90, 85, 78, 0, 0, 0),
|
||||
byteArrayOf(90, 85, 79, 0, 0, 0), byteArrayOf(0, 0, 0, 0, 0, 0),
|
||||
byteArrayOf(83, 72, 65, 78, 0, 0), byteArrayOf(0, 0, 0, 0, 0, 0)
|
||||
)
|
||||
|
||||
private const val FIRST_PINYIN_UNIHAN = "阿"
|
||||
private const val LAST_PINYIN_UNIHAN = "鿿"
|
||||
|
||||
private val COLLATOR: Collator = Collator.getInstance(Locale.CHINA)
|
||||
|
||||
private var sInstance: HanziToPinyin? = null
|
||||
|
||||
fun getInstance(): HanziToPinyin {
|
||||
synchronized(HanziToPinyin::class.java) {
|
||||
if (sInstance != null) {
|
||||
return sInstance!!
|
||||
}
|
||||
|
||||
val locale = Collator.getAvailableLocales()
|
||||
for (value in locale) {
|
||||
if (value == Locale.CHINA || value.language.contains("zh")) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Self validation. Result: ${doSelfValidation()}")
|
||||
}
|
||||
sInstance = HanziToPinyin(true)
|
||||
return sInstance!!
|
||||
}
|
||||
}
|
||||
|
||||
if (sInstance == null) {
|
||||
if (Locale.CHINA == Locale.getDefault()) {
|
||||
sInstance = HanziToPinyin(true)
|
||||
return sInstance!!
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "There is no Chinese collator, HanziToPinyin is disabled")
|
||||
sInstance = HanziToPinyin(false)
|
||||
return sInstance!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun doSelfValidation(): Boolean {
|
||||
val lastChar = UNIHANS[0]
|
||||
var lastString = lastChar.toString()
|
||||
for (c in UNIHANS) {
|
||||
if (lastChar == c) {
|
||||
continue
|
||||
}
|
||||
val curString = c.toString()
|
||||
val cmp = COLLATOR.compare(lastString, curString)
|
||||
if (cmp >= 0) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Internal error in Unihan table. The last string \"$lastString\" " +
|
||||
"is greater than current string \"$curString\"."
|
||||
)
|
||||
return false
|
||||
}
|
||||
lastString = curString
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,13 @@ private fun getKsuDaemonPath(): String {
|
||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksud.so"
|
||||
}
|
||||
|
||||
data class FlashResult(val code: Int, val err: String, val showReboot: Boolean) {
|
||||
constructor(result: Shell.Result, showReboot: Boolean) : this(result.code, result.err.joinToString("\n"), showReboot)
|
||||
constructor(result: Shell.Result) : this(result, result.isSuccess)
|
||||
}
|
||||
|
||||
object KsuCli {
|
||||
var SHELL: Shell = createRootShell()
|
||||
val SHELL: Shell = createRootShell()
|
||||
val GLOBAL_MNT_SHELL: Shell = createRootShell(true)
|
||||
}
|
||||
|
||||
@@ -135,6 +140,13 @@ fun toggleModule(id: String, enable: Boolean): Boolean {
|
||||
return result
|
||||
}
|
||||
|
||||
fun undoUninstallModule(id: String): Boolean {
|
||||
val cmd = "module undo-uninstall $id"
|
||||
val result = execKsud(cmd, true)
|
||||
Log.i(TAG, "undo uninstall module $id result: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun uninstallModule(id: String): Boolean {
|
||||
val cmd = "module uninstall $id"
|
||||
val result = execKsud(cmd, true)
|
||||
@@ -142,13 +154,6 @@ fun uninstallModule(id: String): Boolean {
|
||||
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,
|
||||
@@ -174,10 +179,9 @@ private fun flashWithIO(
|
||||
|
||||
fun flashModule(
|
||||
uri: Uri,
|
||||
onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit
|
||||
): Boolean {
|
||||
): FlashResult {
|
||||
val resolver = ksuApp.contentResolver
|
||||
with(resolver.openInputStream(uri)) {
|
||||
val file = File(ksuApp.cacheDir, "module.zip")
|
||||
@@ -190,8 +194,7 @@ fun flashModule(
|
||||
|
||||
file.delete()
|
||||
|
||||
onFinish(result.isSuccess, result.code)
|
||||
return result.isSuccess
|
||||
return FlashResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,26 +223,19 @@ fun runModuleAction(
|
||||
}
|
||||
|
||||
fun restoreBoot(
|
||||
onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
||||
): Boolean {
|
||||
onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
||||
): FlashResult {
|
||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so")
|
||||
val result = flashWithIO(
|
||||
"${getKsuDaemonPath()} boot-restore -f --magiskboot $magiskboot",
|
||||
onStdout,
|
||||
onStderr
|
||||
)
|
||||
onFinish(result.isSuccess, result.code)
|
||||
return result.isSuccess
|
||||
val result = flashWithIO("${getKsuDaemonPath()} boot-restore -f --magiskboot $magiskboot", onStdout, onStderr)
|
||||
return FlashResult(result)
|
||||
}
|
||||
|
||||
fun uninstallPermanently(
|
||||
onFinish: (Boolean, Int) -> Unit, onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
||||
): Boolean {
|
||||
onStdout: (String) -> Unit, onStderr: (String) -> Unit
|
||||
): FlashResult {
|
||||
val magiskboot = File(ksuApp.applicationInfo.nativeLibraryDir, "libmagiskboot.so")
|
||||
val result =
|
||||
flashWithIO("${getKsuDaemonPath()} uninstall --magiskboot $magiskboot", onStdout, onStderr)
|
||||
onFinish(result.isSuccess, result.code)
|
||||
return result.isSuccess
|
||||
val result = flashWithIO("${getKsuDaemonPath()} uninstall --magiskboot $magiskboot", onStdout, onStderr)
|
||||
return FlashResult(result)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@@ -254,10 +250,9 @@ fun installBoot(
|
||||
lkm: LkmSelection,
|
||||
ota: Boolean,
|
||||
partition: String?,
|
||||
onFinish: (Boolean, Int) -> Unit,
|
||||
onStdout: (String) -> Unit,
|
||||
onStderr: (String) -> Unit,
|
||||
): Boolean {
|
||||
): FlashResult {
|
||||
val resolver = ksuApp.contentResolver
|
||||
|
||||
val bootFile = bootUri?.let { uri ->
|
||||
@@ -324,13 +319,11 @@ fun installBoot(
|
||||
lkmFile?.delete()
|
||||
|
||||
// if boot uri is empty, it is direct install, when success, we should show reboot button
|
||||
onFinish(bootUri == null && result.isSuccess, result.code)
|
||||
|
||||
if (bootUri == null && result.isSuccess) {
|
||||
install()
|
||||
val showReboot = bootUri == null && result.isSuccess // we create a temporary val here, to avoid calc showReboot double
|
||||
if (showReboot) { // because we decide do not update ksud when startActivity
|
||||
install() // install ksud here
|
||||
}
|
||||
|
||||
return result.isSuccess
|
||||
return FlashResult(result, showReboot)
|
||||
}
|
||||
|
||||
fun reboot(reason: String = "") {
|
||||
@@ -347,7 +340,6 @@ fun rootAvailable(): Boolean {
|
||||
return shell.isRoot
|
||||
}
|
||||
|
||||
|
||||
suspend fun getCurrentKmi(): String = withContext(Dispatchers.IO) {
|
||||
val shell = getRootShell()
|
||||
val cmd = "boot-info current-kmi"
|
||||
@@ -394,6 +386,12 @@ suspend fun getAvailablePartitions(): List<String> = withContext(Dispatchers.IO)
|
||||
out.filter { it.isNotBlank() }.map { it.trim() }
|
||||
}
|
||||
|
||||
fun overlayFsAvailable(): Boolean {
|
||||
val shell = getRootShell()
|
||||
// check /proc/filesystems
|
||||
return ShellUtils.fastCmdResult(shell, "cat /proc/filesystems | grep overlay")
|
||||
}
|
||||
|
||||
fun hasMagisk(): Boolean {
|
||||
val shell = getRootShell(true)
|
||||
val result = shell.newJob().add("which magisk").exec()
|
||||
@@ -454,69 +452,6 @@ fun deleteAppProfileTemplate(id: String): Boolean {
|
||||
return shell.newJob().add("${getKsuDaemonPath()} profile delete-template '${id}'")
|
||||
.to(ArrayList(), null).exec().isSuccess
|
||||
}
|
||||
// KPM控制
|
||||
fun loadKpmModule(path: String, args: String? = null): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} kpm load $path ${args ?: ""}"
|
||||
return ShellUtils.fastCmd(shell, cmd)
|
||||
}
|
||||
|
||||
fun unloadKpmModule(name: String): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} kpm unload $name"
|
||||
return ShellUtils.fastCmd(shell, cmd)
|
||||
}
|
||||
|
||||
fun getKpmModuleCount(): Int {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} kpm num"
|
||||
val result = ShellUtils.fastCmd(shell, cmd)
|
||||
return result.trim().toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
fun runCmd(shell: Shell, cmd: String): String {
|
||||
return shell.newJob()
|
||||
.add(cmd)
|
||||
.to(mutableListOf<String>(), null)
|
||||
.exec().out
|
||||
.joinToString("\n")
|
||||
}
|
||||
|
||||
fun listKpmModules(): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} kpm list"
|
||||
return try {
|
||||
runCmd(shell, cmd).trim()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to list KPM modules", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun getKpmModuleInfo(name: String): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} kpm info $name"
|
||||
return try {
|
||||
runCmd(shell, cmd).trim()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get KPM module info: $name", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun controlKpmModule(name: String, args: String? = null): Int {
|
||||
val shell = getRootShell()
|
||||
val cmd = """${getKsuDaemonPath()} kpm control $name "${args ?: ""}""""
|
||||
val result = runCmd(shell, cmd)
|
||||
return result.trim().toIntOrNull() ?: -1
|
||||
}
|
||||
|
||||
fun getKpmVersion(): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} kpm version"
|
||||
val result = ShellUtils.fastCmd(shell, cmd)
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
fun forceStopApp(packageName: String) {
|
||||
val shell = getRootShell()
|
||||
@@ -525,7 +460,6 @@ fun forceStopApp(packageName: String) {
|
||||
}
|
||||
|
||||
fun launchApp(packageName: String) {
|
||||
|
||||
val shell = getRootShell()
|
||||
val result =
|
||||
shell.newJob()
|
||||
@@ -538,187 +472,3 @@ fun restartApp(packageName: String) {
|
||||
forceStopApp(packageName)
|
||||
launchApp(packageName)
|
||||
}
|
||||
|
||||
fun getSuSFSDaemonPath(): String {
|
||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksu_susfs.so"
|
||||
}
|
||||
|
||||
fun getSuSFSVersion(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} show version")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getSuSFSVariant(): String {
|
||||
val shell = getRootShell()
|
||||
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} show variant")
|
||||
return result
|
||||
}
|
||||
|
||||
fun getSuSFSFeatures(): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getSuSFSDaemonPath()} show enabled_features"
|
||||
return runCmd(shell, cmd)
|
||||
}
|
||||
|
||||
fun getZygiskImplement(): String {
|
||||
val shell = getRootShell()
|
||||
|
||||
val zygiskModuleIds = listOf(
|
||||
"zygisksu",
|
||||
"rezygisk",
|
||||
"shirokozygisk"
|
||||
)
|
||||
|
||||
for (moduleId in zygiskModuleIds) {
|
||||
val modulePath = "/data/adb/modules/$moduleId"
|
||||
when {
|
||||
ShellUtils.fastCmdResult(shell, "test -f $modulePath/module.prop && test ! -f $modulePath/disable") -> {
|
||||
val result = ShellUtils.fastCmd(shell, "grep '^name=' $modulePath/module.prop | cut -d'=' -f2")
|
||||
Log.i(TAG, "Zygisk implement: $result")
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Zygisk implement: None")
|
||||
return "None"
|
||||
}
|
||||
|
||||
fun getUidScannerDaemonPath(): String {
|
||||
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libuid_scanner.so"
|
||||
}
|
||||
|
||||
private const val targetPath = "/data/adb/uid_scanner"
|
||||
fun ensureUidScannerExecutable(): Boolean {
|
||||
val shell = getRootShell()
|
||||
val uidScannerPath = getUidScannerDaemonPath()
|
||||
if (!ShellUtils.fastCmdResult(shell, "test -f $targetPath")) {
|
||||
val copyResult = ShellUtils.fastCmdResult(shell, "cp $uidScannerPath $targetPath")
|
||||
if (!copyResult) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val result = ShellUtils.fastCmdResult(shell, "chmod 755 $targetPath")
|
||||
return result
|
||||
}
|
||||
|
||||
fun setUidAutoScan(enabled: Boolean): Boolean {
|
||||
val shell = getRootShell()
|
||||
if (!ensureUidScannerExecutable()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val enableValue = if (enabled) 1 else 0
|
||||
val cmd = "$targetPath --auto-scan $enableValue && $targetPath reload"
|
||||
val result = ShellUtils.fastCmdResult(shell, cmd)
|
||||
|
||||
val throneResult = Natives.setUidScannerEnabled(enabled)
|
||||
|
||||
return result && throneResult
|
||||
}
|
||||
|
||||
fun setUidMultiUserScan(enabled: Boolean): Boolean {
|
||||
val shell = getRootShell()
|
||||
if (!ensureUidScannerExecutable()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val enableValue = if (enabled) 1 else 0
|
||||
val cmd = "$targetPath --multi-user $enableValue && $targetPath reload"
|
||||
val result = ShellUtils.fastCmdResult(shell, cmd)
|
||||
return result
|
||||
}
|
||||
|
||||
fun getUidMultiUserScan(): Boolean {
|
||||
val shell = getRootShell()
|
||||
|
||||
val cmd = "grep 'multi_user_scan=' /data/misc/user_uid/uid_scanner.conf | cut -d'=' -f2"
|
||||
val result = ShellUtils.fastCmd(shell, cmd).trim()
|
||||
|
||||
return try {
|
||||
result.toInt() == 1
|
||||
} catch (_: NumberFormatException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanRuntimeEnvironment(): Boolean {
|
||||
val shell = getRootShell()
|
||||
return try {
|
||||
try {
|
||||
ShellUtils.fastCmd(shell, "/data/adb/uid_scanner stop")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
ShellUtils.fastCmdResult(shell, "rm -rf /data/misc/user_uid")
|
||||
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/uid_scanner")
|
||||
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/ksu/bin/user_uid")
|
||||
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/service.d/uid_scanner.sh")
|
||||
Natives.clearUidScannerEnvironment()
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun readUidScannerFile(): Boolean {
|
||||
val shell = getRootShell()
|
||||
return try {
|
||||
ShellUtils.fastCmd(shell, "cat /data/adb/ksu/.uid_scanner").trim() == "1"
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun addUmountPath(path: String, flags: Int): Boolean {
|
||||
val shell = getRootShell()
|
||||
val flagsArg = if (flags >= 0) "--flags $flags" else ""
|
||||
val cmd = "${getKsuDaemonPath()} umount add $path $flagsArg"
|
||||
val result = ShellUtils.fastCmdResult(shell, cmd)
|
||||
Log.i(TAG, "add umount path $path result: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun removeUmountPath(path: String): Boolean {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} umount remove $path"
|
||||
val result = ShellUtils.fastCmdResult(shell, cmd)
|
||||
Log.i(TAG, "remove umount path $path result: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun listUmountPaths(): String {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} umount list"
|
||||
return try {
|
||||
runCmd(shell, cmd).trim()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to list umount paths", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCustomUmountPaths(): Boolean {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} umount clear-custom"
|
||||
val result = ShellUtils.fastCmdResult(shell, cmd)
|
||||
Log.i(TAG, "clear custom umount paths result: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun saveUmountConfig(): Boolean {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} umount save"
|
||||
val result = ShellUtils.fastCmdResult(shell, cmd)
|
||||
Log.i(TAG, "save umount config result: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
fun applyUmountConfigToKernel(): Boolean {
|
||||
val shell = getRootShell()
|
||||
val cmd = "${getKsuDaemonPath()} umount apply"
|
||||
val result = ShellUtils.fastCmdResult(shell, cmd)
|
||||
Log.i(TAG, "apply umount config to kernel result: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
package com.sukisu.ultra.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.topjohnwu.superuser.Shell
|
||||
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)
|
||||
@Composable
|
||||
fun getSELinuxStatus(): String {
|
||||
val shell = Shell.Builder.create()
|
||||
.setFlags(Shell.FLAG_REDIRECT_STDERR)
|
||||
.build("sh")
|
||||
|
||||
val list = ArrayList<String>()
|
||||
val result = shell.use {
|
||||
it.newJob().add("getenforce").to(list, list).exec()
|
||||
}
|
||||
val output = result.out.joinToString("\n").trim()
|
||||
|
||||
if (result.isSuccess) {
|
||||
return when (output) {
|
||||
"Enforcing" -> stringResource(R.string.selinux_status_enforcing)
|
||||
"Permissive" -> stringResource(R.string.selinux_status_permissive)
|
||||
"Disabled" -> stringResource(R.string.selinux_status_disabled)
|
||||
else -> stringResource(R.string.selinux_status_unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (output.endsWith("Permission denied")) {
|
||||
stringResource(R.string.selinux_status_enforcing)
|
||||
} else {
|
||||
stringResource(R.string.selinux_status_unknown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.sukisu.ultra.ui.util
|
||||
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ksuApp
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
|
||||
private val PREFERRED_PKG_BY_SUID = mapOf(
|
||||
"android.uid.system" to "android",
|
||||
"android.uid.phone" to "com.android.phone",
|
||||
"android.uid.bluetooth" to "com.android.bluetooth",
|
||||
"android.uid.nfc" to "com.android.nfc",
|
||||
)
|
||||
|
||||
fun pickPrimary(apps: List<SuperUserViewModel.AppInfo>): SuperUserViewModel.AppInfo {
|
||||
if (apps.isEmpty()) throw IllegalArgumentException("apps must not be empty")
|
||||
val labeled = apps.filter { it.packageInfo.sharedUserLabel != 0 }
|
||||
if (labeled.isNotEmpty()) {
|
||||
return labeled.minWith(compareBy({ it.packageName.length }, { it.packageName }))
|
||||
}
|
||||
val bySuid = apps.groupBy { it.packageInfo.sharedUserId ?: "" }
|
||||
.filterKeys { it.startsWith("android.uid.") }
|
||||
if (bySuid.isEmpty()) return apps.first()
|
||||
val suid = bySuid.keys.minOf { it }
|
||||
val group = bySuid[suid] ?: apps
|
||||
val preferredPkg = PREFERRED_PKG_BY_SUID[suid]
|
||||
preferredPkg?.let { pkg ->
|
||||
group.firstOrNull { it.packageName == pkg }?.let { return it }
|
||||
}
|
||||
return group.minWith(compareBy({ it.packageName.length }, { it.packageName }))
|
||||
}
|
||||
|
||||
val ownerNameCache = mutableMapOf<Int, String>()
|
||||
fun ownerNameForUid(uid: Int): String {
|
||||
ownerNameCache[uid]?.let { return it.ifEmpty { uid.toString() } }
|
||||
val apps = SuperUserViewModel.apps.filter { it.uid == uid }
|
||||
val labeledApp = apps.firstOrNull { it.packageInfo.sharedUserLabel != 0 }
|
||||
val name = if (labeledApp != null) {
|
||||
val pm = ksuApp.packageManager
|
||||
val resId = labeledApp.packageInfo.sharedUserLabel
|
||||
val text = runCatching { pm.getText(labeledApp.packageName, resId, labeledApp.packageInfo.applicationInfo) }.getOrNull()
|
||||
text?.toString() ?: ""
|
||||
} else {
|
||||
Natives.getUserName(uid) ?: ""
|
||||
}
|
||||
val appId = uid % 100000
|
||||
val isAppRange = appId in 10000..19999
|
||||
val isUA = name.matches(Regex("u\\d+_a\\d+"))
|
||||
val sharedUserId = apps.firstOrNull { !it.packageInfo.sharedUserId.isNullOrEmpty() }?.packageInfo?.sharedUserId
|
||||
val finalName = if (isAppRange && isUA && !sharedUserId.isNullOrEmpty()) {
|
||||
sharedUserId
|
||||
} else {
|
||||
name
|
||||
}
|
||||
ownerNameCache[uid] = finalName
|
||||
return finalName.ifEmpty { uid.toString() }
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.sukisu.ultra.ui.util.module
|
||||
|
||||
data class LatestVersionInfo(
|
||||
val versionCode : Int = 0,
|
||||
val downloadUrl : String = "",
|
||||
val changelog : String = "",
|
||||
val versionName: String = ""
|
||||
val versionCode: Int = 0,
|
||||
val downloadUrl: String = "",
|
||||
val changelog: String = ""
|
||||
)
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
package com.sukisu.ultra.ui.util.module
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.util.reboot
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object ModuleModify {
|
||||
@Composable
|
||||
fun RestoreConfirmationDialog(
|
||||
showDialog: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = context.getString(R.string.restore_confirm_title),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = context.getString(R.string.restore_confirm_message),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(context.getString(R.string.confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(context.getString(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AllowlistRestoreConfirmationDialog(
|
||||
showDialog: Boolean,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
if (showDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = context.getString(R.string.allowlist_restore_confirm_title),
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = context.getString(R.string.allowlist_restore_confirm_message),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(context.getString(R.string.confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(context.getString(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun backupModules(context: Context, snackBarHost: SnackbarHostState, uri: Uri) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val busyboxPath = "/data/adb/ksu/bin/busybox"
|
||||
val moduleDir = "/data/adb/modules"
|
||||
|
||||
// 直接将tar输出重定向到用户选择的文件
|
||||
val command = """
|
||||
cd "$moduleDir" &&
|
||||
$busyboxPath tar -cz ./* > /proc/self/fd/1
|
||||
""".trimIndent()
|
||||
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
|
||||
|
||||
// 直接将tar输出写入到用户选择的文件
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
process.inputStream.copyTo(output)
|
||||
}
|
||||
|
||||
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
|
||||
if (process.exitValue() != 0) {
|
||||
throw IOException(context.getString(R.string.command_execution_failed, error))
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.backup_success),
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("Backup", context.getString(R.string.backup_failed, ""), e)
|
||||
withContext(Dispatchers.Main) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.backup_failed, e.message),
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restoreModules(
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState,
|
||||
uri: Uri,
|
||||
showConfirmDialog: (Boolean) -> Unit,
|
||||
confirmResult: CompletableDeferred<Boolean>
|
||||
) {
|
||||
// 显示确认对话框
|
||||
withContext(Dispatchers.Main) {
|
||||
showConfirmDialog(true)
|
||||
}
|
||||
|
||||
val userConfirmed = confirmResult.await()
|
||||
if (!userConfirmed) return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val busyboxPath = "/data/adb/ksu/bin/busybox"
|
||||
val moduleDir = "/data/adb/modules"
|
||||
|
||||
// 直接从用户选择的文件读取并解压
|
||||
val process = Runtime.getRuntime()
|
||||
.exec(arrayOf("su", "-c", "$busyboxPath tar -xz -C $moduleDir"))
|
||||
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
input.copyTo(process.outputStream)
|
||||
}
|
||||
process.outputStream.close()
|
||||
|
||||
process.waitFor()
|
||||
|
||||
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
|
||||
if (process.exitValue() != 0) {
|
||||
throw IOException(context.getString(R.string.command_execution_failed, error))
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
val snackbarResult = snackBarHost.showSnackbar(
|
||||
message = context.getString(R.string.restore_success),
|
||||
actionLabel = context.getString(R.string.restart_now),
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
if (snackbarResult == SnackbarResult.ActionPerformed) {
|
||||
reboot()
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("Restore", context.getString(R.string.restore_failed, ""), e)
|
||||
withContext(Dispatchers.Main) {
|
||||
snackBarHost.showSnackbar(
|
||||
message = context.getString(
|
||||
R.string.restore_failed,
|
||||
e.message ?: context.getString(R.string.unknown_error)
|
||||
),
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun backupAllowlist(context: Context, snackBarHost: SnackbarHostState, uri: Uri) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val allowlistPath = "/data/adb/ksu/.allowlist"
|
||||
|
||||
// 直接复制文件到用户选择的位置
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat $allowlistPath"))
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
process.inputStream.copyTo(output)
|
||||
}
|
||||
|
||||
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
|
||||
if (process.exitValue() != 0) {
|
||||
throw IOException(context.getString(R.string.command_execution_failed, error))
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.allowlist_backup_success),
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("AllowlistBackup", context.getString(R.string.allowlist_backup_failed, ""), e)
|
||||
withContext(Dispatchers.Main) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.allowlist_backup_failed, e.message),
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restoreAllowlist(
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState,
|
||||
uri: Uri,
|
||||
showConfirmDialog: (Boolean) -> Unit,
|
||||
confirmResult: CompletableDeferred<Boolean>
|
||||
) {
|
||||
// 显示确认对话框
|
||||
withContext(Dispatchers.Main) {
|
||||
showConfirmDialog(true)
|
||||
}
|
||||
|
||||
val userConfirmed = confirmResult.await()
|
||||
if (!userConfirmed) return
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val allowlistPath = "/data/adb/ksu/.allowlist"
|
||||
|
||||
// 直接从用户选择的文件读取并写入到目标位置
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat > $allowlistPath"))
|
||||
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
input.copyTo(process.outputStream)
|
||||
}
|
||||
process.outputStream.close()
|
||||
|
||||
process.waitFor()
|
||||
|
||||
val error = BufferedReader(InputStreamReader(process.errorStream)).readText()
|
||||
if (process.exitValue() != 0) {
|
||||
throw IOException(context.getString(R.string.command_execution_failed, error))
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.allowlist_restore_success),
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AllowlistRestore",
|
||||
context.getString(R.string.allowlist_restore_failed, ""),
|
||||
e
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.allowlist_restore_failed, e.message),
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberModuleBackupLauncher(
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState,
|
||||
scope: CoroutineScope = rememberCoroutineScope()
|
||||
) = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
scope.launch {
|
||||
backupModules(context, snackBarHost, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberModuleRestoreLauncher(
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState,
|
||||
scope: CoroutineScope = rememberCoroutineScope()
|
||||
): ActivityResultLauncher<Intent> {
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var restoreConfirmResult by remember { mutableStateOf<CompletableDeferred<Boolean>?>(null) }
|
||||
|
||||
// 显示恢复确认对话框
|
||||
RestoreConfirmationDialog(
|
||||
showDialog = showRestoreDialog,
|
||||
onConfirm = {
|
||||
showRestoreDialog = false
|
||||
restoreConfirmResult?.complete(true)
|
||||
},
|
||||
onDismiss = {
|
||||
showRestoreDialog = false
|
||||
restoreConfirmResult?.complete(false)
|
||||
}
|
||||
)
|
||||
|
||||
return rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
scope.launch {
|
||||
val confirmResult = CompletableDeferred<Boolean>()
|
||||
restoreConfirmResult = confirmResult
|
||||
|
||||
restoreModules(
|
||||
context = context,
|
||||
snackBarHost = snackBarHost,
|
||||
uri = uri,
|
||||
showConfirmDialog = { show -> showRestoreDialog = show },
|
||||
confirmResult = confirmResult
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberAllowlistBackupLauncher(
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState,
|
||||
scope: CoroutineScope = rememberCoroutineScope()
|
||||
) = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
scope.launch {
|
||||
backupAllowlist(context, snackBarHost, uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberAllowlistRestoreLauncher(
|
||||
context: Context,
|
||||
snackBarHost: SnackbarHostState,
|
||||
scope: CoroutineScope = rememberCoroutineScope()
|
||||
): ActivityResultLauncher<Intent> {
|
||||
var showAllowlistRestoreDialog by remember { mutableStateOf(false) }
|
||||
var allowlistRestoreConfirmResult by remember {
|
||||
mutableStateOf<CompletableDeferred<Boolean>?>(
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// 显示允许列表恢复确认对话框
|
||||
AllowlistRestoreConfirmationDialog(
|
||||
showDialog = showAllowlistRestoreDialog,
|
||||
onConfirm = {
|
||||
showAllowlistRestoreDialog = false
|
||||
allowlistRestoreConfirmResult?.complete(true)
|
||||
},
|
||||
onDismiss = {
|
||||
showAllowlistRestoreDialog = false
|
||||
allowlistRestoreConfirmResult?.complete(false)
|
||||
}
|
||||
)
|
||||
|
||||
return rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
scope.launch {
|
||||
val confirmResult = CompletableDeferred<Boolean>()
|
||||
allowlistRestoreConfirmResult = confirmResult
|
||||
|
||||
restoreAllowlist(
|
||||
context = context,
|
||||
snackBarHost = snackBarHost,
|
||||
uri = uri,
|
||||
showConfirmDialog = { show -> showAllowlistRestoreDialog = show },
|
||||
confirmResult = confirmResult
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createBackupIntent(): Intent {
|
||||
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/zip"
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
putExtra(Intent.EXTRA_TITLE, "modules_backup_$timestamp.zip")
|
||||
}
|
||||
}
|
||||
|
||||
fun createRestoreIntent(): Intent {
|
||||
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/zip"
|
||||
}
|
||||
}
|
||||
|
||||
fun createAllowlistBackupIntent(): Intent {
|
||||
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/octet-stream"
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
putExtra(Intent.EXTRA_TITLE, "ksu_allowlist_backup_$timestamp.dat")
|
||||
}
|
||||
}
|
||||
|
||||
fun createAllowlistRestoreIntent(): Intent {
|
||||
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.sukisu.ultra.ui.util.module
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.sukisu.ultra.R
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
object ModuleUtils {
|
||||
private const val TAG = "ModuleUtils"
|
||||
|
||||
fun extractModuleName(context: Context, uri: Uri): String {
|
||||
if (uri == Uri.EMPTY) {
|
||||
Log.e(TAG, "The supplied URI is empty")
|
||||
return context.getString(R.string.unknown_module)
|
||||
}
|
||||
|
||||
return try {
|
||||
Log.d(TAG, "Start extracting module names from URIs: $uri")
|
||||
|
||||
// 从URI路径中提取文件名
|
||||
val fileName = uri.lastPathSegment?.let { path ->
|
||||
val lastSlash = path.lastIndexOf('/')
|
||||
if (lastSlash != -1 && lastSlash < path.length - 1) {
|
||||
path.substring(lastSlash + 1)
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}?.removeSuffix(".zip") ?: context.getString(R.string.unknown_module)
|
||||
|
||||
val formattedFileName = fileName.replace(Regex("[^a-zA-Z0-9\\s\\-_.@()\\u4e00-\\u9fa5]"), "").trim()
|
||||
var moduleName = formattedFileName
|
||||
|
||||
try {
|
||||
// 打开ZIP文件输入流
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
Log.e(TAG, "Unable to get input stream from URI: $uri")
|
||||
return formattedFileName
|
||||
}
|
||||
|
||||
val zipInputStream = ZipInputStream(inputStream)
|
||||
var entry = zipInputStream.nextEntry
|
||||
|
||||
// 遍历ZIP文件中的条目,查找module.prop文件
|
||||
while (entry != null) {
|
||||
if (entry.name == "module.prop") {
|
||||
val reader = BufferedReader(InputStreamReader(zipInputStream, StandardCharsets.UTF_8))
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
if (line?.startsWith("name=") == true) {
|
||||
moduleName = line.substringAfter("=")
|
||||
moduleName = moduleName.replace(Regex("[^a-zA-Z0-9\\s\\-_.@()\\u4e00-\\u9fa5]"), "").trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
entry = zipInputStream.nextEntry
|
||||
}
|
||||
zipInputStream.close()
|
||||
Log.d(TAG, "Successfully extracted module name: $moduleName")
|
||||
moduleName
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error reading ZIP file: ${e.message}")
|
||||
formattedFileName
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception when extracting module name: ${e.message}")
|
||||
context.getString(R.string.unknown_module)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证URI是否有效并可访问
|
||||
fun isUriAccessible(context: Context, uri: Uri): Boolean {
|
||||
if (uri == Uri.EMPTY) return false
|
||||
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
inputStream?.close()
|
||||
inputStream != null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "The URI is inaccessible: $uri, Error: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取URI的持久权限
|
||||
fun takePersistableUriPermission(context: Context, uri: Uri) {
|
||||
try {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
Log.d(TAG, "Persistent permissions for URIs have been obtained: $uri")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to get persistent permissions on URIs: $uri, Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun extractModuleId(context: Context, uri: Uri): String? {
|
||||
if (uri == Uri.EMPTY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
|
||||
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
|
||||
|
||||
val zipInputStream = ZipInputStream(inputStream)
|
||||
var entry = zipInputStream.nextEntry
|
||||
var moduleId: String? = null
|
||||
|
||||
// 遍历ZIP文件中的条目,查找module.prop文件
|
||||
while (entry != null) {
|
||||
if (entry.name == "module.prop") {
|
||||
val reader = BufferedReader(InputStreamReader(zipInputStream, StandardCharsets.UTF_8))
|
||||
var line: String?
|
||||
while (reader.readLine().also { line = it } != null) {
|
||||
if (line?.startsWith("id=") == true) {
|
||||
moduleId = line.substringAfter("=").trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
entry = zipInputStream.nextEntry
|
||||
}
|
||||
zipInputStream.close()
|
||||
moduleId
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "提取模块ID时发生异常: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package com.sukisu.ultra.ui.util.module
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ui.util.getRootShell
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/8/3
|
||||
*/
|
||||
|
||||
// 模块签名验证工具类
|
||||
object ModuleSignatureUtils {
|
||||
private const val TAG = "ModuleSignatureUtils"
|
||||
|
||||
fun verifyModuleSignature(context: Context, moduleUri: Uri): Boolean {
|
||||
return try {
|
||||
// 创建临时文件
|
||||
val tempFile = File(context.cacheDir, "temp_module_${System.currentTimeMillis()}.zip")
|
||||
|
||||
// 复制URI内容到临时文件
|
||||
context.contentResolver.openInputStream(moduleUri)?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用native方法验证签名
|
||||
val isVerified = Natives.verifyModuleSignature(tempFile.absolutePath)
|
||||
|
||||
// 清理临时文件
|
||||
tempFile.delete()
|
||||
|
||||
Log.d(TAG, "Module signature verification result: $isVerified")
|
||||
isVerified
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error verifying module signature", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 验证模块签名
|
||||
fun verifyModuleSignature(context: Context, moduleUri: Uri): Boolean {
|
||||
return ModuleSignatureUtils.verifyModuleSignature(context, moduleUri)
|
||||
}
|
||||
|
||||
object ModuleOperationUtils {
|
||||
private const val TAG = "ModuleOperationUtils"
|
||||
|
||||
fun handleModuleInstallSuccess(context: Context, moduleUri: Uri, isSignatureVerified: Boolean) {
|
||||
if (!isSignatureVerified) {
|
||||
Log.d(TAG, "模块签名未验证,跳过创建验证标志")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 从ZIP文件提取模块ID
|
||||
val moduleId = ModuleUtils.extractModuleId(context, moduleUri)
|
||||
if (moduleId == null) {
|
||||
Log.e(TAG, "无法提取模块ID,无法创建验证标志")
|
||||
return
|
||||
}
|
||||
|
||||
// 创建验证标志文件
|
||||
val success = ModuleVerificationManager.createVerificationFlag(moduleId)
|
||||
if (success) {
|
||||
Log.d(TAG, "模块 $moduleId 验证标志创建成功")
|
||||
} else {
|
||||
Log.e(TAG, "模块 $moduleId 验证标志创建失败")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "处理模块安装成功时发生异常", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleModuleUninstall(moduleId: String) {
|
||||
try {
|
||||
val success = ModuleVerificationManager.removeVerificationFlag(moduleId)
|
||||
if (success) {
|
||||
Log.d(TAG, "模块 $moduleId 验证标志移除成功")
|
||||
} else {
|
||||
Log.d(TAG, "模块 $moduleId 验证标志移除失败或不存在")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "处理模块卸载时发生异常: $moduleId", e)
|
||||
}
|
||||
}
|
||||
fun handleModuleUpdate(context: Context, moduleUri: Uri, isSignatureVerified: Boolean) {
|
||||
try {
|
||||
val moduleId = ModuleUtils.extractModuleId(context, moduleUri)
|
||||
if (moduleId == null) {
|
||||
Log.e(TAG, "无法提取模块ID,无法处理验证标志")
|
||||
return
|
||||
}
|
||||
|
||||
if (isSignatureVerified) {
|
||||
// 签名验证通过,创建或更新验证标志
|
||||
val success = ModuleVerificationManager.createVerificationFlag(moduleId)
|
||||
if (success) {
|
||||
Log.d(TAG, "模块 $moduleId 更新后验证标志已更新")
|
||||
} else {
|
||||
Log.e(TAG, "模块 $moduleId 更新后验证标志更新失败")
|
||||
}
|
||||
} else {
|
||||
// 签名验证失败,移除验证标志
|
||||
ModuleVerificationManager.removeVerificationFlag(moduleId)
|
||||
Log.d(TAG, "模块 $moduleId 更新后签名未验证,验证标志已移除")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "处理模块更新时发生异常", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ModuleVerificationManager {
|
||||
private const val TAG = "ModuleVerificationManager"
|
||||
private const val VERIFICATION_FLAGS_DIR = "/data/adb/ksu/verified_modules"
|
||||
|
||||
// 为指定模块创建验证标志文件
|
||||
fun createVerificationFlag(moduleId: String): Boolean {
|
||||
return try {
|
||||
val shell = getRootShell()
|
||||
val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId"
|
||||
|
||||
// 确保目录存在
|
||||
val createDirCommand = "mkdir -p '$VERIFICATION_FLAGS_DIR'"
|
||||
shell.newJob().add(createDirCommand).exec()
|
||||
|
||||
// 创建验证标志文件,写入验证时间戳
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val command = "echo '$timestamp' > '$flagFilePath'"
|
||||
|
||||
val result = shell.newJob().add(command).exec()
|
||||
|
||||
if (result.isSuccess) {
|
||||
Log.d(TAG, "验证标志文件创建成功: $flagFilePath")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "验证标志文件创建失败: $moduleId")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "创建验证标志文件时发生异常: $moduleId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun removeVerificationFlag(moduleId: String): Boolean {
|
||||
return try {
|
||||
val shell = getRootShell()
|
||||
val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId"
|
||||
|
||||
val command = "rm -f '$flagFilePath'"
|
||||
val result = shell.newJob().add(command).exec()
|
||||
|
||||
if (result.isSuccess) {
|
||||
Log.d(TAG, "验证标志文件移除成功: $flagFilePath")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "验证标志文件移除失败: $moduleId")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "移除验证标志文件时发生异常: $moduleId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getVerificationTimestamp(moduleId: String): Long {
|
||||
return try {
|
||||
val shell = getRootShell()
|
||||
val flagFilePath = "$VERIFICATION_FLAGS_DIR/$moduleId"
|
||||
|
||||
val command = "cat '$flagFilePath' 2>/dev/null || echo '0'"
|
||||
val result = shell.newJob().add(command).to(ArrayList(), null).exec()
|
||||
|
||||
if (result.isSuccess && result.out.isNotEmpty()) {
|
||||
val timestampStr = result.out.firstOrNull()?.trim() ?: "0"
|
||||
timestampStr.toLongOrNull() ?: 0L
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "获取验证时间戳时发生异常: $moduleId", e)
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
fun batchCheckVerificationStatus(moduleIds: List<String>): Map<String, Boolean> {
|
||||
if (moduleIds.isEmpty()) return emptyMap()
|
||||
|
||||
return try {
|
||||
val shell = getRootShell()
|
||||
val result = mutableMapOf<String, Boolean>()
|
||||
|
||||
// 确保目录存在
|
||||
val createDirCommand = "mkdir -p '$VERIFICATION_FLAGS_DIR'"
|
||||
shell.newJob().add(createDirCommand).exec()
|
||||
|
||||
// 批量检查所有模块的验证标志文件
|
||||
val commands = moduleIds.map { moduleId ->
|
||||
"test -f '$VERIFICATION_FLAGS_DIR/$moduleId' && echo '$moduleId:true' || echo '$moduleId:false'"
|
||||
}
|
||||
|
||||
val command = commands.joinToString(" && ")
|
||||
val shellResult = shell.newJob().add(command).to(ArrayList(), null).exec()
|
||||
|
||||
if (shellResult.isSuccess) {
|
||||
shellResult.out.forEach { line ->
|
||||
val parts = line.split(":")
|
||||
if (parts.size == 2) {
|
||||
val moduleId = parts[0]
|
||||
val isVerified = parts[1] == "true"
|
||||
result[moduleId] = isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "批量验证检查完成,共检查 ${moduleIds.size} 个模块")
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "批量检查验证状态时发生异常", e)
|
||||
// 返回默认值,所有模块都标记为未验证
|
||||
moduleIds.associateWith { false }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
package com.sukisu.ultra.ui.viewmodel
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.system.Os
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.sukisu.ultra.KernelVersion
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.getKernelVersion
|
||||
import com.sukisu.ultra.ksuApp
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import com.sukisu.ultra.ui.util.module.LatestVersionInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
// 系统状态
|
||||
data class SystemStatus(
|
||||
val isManager: Boolean = false,
|
||||
val ksuVersion: Int? = null,
|
||||
val ksuFullVersion : String? = null,
|
||||
val lkmMode: Boolean? = null,
|
||||
val kernelVersion: KernelVersion = getKernelVersion(),
|
||||
val isRootAvailable: Boolean = false,
|
||||
val isKpmConfigured: Boolean = false,
|
||||
val requireNewKernel: Boolean = false
|
||||
)
|
||||
|
||||
// 系统信息
|
||||
data class SystemInfo(
|
||||
val kernelRelease: String = "",
|
||||
val androidVersion: String = "",
|
||||
val deviceModel: String = "",
|
||||
val managerVersion: Pair<String, Long> = Pair("", 0L),
|
||||
val seLinuxStatus: String = "",
|
||||
val kpmVersion: String = "",
|
||||
val suSFSStatus: String = "",
|
||||
val suSFSVersion: String = "",
|
||||
val suSFSVariant: String = "",
|
||||
val suSFSFeatures: String = "",
|
||||
val superuserCount: Int = 0,
|
||||
val moduleCount: Int = 0,
|
||||
val kpmModuleCount: Int = 0,
|
||||
val managersList: Natives.ManagersList? = null,
|
||||
val isDynamicSignEnabled: Boolean = false,
|
||||
val zygiskImplement: String = ""
|
||||
)
|
||||
|
||||
// 状态变量
|
||||
var systemStatus by mutableStateOf(SystemStatus())
|
||||
private set
|
||||
|
||||
var systemInfo by mutableStateOf(SystemInfo())
|
||||
private set
|
||||
|
||||
var latestVersionInfo by mutableStateOf(LatestVersionInfo())
|
||||
private set
|
||||
|
||||
var isSimpleMode by mutableStateOf(false)
|
||||
private set
|
||||
var isKernelSimpleMode by mutableStateOf(false)
|
||||
private set
|
||||
var isHideVersion by mutableStateOf(false)
|
||||
private set
|
||||
var isHideOtherInfo by mutableStateOf(false)
|
||||
private set
|
||||
var isHideSusfsStatus by mutableStateOf(false)
|
||||
private set
|
||||
var isHideZygiskImplement by mutableStateOf(false)
|
||||
private set
|
||||
var isHideLinkCard by mutableStateOf(false)
|
||||
private set
|
||||
var showKpmInfo by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var isCoreDataLoaded by mutableStateOf(false)
|
||||
private set
|
||||
var isExtendedDataLoaded by mutableStateOf(false)
|
||||
private set
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
// 数据刷新状态流,用于监听变化
|
||||
private val _dataRefreshTrigger = MutableStateFlow(0L)
|
||||
val dataRefreshTrigger: StateFlow<Long> = _dataRefreshTrigger
|
||||
|
||||
private var loadingJobs = mutableListOf<Job>()
|
||||
private var lastRefreshTime = 0L
|
||||
private val refreshCooldown = 2000L
|
||||
|
||||
fun loadUserSettings(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val settingsPrefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
isSimpleMode = settingsPrefs.getBoolean("is_simple_mode", false)
|
||||
isKernelSimpleMode = settingsPrefs.getBoolean("is_kernel_simple_mode", false)
|
||||
isHideVersion = settingsPrefs.getBoolean("is_hide_version", false)
|
||||
isHideOtherInfo = settingsPrefs.getBoolean("is_hide_other_info", false)
|
||||
isHideSusfsStatus = settingsPrefs.getBoolean("is_hide_susfs_status", false)
|
||||
isHideLinkCard = settingsPrefs.getBoolean("is_hide_link_card", false)
|
||||
isHideZygiskImplement = settingsPrefs.getBoolean("is_hide_zygisk_Implement", false)
|
||||
showKpmInfo = settingsPrefs.getBoolean("show_kpm_info", false)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCoreData() {
|
||||
if (isCoreDataLoaded) return
|
||||
|
||||
val job = viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val kernelVersion = getKernelVersion()
|
||||
val isManager = try {
|
||||
Natives.isManager
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val ksuVersion = if (isManager) Natives.version else null
|
||||
|
||||
val fullVersion = try {
|
||||
Natives.getFullVersion()
|
||||
} catch (_: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
val ksuFullVersion = if (isKernelSimpleMode) {
|
||||
try {
|
||||
val startIndex = fullVersion.indexOf('v')
|
||||
if (startIndex >= 0) {
|
||||
val endIndex = fullVersion.indexOf('-', startIndex)
|
||||
val versionStr = if (endIndex > startIndex) {
|
||||
fullVersion.substring(startIndex, endIndex)
|
||||
} else {
|
||||
fullVersion.substring(startIndex)
|
||||
}
|
||||
val numericVersion = "v" + (Regex("""\d+(\.\d+)*""").find(versionStr)?.value ?: versionStr)
|
||||
numericVersion
|
||||
} else {
|
||||
fullVersion
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
fullVersion
|
||||
}
|
||||
} else {
|
||||
fullVersion
|
||||
}
|
||||
|
||||
val lkmMode = ksuVersion?.let {
|
||||
if (kernelVersion.isGKI()) Natives.isLkmMode else null
|
||||
}
|
||||
|
||||
val isRootAvailable = try {
|
||||
rootAvailable()
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val isKpmConfigured = try {
|
||||
Natives.isKPMEnabled()
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val requireNewKernel = try {
|
||||
isManager && Natives.requireNewKernel()
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
systemStatus = SystemStatus(
|
||||
isManager = isManager,
|
||||
ksuVersion = ksuVersion,
|
||||
ksuFullVersion = ksuFullVersion,
|
||||
lkmMode = lkmMode,
|
||||
kernelVersion = kernelVersion,
|
||||
isRootAvailable = isRootAvailable,
|
||||
isKpmConfigured = isKpmConfigured,
|
||||
requireNewKernel = requireNewKernel
|
||||
)
|
||||
|
||||
isCoreDataLoaded = true
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
loadingJobs.add(job)
|
||||
}
|
||||
|
||||
fun loadExtendedData(context: Context) {
|
||||
if (isExtendedDataLoaded) return
|
||||
|
||||
val job = viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// 分批加载
|
||||
delay(50)
|
||||
|
||||
val basicInfo = loadBasicSystemInfo(context)
|
||||
systemInfo = systemInfo.copy(
|
||||
kernelRelease = basicInfo.first,
|
||||
androidVersion = basicInfo.second,
|
||||
deviceModel = basicInfo.third,
|
||||
managerVersion = basicInfo.fourth,
|
||||
seLinuxStatus = basicInfo.fifth
|
||||
)
|
||||
|
||||
delay(100)
|
||||
|
||||
// 加载模块信息
|
||||
if (!isSimpleMode) {
|
||||
val moduleInfo = loadModuleInfo()
|
||||
systemInfo = systemInfo.copy(
|
||||
kpmVersion = moduleInfo.first,
|
||||
superuserCount = moduleInfo.second,
|
||||
moduleCount = moduleInfo.third,
|
||||
kpmModuleCount = moduleInfo.fourth,
|
||||
zygiskImplement = moduleInfo.fifth
|
||||
)
|
||||
}
|
||||
|
||||
delay(100)
|
||||
|
||||
// 加载SuSFS信息
|
||||
if (!isHideSusfsStatus) {
|
||||
val suSFSInfo = loadSuSFSInfo()
|
||||
systemInfo = systemInfo.copy(
|
||||
suSFSStatus = suSFSInfo.first,
|
||||
suSFSVersion = suSFSInfo.second,
|
||||
suSFSVariant = suSFSInfo.third,
|
||||
suSFSFeatures = suSFSInfo.fourth,
|
||||
)
|
||||
}
|
||||
|
||||
delay(100)
|
||||
|
||||
// 加载管理器列表
|
||||
val managerInfo = loadManagerInfo()
|
||||
systemInfo = systemInfo.copy(
|
||||
managersList = managerInfo.first,
|
||||
isDynamicSignEnabled = managerInfo.second
|
||||
)
|
||||
|
||||
isExtendedDataLoaded = true
|
||||
} catch (_: Exception) {
|
||||
// 静默处理错误
|
||||
}
|
||||
}
|
||||
loadingJobs.add(job)
|
||||
}
|
||||
|
||||
fun refreshData(context: Context, forceRefresh: Boolean = false) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 如果不是强制刷新,检查冷却时间
|
||||
if (!forceRefresh && currentTime - lastRefreshTime < refreshCooldown) {
|
||||
return
|
||||
}
|
||||
|
||||
lastRefreshTime = currentTime
|
||||
|
||||
viewModelScope.launch {
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
// 取消正在进行的加载任务
|
||||
loadingJobs.forEach { it.cancel() }
|
||||
loadingJobs.clear()
|
||||
|
||||
// 重置状态
|
||||
isCoreDataLoaded = false
|
||||
isExtendedDataLoaded = false
|
||||
|
||||
// 触发数据刷新状态流
|
||||
_dataRefreshTrigger.value = currentTime
|
||||
|
||||
// 重新加载用户设置
|
||||
loadUserSettings(context)
|
||||
|
||||
// 重新加载核心数据
|
||||
loadCoreData()
|
||||
delay(100)
|
||||
|
||||
// 重新加载扩展数据
|
||||
loadExtendedData(context)
|
||||
|
||||
// 检查更新
|
||||
val settingsPrefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val checkUpdate = settingsPrefs.getBoolean("check_update", true)
|
||||
if (checkUpdate) {
|
||||
try {
|
||||
val newVersionInfo = withContext(Dispatchers.IO) {
|
||||
checkNewVersion()
|
||||
}
|
||||
latestVersionInfo = newVersionInfo
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 静默处理错误
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手动触发刷新(下拉刷新使用)
|
||||
fun onPullRefresh(context: Context) {
|
||||
refreshData(context, forceRefresh = true)
|
||||
}
|
||||
|
||||
// 自动刷新数据(当检测到变化时)
|
||||
fun autoRefreshIfNeeded(context: Context) {
|
||||
viewModelScope.launch {
|
||||
// 检查是否需要刷新数据
|
||||
val needsRefresh = checkIfDataNeedsRefresh()
|
||||
if (needsRefresh) {
|
||||
refreshData(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkIfDataNeedsRefresh(): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 检查KSU状态是否发生变化
|
||||
val currentKsuVersion = try {
|
||||
if (Natives.isManager) {
|
||||
Natives.version
|
||||
} else null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
// 如果KSU版本发生变化,需要刷新
|
||||
if (currentKsuVersion != systemStatus.ksuVersion) {
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
// 检查模块数量是否发生变化
|
||||
val currentModuleCount = try {
|
||||
getModuleCount()
|
||||
} catch (_: Exception) {
|
||||
systemInfo.moduleCount
|
||||
}
|
||||
|
||||
if (currentModuleCount != systemInfo.moduleCount) {
|
||||
return@withContext true
|
||||
}
|
||||
|
||||
false
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBasicSystemInfo(context: Context): Tuple5<String, String, String, Pair<String, Long>, String> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val uname = try {
|
||||
Os.uname()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val deviceModel = try {
|
||||
getDeviceModel()
|
||||
} catch (_: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
val managerVersion = try {
|
||||
getManagerVersion(context)
|
||||
} catch (_: Exception) {
|
||||
Pair("Unknown", 0L)
|
||||
}
|
||||
|
||||
val seLinuxStatus = try {
|
||||
getSELinuxStatus(ksuApp.applicationContext)
|
||||
} catch (_: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
Tuple5(
|
||||
uname?.release ?: "Unknown",
|
||||
Build.VERSION.RELEASE ?: "Unknown",
|
||||
deviceModel,
|
||||
managerVersion,
|
||||
seLinuxStatus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadModuleInfo(): Tuple5<String, Int, Int, Int, String> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val kpmVersion = try {
|
||||
getKpmVersion()
|
||||
} catch (_: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
val superuserCount = try {
|
||||
getSuperuserCount()
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
|
||||
val moduleCount = try {
|
||||
getModuleCount()
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
|
||||
val kpmModuleCount = try {
|
||||
getKpmModuleCount()
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}
|
||||
|
||||
val zygiskImplement = try {
|
||||
getZygiskImplement()
|
||||
} catch (_: Exception) {
|
||||
"None"
|
||||
}
|
||||
|
||||
Tuple5(kpmVersion, superuserCount, moduleCount, kpmModuleCount, zygiskImplement)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSuSFSInfo(): Tuple4<String, String, String, String> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val suSFS = try {
|
||||
val rawFeature = getSuSFSFeatures()
|
||||
if (rawFeature.isNotEmpty() && !rawFeature.startsWith("[-]")) {
|
||||
"Supported"
|
||||
} else {
|
||||
rawFeature
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"Unknown"
|
||||
}
|
||||
|
||||
if (suSFS != "Supported") {
|
||||
return@withContext Tuple4(suSFS, "", "", "")
|
||||
}
|
||||
|
||||
val suSFSVersion = try {
|
||||
getSuSFSVersion()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
if (suSFSVersion.isEmpty()) {
|
||||
return@withContext Tuple4(suSFS, "", "", "")
|
||||
}
|
||||
|
||||
val suSFSVariant = try {
|
||||
getSuSFSVariant()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
val suSFSFeatures = try {
|
||||
getSuSFSFeatures()
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
Tuple4(suSFS, suSFSVersion, suSFSVariant, suSFSFeatures)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadManagerInfo(): Pair<Natives.ManagersList?, Boolean> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val dynamicSignConfig = try {
|
||||
Natives.getDynamicManager()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val isDynamicSignEnabled = try {
|
||||
dynamicSignConfig?.isValid() == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val managersList = if (isDynamicSignEnabled) {
|
||||
try {
|
||||
Natives.getManagersList()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Pair(managersList, isDynamicSignEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateApi")
|
||||
private fun getDeviceModel(): String {
|
||||
return try {
|
||||
val systemProperties = Class.forName("android.os.SystemProperties")
|
||||
val getMethod = systemProperties.getMethod("get", String::class.java, String::class.java)
|
||||
val marketNameKeys = listOf(
|
||||
"ro.product.marketname",
|
||||
"ro.vendor.oplus.market.name",
|
||||
"ro.vivo.market.name",
|
||||
"ro.config.marketing_name"
|
||||
)
|
||||
var result = getDeviceInfo()
|
||||
for (key in marketNameKeys) {
|
||||
try {
|
||||
val marketName = getMethod.invoke(null, key, "") as String
|
||||
if (marketName.isNotEmpty()) {
|
||||
result = marketName
|
||||
break
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (
|
||||
|
||||
_: Exception) {
|
||||
getDeviceInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeviceInfo(): String {
|
||||
return try {
|
||||
var manufacturer = Build.MANUFACTURER ?: "Unknown"
|
||||
manufacturer = manufacturer[0].uppercaseChar().toString() + manufacturer.substring(1)
|
||||
|
||||
val brand = Build.BRAND ?: ""
|
||||
if (brand.isNotEmpty() && !brand.equals(Build.MANUFACTURER, ignoreCase = true)) {
|
||||
manufacturer += " " + brand[0].uppercaseChar() + brand.substring(1)
|
||||
}
|
||||
|
||||
val model = Build.MODEL ?: ""
|
||||
if (model.isNotEmpty()) {
|
||||
manufacturer += " $model "
|
||||
}
|
||||
|
||||
manufacturer
|
||||
} catch (_: Exception) {
|
||||
"Unknown Device"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getManagerVersion(context: Context): Pair<String, Long> {
|
||||
return try {
|
||||
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
val versionCode = androidx.core.content.pm.PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||
val versionName = packageInfo.versionName ?: "Unknown"
|
||||
Pair(versionName, versionCode)
|
||||
} catch (_: Exception) {
|
||||
Pair("Unknown", 0L)
|
||||
}
|
||||
}
|
||||
|
||||
data class Tuple5<T1, T2, T3, T4, T5>(
|
||||
val first: T1,
|
||||
val second: T2,
|
||||
val third: T3,
|
||||
val fourth: T4,
|
||||
val fifth: T5
|
||||
)
|
||||
|
||||
data class Tuple4<T1, T2, T3, T4>(
|
||||
val first: T1,
|
||||
val second: T2,
|
||||
val third: T3,
|
||||
val fourth: T4
|
||||
)
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
loadingJobs.forEach { it.cancel() }
|
||||
loadingJobs.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -1,77 +1,43 @@
|
||||
package com.sukisu.ultra.ui.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
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.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.sukisu.ultra.ksuApp
|
||||
import com.sukisu.ultra.ui.component.SearchStatus
|
||||
import com.sukisu.ultra.ui.util.HanziToPinyin
|
||||
import com.sukisu.ultra.ui.util.listModules
|
||||
import com.sukisu.ultra.ui.util.getRootShell
|
||||
import com.sukisu.ultra.ui.util.module.ModuleVerificationManager
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.sukisu.ultra.ui.util.overlayFsAvailable
|
||||
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, "模块大小缓存刷新完成")
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class ModuleInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@@ -85,27 +51,63 @@ class ModuleViewModel : ViewModel() {
|
||||
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, // 添加验证时间戳
|
||||
val metamodule: Boolean,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class ModuleUpdateInfo(
|
||||
val downloadUrl: String,
|
||||
val version: String,
|
||||
val changelog: String
|
||||
) {
|
||||
companion object {
|
||||
val Empty = ModuleUpdateInfo("", "", "")
|
||||
}
|
||||
}
|
||||
|
||||
private data class ModuleUpdateSignature(
|
||||
val updateJson: String,
|
||||
val versionCode: Int,
|
||||
val enabled: Boolean,
|
||||
val update: Boolean,
|
||||
val remove: Boolean
|
||||
)
|
||||
|
||||
private data class ModuleUpdateCache(
|
||||
val signature: ModuleUpdateSignature,
|
||||
val info: ModuleUpdateInfo
|
||||
)
|
||||
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private set
|
||||
var search by mutableStateOf("")
|
||||
|
||||
var isOverlayAvailable by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var sortEnabledFirst by mutableStateOf(false)
|
||||
var sortActionFirst by mutableStateOf(false)
|
||||
var checkModuleUpdate by mutableStateOf(true)
|
||||
|
||||
private val updateInfoMutex = Mutex()
|
||||
private var updateInfoCache: MutableMap<String, ModuleUpdateCache> = mutableMapOf()
|
||||
private val updateInfoInFlight = mutableSetOf<String>()
|
||||
private val _updateInfo = mutableStateMapOf<String, ModuleUpdateInfo>()
|
||||
val updateInfo: SnapshotStateMap<String, ModuleUpdateInfo> = _updateInfo
|
||||
|
||||
private val _searchStatus = mutableStateOf(SearchStatus(""))
|
||||
val searchStatus: State<SearchStatus> = _searchStatus
|
||||
|
||||
private val _searchResults = mutableStateOf<List<ModuleInfo>>(emptyList())
|
||||
val searchResults: State<List<ModuleInfo>> = _searchResults
|
||||
|
||||
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)
|
||||
val comparator = moduleComparator()
|
||||
modules.filter {
|
||||
it.id.contains(search, true) || it.name.contains(search, true) || HanziToPinyin.getInstance()
|
||||
.toPinyinString(it.name)?.contains(search, true) == true
|
||||
it.id.contains(searchStatus.value.searchText, true) || it.name.contains(
|
||||
searchStatus.value.searchText,
|
||||
true
|
||||
) || HanziToPinyin.getInstance()
|
||||
.toPinyinString(it.name).contains(searchStatus.value.searchText, true)
|
||||
}.sortedWith(comparator).also {
|
||||
isRefreshing = false
|
||||
}
|
||||
@@ -116,113 +118,115 @@ class ModuleViewModel : ViewModel() {
|
||||
|
||||
fun markNeedRefresh() {
|
||||
isNeedRefresh = true
|
||||
// 标记需要刷新时,同时刷新大小缓存
|
||||
refreshModuleSizeCache()
|
||||
}
|
||||
|
||||
suspend fun updateSearchText(text: String) {
|
||||
_searchStatus.value.searchText = text
|
||||
|
||||
if (text.isEmpty()) {
|
||||
_searchStatus.value.resultStatus = SearchStatus.ResultStatus.DEFAULT
|
||||
_searchResults.value = emptyList()
|
||||
return
|
||||
}
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
_searchStatus.value.resultStatus = SearchStatus.ResultStatus.LOAD
|
||||
modules.filter {
|
||||
it.id.contains(text, true) || it.name.contains(text, true) ||
|
||||
it.description.contains(text, true) || it.author.contains(text, true) ||
|
||||
HanziToPinyin.getInstance().toPinyinString(it.name).contains(text, true)
|
||||
}.let { filteredModules ->
|
||||
val comparator = moduleComparator()
|
||||
filteredModules.sortedWith(comparator)
|
||||
}
|
||||
}
|
||||
|
||||
_searchResults.value = result
|
||||
_searchStatus.value.resultStatus = if (result.isEmpty()) {
|
||||
SearchStatus.ResultStatus.EMPTY
|
||||
} else {
|
||||
SearchStatus.ResultStatus.SHOW
|
||||
}
|
||||
}
|
||||
|
||||
private fun moduleComparator(): Comparator<ModuleInfo> {
|
||||
return compareBy<ModuleInfo>(
|
||||
{
|
||||
val executable = it.hasWebUi || it.hasActionScript
|
||||
when {
|
||||
it.metamodule && it.enabled -> 0
|
||||
sortEnabledFirst && sortActionFirst -> when {
|
||||
it.enabled && executable -> 1
|
||||
it.enabled -> 2
|
||||
executable -> 3
|
||||
else -> 4
|
||||
}
|
||||
sortEnabledFirst && !sortActionFirst -> if (it.enabled) 1 else 2
|
||||
!sortEnabledFirst && sortActionFirst -> if (executable) 1 else 2
|
||||
else -> 1
|
||||
}
|
||||
},
|
||||
{ if (sortEnabledFirst) !it.enabled else 0 },
|
||||
{ if (sortActionFirst) !(it.hasWebUi || it.hasActionScript) else 0 },
|
||||
).thenBy(Collator.getInstance(Locale.getDefault()), ModuleInfo::id)
|
||||
}
|
||||
|
||||
fun fetchModuleList() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
isRefreshing = true
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.Main) { 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
|
||||
val overlayAvailable = withContext(Dispatchers.IO) {
|
||||
kotlin.runCatching { overlayFsAvailable() }.getOrDefault(false)
|
||||
}
|
||||
|
||||
// when both old and new is kotlin.collections.EmptyList
|
||||
// moduleList update will don't trigger
|
||||
if (oldModuleList === modules) {
|
||||
val parsedModules = withContext(Dispatchers.IO) {
|
||||
kotlin.runCatching {
|
||||
val result = listModules()
|
||||
Log.i(TAG, "result: $result")
|
||||
val array = JSONArray(result)
|
||||
(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.optBoolean("update"),
|
||||
obj.getBoolean("remove"),
|
||||
obj.optString("updateJson"),
|
||||
obj.optBoolean("web"),
|
||||
obj.optBoolean("action"),
|
||||
(obj.optInt("metamodule") != 0) or obj.optBoolean("metamodule")
|
||||
)
|
||||
}.toList()
|
||||
}.getOrElse {
|
||||
Log.e(TAG, "fetchModuleList: ", it)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isOverlayAvailable = overlayAvailable
|
||||
modules = parsedModules
|
||||
isNeedRefresh = false
|
||||
if (oldModuleList === modules) {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedModules.isNotEmpty()) {
|
||||
syncModuleUpdateInfo(parsedModules)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
@@ -234,50 +238,98 @@ class ModuleViewModel : ViewModel() {
|
||||
return version.replace(Regex("[^a-zA-Z0-9.\\-_]"), "_")
|
||||
}
|
||||
|
||||
fun checkUpdate(m: ModuleInfo): Triple<String, String, String> {
|
||||
val empty = Triple("", "", "")
|
||||
private fun ModuleInfo.toSignature(): ModuleUpdateSignature {
|
||||
return ModuleUpdateSignature(
|
||||
updateJson = updateJson,
|
||||
versionCode = versionCode,
|
||||
enabled = enabled,
|
||||
update = update,
|
||||
remove = remove
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun syncModuleUpdateInfo(modules: List<ModuleInfo>) {
|
||||
if (!checkModuleUpdate) return
|
||||
|
||||
val modulesToFetch = mutableListOf<Triple<String, ModuleInfo, ModuleUpdateSignature>>()
|
||||
val removedIds = mutableSetOf<String>()
|
||||
|
||||
updateInfoMutex.withLock {
|
||||
val ids = modules.map { it.id }.toSet()
|
||||
updateInfoCache.keys.filter { it !in ids }.forEach { removedId ->
|
||||
removedIds += removedId
|
||||
updateInfoCache.remove(removedId)
|
||||
updateInfoInFlight.remove(removedId)
|
||||
}
|
||||
|
||||
modules.forEach { module ->
|
||||
val signature = module.toSignature()
|
||||
val cached = updateInfoCache[module.id]
|
||||
if ((cached == null || cached.signature != signature) && updateInfoInFlight.add(module.id)) {
|
||||
modulesToFetch += Triple(module.id, module, signature)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fetchedEntries = coroutineScope {
|
||||
modulesToFetch.map { (id, module, signature) ->
|
||||
async(Dispatchers.IO) {
|
||||
id to ModuleUpdateCache(signature, checkUpdate(module))
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
val changedEntries = mutableListOf<Pair<String, ModuleUpdateInfo>>()
|
||||
updateInfoMutex.withLock {
|
||||
fetchedEntries.forEach { (id, entry) ->
|
||||
val existing = updateInfoCache[id]
|
||||
if (existing == null || existing.signature != entry.signature || existing.info != entry.info) {
|
||||
updateInfoCache[id] = entry
|
||||
changedEntries += id to entry.info
|
||||
}
|
||||
updateInfoInFlight.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (removedIds.isEmpty() && changedEntries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
removedIds.forEach { _updateInfo.remove(it) }
|
||||
changedEntries.forEach { (id, info) ->
|
||||
_updateInfo[id] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdate(m: ModuleInfo): ModuleUpdateInfo {
|
||||
if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) {
|
||||
return empty
|
||||
return ModuleUpdateInfo.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()
|
||||
|
||||
val response = ksuApp.okhttpClient.newCall(
|
||||
okhttp3.Request.Builder().url(url).build()
|
||||
).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)
|
||||
""
|
||||
}
|
||||
|
||||
}.getOrDefault("")
|
||||
Log.i(TAG, "checkUpdate result: $result")
|
||||
|
||||
if (result.isEmpty()) {
|
||||
return empty
|
||||
return ModuleUpdateInfo.Empty
|
||||
}
|
||||
|
||||
val updateJson = kotlin.runCatching {
|
||||
JSONObject(result)
|
||||
}.getOrNull() ?: return empty
|
||||
}.getOrNull() ?: return ModuleUpdateInfo.Empty
|
||||
|
||||
var version = updateJson.optString("version", "")
|
||||
version = sanitizeVersionString(version)
|
||||
@@ -285,200 +337,9 @@ class ModuleViewModel : ViewModel() {
|
||||
val zipUrl = updateJson.optString("zipUrl", "")
|
||||
val changelog = updateJson.optString("changelog", "")
|
||||
if (versionCode <= m.versionCode || zipUrl.isEmpty()) {
|
||||
return empty
|
||||
return ModuleUpdateInfo.Empty
|
||||
}
|
||||
|
||||
return Triple(zipUrl, version, changelog)
|
||||
return ModuleUpdateInfo(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]
|
||||
}
|
||||
@@ -1,401 +1,230 @@
|
||||
package com.sukisu.ultra.ui.viewmodel
|
||||
|
||||
import android.content.*
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.DeadObjectException
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
import android.os.RemoteException
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.core.content.edit
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.sukisu.zako.IKsuInterface
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ksuApp
|
||||
import com.sukisu.ultra.ui.KsuService
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import com.sukisu.ultra.ui.component.SearchStatus
|
||||
import com.sukisu.ultra.ui.util.HanziToPinyin
|
||||
import com.sukisu.ultra.ui.util.KsuCli
|
||||
import java.text.Collator
|
||||
import java.util.*
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import com.sukisu.zako.IKsuInterface
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
enum class AppCategory(val displayNameRes: Int, val persistKey: String) {
|
||||
ALL(com.sukisu.ultra.R.string.category_all_apps, "ALL"),
|
||||
ROOT(com.sukisu.ultra.R.string.category_root_apps, "ROOT"),
|
||||
CUSTOM(com.sukisu.ultra.R.string.category_custom_apps, "CUSTOM"),
|
||||
DEFAULT(com.sukisu.ultra.R.string.category_default_apps, "DEFAULT");
|
||||
|
||||
companion object {
|
||||
fun fromPersistKey(key: String): AppCategory = entries.find { it.persistKey == key } ?: ALL
|
||||
}
|
||||
}
|
||||
|
||||
enum class SortType(val displayNameRes: Int, val persistKey: String) {
|
||||
NAME_ASC(com.sukisu.ultra.R.string.sort_name_asc, "NAME_ASC"),
|
||||
NAME_DESC(com.sukisu.ultra.R.string.sort_name_desc, "NAME_DESC"),
|
||||
INSTALL_TIME_NEW(com.sukisu.ultra.R.string.sort_install_time_new, "INSTALL_TIME_NEW"),
|
||||
INSTALL_TIME_OLD(com.sukisu.ultra.R.string.sort_install_time_old, "INSTALL_TIME_OLD"),
|
||||
SIZE_DESC(com.sukisu.ultra.R.string.sort_size_desc, "SIZE_DESC"),
|
||||
SIZE_ASC(com.sukisu.ultra.R.string.sort_size_asc, "SIZE_ASC"),
|
||||
USAGE_FREQ(com.sukisu.ultra.R.string.sort_usage_freq, "USAGE_FREQ");
|
||||
|
||||
companion object {
|
||||
fun fromPersistKey(key: String): SortType = entries.find { it.persistKey == key } ?: NAME_ASC
|
||||
}
|
||||
}
|
||||
|
||||
class SuperUserViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SuperUserViewModel"
|
||||
private val appsLock = Any()
|
||||
var apps by mutableStateOf<List<AppInfo>>(emptyList())
|
||||
private val _isAppListLoaded = MutableStateFlow(false)
|
||||
val isAppListLoaded = _isAppListLoaded.asStateFlow()
|
||||
|
||||
@JvmStatic
|
||||
fun getAppIconDrawable(context: Context, packageName: String): Drawable? {
|
||||
val appList = synchronized(appsLock) { apps }
|
||||
return appList.find { it.packageName == packageName }
|
||||
?.packageInfo?.applicationInfo?.loadIcon(context.packageManager)
|
||||
val appDetail = appList.find { it.packageName == packageName }
|
||||
return appDetail?.packageInfo?.applicationInfo?.loadIcon(context.packageManager)
|
||||
}
|
||||
|
||||
var appGroups by mutableStateOf<List<AppGroup>>(emptyList())
|
||||
|
||||
private const val PREFS_NAME = "settings"
|
||||
private const val KEY_SHOW_SYSTEM_APPS = "show_system_apps"
|
||||
private const val KEY_SELECTED_CATEGORY = "selected_category"
|
||||
private const val KEY_CURRENT_SORT_TYPE = "current_sort_type"
|
||||
private const val CORE_POOL_SIZE = 8
|
||||
private const val MAX_POOL_SIZE = 16
|
||||
private const val KEEP_ALIVE_TIME = 60L
|
||||
private const val BATCH_SIZE = 20
|
||||
}
|
||||
|
||||
@Immutable
|
||||
|
||||
private var _appList = mutableStateOf<List<AppInfo>>(emptyList())
|
||||
val appList: State<List<AppInfo>> = _appList
|
||||
private val _searchStatus = mutableStateOf(SearchStatus(""))
|
||||
val searchStatus: State<SearchStatus> = _searchStatus
|
||||
|
||||
@Parcelize
|
||||
data class AppInfo(
|
||||
val label: String,
|
||||
val packageInfo: PackageInfo,
|
||||
val profile: Natives.Profile?,
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val packageName: String = packageInfo.packageName
|
||||
@IgnoredOnParcel
|
||||
val uid: Int = packageInfo.applicationInfo!!.uid
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@Parcelize
|
||||
data class AppGroup(
|
||||
val uid: Int,
|
||||
val apps: List<AppInfo>,
|
||||
val profile: Natives.Profile?
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val mainApp: AppInfo = apps.first()
|
||||
@IgnoredOnParcel
|
||||
val packageNames: List<String> = apps.map { it.packageName }
|
||||
@IgnoredOnParcel
|
||||
val allowSu: Boolean = profile?.allowSu == true
|
||||
@IgnoredOnParcel
|
||||
val userName: String? = Natives.getUserName(uid)
|
||||
@IgnoredOnParcel
|
||||
val hasCustomProfile : Boolean = profile?.let { if (it.allowSu) !it.rootUseDefault else !it.nonRootUseDefault } ?: false
|
||||
}
|
||||
|
||||
private val appProcessingThreadPool = ThreadPoolExecutor(
|
||||
CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS,
|
||||
LinkedBlockingQueue()
|
||||
) { runnable ->
|
||||
Thread(runnable, "AppProcessing-${System.currentTimeMillis()}").apply {
|
||||
isDaemon = true
|
||||
priority = Thread.NORM_PRIORITY
|
||||
}
|
||||
}.asCoroutineDispatcher()
|
||||
|
||||
private val appListMutex = Mutex()
|
||||
private val configChangeListeners = mutableSetOf<(String) -> Unit>()
|
||||
private val prefs = ksuApp.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var search by mutableStateOf("")
|
||||
var showSystemApps by mutableStateOf(prefs.getBoolean(KEY_SHOW_SYSTEM_APPS, false))
|
||||
private set
|
||||
var selectedCategory by mutableStateOf(loadSelectedCategory())
|
||||
private set
|
||||
var currentSortType by mutableStateOf(loadCurrentSortType())
|
||||
private set
|
||||
var showSystemApps by mutableStateOf(false)
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
private set
|
||||
var showBatchActions by mutableStateOf(false)
|
||||
internal set
|
||||
var selectedApps by mutableStateOf<Set<String>>(emptySet())
|
||||
internal set
|
||||
var loadingProgress by mutableFloatStateOf(0f)
|
||||
private set
|
||||
|
||||
private fun loadSelectedCategory(): AppCategory {
|
||||
val categoryKey = prefs.getString(KEY_SELECTED_CATEGORY, AppCategory.ALL.persistKey)
|
||||
?: AppCategory.ALL.persistKey
|
||||
return AppCategory.fromPersistKey(categoryKey)
|
||||
}
|
||||
private val _searchResults = mutableStateOf<List<AppInfo>>(emptyList())
|
||||
val searchResults: State<List<AppInfo>> = _searchResults
|
||||
|
||||
private fun loadCurrentSortType(): SortType {
|
||||
val sortKey = prefs.getString(KEY_CURRENT_SORT_TYPE, SortType.NAME_ASC.persistKey)
|
||||
?: SortType.NAME_ASC.persistKey
|
||||
return SortType.fromPersistKey(sortKey)
|
||||
}
|
||||
suspend fun updateSearchText(text: String) {
|
||||
_searchStatus.value.searchText = text
|
||||
|
||||
fun updateShowSystemApps(newValue: Boolean) {
|
||||
showSystemApps = newValue
|
||||
prefs.edit { putBoolean(KEY_SHOW_SYSTEM_APPS, newValue) }
|
||||
notifyAppListChanged()
|
||||
}
|
||||
if (text.isEmpty()) {
|
||||
_searchStatus.value.resultStatus = SearchStatus.ResultStatus.DEFAULT
|
||||
_searchResults.value = emptyList()
|
||||
return
|
||||
}
|
||||
|
||||
private fun notifyAppListChanged() {
|
||||
val currentApps = apps
|
||||
apps = emptyList()
|
||||
apps = currentApps
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
_searchStatus.value.resultStatus = SearchStatus.ResultStatus.LOAD
|
||||
_appList.value.filter {
|
||||
it.label.contains(_searchStatus.value.searchText, true) || it.packageName.contains(
|
||||
_searchStatus.value.searchText,
|
||||
true
|
||||
) || HanziToPinyin.getInstance().toPinyinString(it.label)
|
||||
.contains(_searchStatus.value.searchText, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSelectedCategory(newCategory: AppCategory) {
|
||||
selectedCategory = newCategory
|
||||
prefs.edit { putString(KEY_SELECTED_CATEGORY, newCategory.persistKey) }
|
||||
}
|
||||
|
||||
fun updateCurrentSortType(newSortType: SortType) {
|
||||
currentSortType = newSortType
|
||||
prefs.edit { putString(KEY_CURRENT_SORT_TYPE, newSortType.persistKey) }
|
||||
}
|
||||
|
||||
fun toggleBatchMode() {
|
||||
showBatchActions = !showBatchActions
|
||||
if (!showBatchActions) clearSelection()
|
||||
}
|
||||
|
||||
fun toggleAppSelection(packageName: String) {
|
||||
selectedApps = if (selectedApps.contains(packageName)) {
|
||||
selectedApps - packageName
|
||||
if (_searchResults.value == result) {
|
||||
fetchAppList()
|
||||
updateSearchText(text)
|
||||
} else {
|
||||
selectedApps + packageName
|
||||
_searchResults.value = result
|
||||
|
||||
}
|
||||
_searchStatus.value.resultStatus = if (result.isEmpty()) {
|
||||
SearchStatus.ResultStatus.EMPTY
|
||||
} else {
|
||||
SearchStatus.ResultStatus.SHOW
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
selectedApps = emptySet()
|
||||
}
|
||||
private suspend inline fun connectKsuService(
|
||||
crossinline onDisconnect: () -> Unit = {}
|
||||
): Pair<IBinder, ServiceConnection> = suspendCoroutine {
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
onDisconnect()
|
||||
}
|
||||
|
||||
suspend fun updateBatchPermissions(allowSu: Boolean, umountModules: Boolean? = null) {
|
||||
selectedApps.forEach { packageName ->
|
||||
apps.find { it.packageName == packageName }?.let { app ->
|
||||
val profile = Natives.getAppProfile(packageName, app.uid)
|
||||
val updatedProfile = profile.copy(
|
||||
allowSu = allowSu,
|
||||
umountModules = umountModules ?: profile.umountModules,
|
||||
nonRootUseDefault = false
|
||||
)
|
||||
if (Natives.setAppProfile(updatedProfile)) {
|
||||
updateAppProfileLocally(packageName, updatedProfile)
|
||||
notifyConfigChange(packageName)
|
||||
}
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
it.resume(binder as IBinder to this)
|
||||
}
|
||||
}
|
||||
clearSelection()
|
||||
showBatchActions = false
|
||||
refreshAppConfigurations()
|
||||
|
||||
val intent = Intent(ksuApp, KsuService::class.java)
|
||||
|
||||
val task = RootService.bindOrTask(
|
||||
intent,
|
||||
Shell.EXECUTOR,
|
||||
connection,
|
||||
)
|
||||
val shell = KsuCli.SHELL
|
||||
task?.let { it1 -> shell.execTask(it1) }
|
||||
}
|
||||
|
||||
fun updateAppProfileLocally(packageName: String, updatedProfile: Natives.Profile) {
|
||||
appListMutex.tryLock().let { locked ->
|
||||
if (locked) {
|
||||
try {
|
||||
apps = apps.map { app ->
|
||||
if (app.packageName == packageName) {
|
||||
app.copy(profile = updatedProfile)
|
||||
} else app
|
||||
}
|
||||
} finally {
|
||||
appListMutex.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyConfigChange(packageName: String) {
|
||||
configChangeListeners.forEach { listener ->
|
||||
try {
|
||||
listener(packageName)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error notifying config change for $packageName", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshAppConfigurations() {
|
||||
withContext(appProcessingThreadPool) {
|
||||
supervisorScope {
|
||||
val currentApps = apps.toList()
|
||||
val batches = currentApps.chunked(BATCH_SIZE)
|
||||
loadingProgress = 0f
|
||||
|
||||
val updatedApps = batches.mapIndexed { batchIndex, batch ->
|
||||
async {
|
||||
val batchResult = batch.map { app ->
|
||||
try {
|
||||
val updatedProfile = Natives.getAppProfile(app.packageName, app.uid)
|
||||
app.copy(profile = updatedProfile)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error refreshing profile for ${app.packageName}", e)
|
||||
app
|
||||
}
|
||||
}
|
||||
loadingProgress = (batchIndex + 1).toFloat() / batches.size
|
||||
batchResult
|
||||
}
|
||||
}.awaitAll().flatten()
|
||||
|
||||
appListMutex.withLock { apps = updatedApps }
|
||||
loadingProgress = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var serviceConnection: ServiceConnection? = null
|
||||
|
||||
private suspend fun connectKsuService(onDisconnect: () -> Unit = {}): IBinder? =
|
||||
suspendCoroutine { continuation ->
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
onDisconnect()
|
||||
serviceConnection = null
|
||||
}
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
continuation.resume(binder)
|
||||
}
|
||||
}
|
||||
serviceConnection = connection
|
||||
val intent = Intent(ksuApp, KsuService::class.java)
|
||||
try {
|
||||
val task = com.topjohnwu.superuser.ipc.RootService.bindOrTask(
|
||||
intent, Shell.EXECUTOR, connection
|
||||
)
|
||||
task?.let { Shell.getShell().execTask(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bind KsuService", e)
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopKsuService() {
|
||||
serviceConnection?.let {
|
||||
try {
|
||||
val intent = Intent(ksuApp, KsuService::class.java)
|
||||
com.topjohnwu.superuser.ipc.RootService.stop(intent)
|
||||
serviceConnection = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to stop KsuService", e)
|
||||
}
|
||||
}
|
||||
val intent = Intent(ksuApp, KsuService::class.java)
|
||||
RootService.stop(intent)
|
||||
}
|
||||
|
||||
suspend fun fetchAppList() {
|
||||
isRefreshing = true
|
||||
loadingProgress = 0f
|
||||
Mutex().withLock {
|
||||
withContext(Dispatchers.Main) { isRefreshing = true }
|
||||
|
||||
val binder = connectKsuService() ?: run { isRefreshing = false; return }
|
||||
val result = connectKsuService {
|
||||
Log.w(TAG, "KsuService disconnected")
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val pm = ksuApp.packageManager
|
||||
val allPackages = IKsuInterface.Stub.asInterface(binder)
|
||||
val total = allPackages.packageCount
|
||||
val pageSize = 100
|
||||
val result = mutableListOf<AppInfo>()
|
||||
val allPackagesSlice = withContext(Dispatchers.IO) {
|
||||
val pm = ksuApp.packageManager
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
|
||||
var start = 0
|
||||
while (start < total) {
|
||||
val page = allPackages.getPackages(start, pageSize)
|
||||
if (page.isEmpty()) break
|
||||
val binder = result.first
|
||||
val iface = IKsuInterface.Stub.asInterface(binder)
|
||||
val slice = try {
|
||||
iface.getPackages(0)
|
||||
} catch (_: DeadObjectException) {
|
||||
val retry = connectKsuService { Log.w(TAG, "KsuService disconnected") }
|
||||
IKsuInterface.Stub.asInterface(retry.first).getPackages(0)
|
||||
} catch (_: RemoteException) {
|
||||
val retry = connectKsuService { Log.w(TAG, "KsuService disconnected") }
|
||||
IKsuInterface.Stub.asInterface(retry.first).getPackages(0)
|
||||
}
|
||||
|
||||
result += page.mapNotNull { packageInfo ->
|
||||
packageInfo.applicationInfo?.let { appInfo ->
|
||||
AppInfo(
|
||||
label = appInfo.loadLabel(pm).toString(),
|
||||
packageInfo = packageInfo,
|
||||
profile = Natives.getAppProfile(packageInfo.packageName, appInfo.uid)
|
||||
)
|
||||
val packages = slice.list
|
||||
val newApps = packages.map {
|
||||
val appInfo = it.applicationInfo
|
||||
val uid = appInfo!!.uid
|
||||
val profile = Natives.getAppProfile(it.packageName, uid)
|
||||
AppInfo(
|
||||
label = appInfo.loadLabel(pm).toString(),
|
||||
packageInfo = it,
|
||||
profile = profile,
|
||||
)
|
||||
}.filter { it.packageName != ksuApp.packageName }
|
||||
.filter {
|
||||
val ai = it.packageInfo.applicationInfo!!
|
||||
if (Build.VERSION.SDK_INT >= 29) !ai.isResourceOverlay else true
|
||||
}
|
||||
|
||||
val comparator = compareBy<AppInfo> {
|
||||
when {
|
||||
it.allowSu -> 0
|
||||
it.hasCustomProfile -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.then(compareBy(Collator.getInstance(Locale.getDefault()), AppInfo::label))
|
||||
val sortedFiltered = newApps.sortedWith(comparator).filter {
|
||||
it.uid == 2000
|
||||
|| showSystemApps
|
||||
|| it.allowSu
|
||||
|| it.hasCustomProfile
|
||||
|| it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0
|
||||
}
|
||||
start += page.size
|
||||
loadingProgress = start.toFloat() / total
|
||||
|
||||
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}")
|
||||
|
||||
Pair(newApps, sortedFiltered)
|
||||
}
|
||||
|
||||
stopKsuService()
|
||||
|
||||
synchronized(appsLock) {
|
||||
_isAppListLoaded.value = true
|
||||
withContext(Dispatchers.Main) {
|
||||
synchronized(appsLock) {
|
||||
apps = allPackagesSlice.first
|
||||
}
|
||||
_appList.value = allPackagesSlice.second
|
||||
isRefreshing = false
|
||||
stopKsuService()
|
||||
}
|
||||
|
||||
appListMutex.withLock {
|
||||
val filteredApps = result.filter { it.packageName != ksuApp.packageName }
|
||||
apps = filteredApps
|
||||
appGroups = groupAppsByUid(filteredApps)
|
||||
}
|
||||
loadingProgress = 1f
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
val appGroupList by derivedStateOf {
|
||||
appGroups.filter { group ->
|
||||
group.apps.any { app ->
|
||||
app.label.contains(search, true) ||
|
||||
app.packageName.contains(search, true) ||
|
||||
HanziToPinyin.getInstance().toPinyinString(app.label)?.contains(search, true) == true
|
||||
}
|
||||
}.filter { group ->
|
||||
group.uid == 2000 || showSystemApps ||
|
||||
group.apps.any { it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupAppsByUid(appList: List<AppInfo>): List<AppGroup> {
|
||||
return appList.groupBy { it.uid }
|
||||
.map { (uid, apps) ->
|
||||
val sortedApps = apps.sortedBy { it.label }
|
||||
val profile = apps.firstOrNull()?.let { Natives.getAppProfile(it.packageName, uid) }
|
||||
AppGroup(uid = uid, apps = sortedApps, profile = profile)
|
||||
}
|
||||
.sortedWith(
|
||||
compareBy<AppGroup> {
|
||||
when {
|
||||
it.allowSu -> 0
|
||||
it.hasCustomProfile -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenBy(Collator.getInstance(Locale.getDefault())) {
|
||||
it.userName?.takeIf { name -> name.isNotBlank() } ?: it.uid.toString()
|
||||
}.thenBy(Collator.getInstance(Locale.getDefault())) { it.mainApp.label }
|
||||
)
|
||||
}
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
try {
|
||||
stopKsuService()
|
||||
appProcessingThreadPool.close()
|
||||
configChangeListeners.clear()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error cleaning up resources", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,22 +7,21 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ksuApp
|
||||
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
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
/**
|
||||
@@ -36,7 +35,6 @@ const val TAG = "TemplateViewModel"
|
||||
|
||||
class TemplateViewModel : ViewModel() {
|
||||
companion object {
|
||||
|
||||
private var templates by mutableStateOf<List<TemplateInfo>>(emptyList())
|
||||
}
|
||||
|
||||
@@ -138,13 +136,7 @@ class TemplateViewModel : ViewModel() {
|
||||
|
||||
private fun fetchRemoteTemplates() {
|
||||
runCatching {
|
||||
val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.writeTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
client.newCall(
|
||||
ksuApp.okhttpClient.newCall(
|
||||
Request.Builder().url(TEMPLATE_INDEX_URL).build()
|
||||
).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
@@ -155,7 +147,7 @@ private fun fetchRemoteTemplates() {
|
||||
0.until(remoteTemplateIds.length()).forEach { i ->
|
||||
val id = remoteTemplateIds.getString(i)
|
||||
Log.i(TAG, "fetch template: $id")
|
||||
val templateJson = client.newCall(
|
||||
val templateJson = ksuApp.okhttpClient.newCall(
|
||||
Request.Builder().url(TEMPLATE_URL.format(id)).build()
|
||||
).runCatching {
|
||||
execute().use { response ->
|
||||
@@ -219,11 +211,11 @@ private fun getLocaleString(json: JSONObject, key: String): String {
|
||||
val localeKey = "${locale.language}_${locale.country}"
|
||||
json.optJSONObject("locales")?.let {
|
||||
// check locale first
|
||||
it.optJSONObject(localeKey)?.let { json->
|
||||
it.optJSONObject(localeKey)?.let { json ->
|
||||
return json.optString(key, fallback)
|
||||
}
|
||||
// fallback to language
|
||||
it.optJSONObject(locale.language)?.let { json->
|
||||
it.optJSONObject(locale.language)?.let { json ->
|
||||
return json.optString(key, fallback)
|
||||
}
|
||||
}
|
||||
@@ -281,8 +273,9 @@ fun TemplateViewModel.TemplateInfo.toJSON(): JSONObject {
|
||||
put("gid", template.gid)
|
||||
|
||||
if (template.groups.isNotEmpty()) {
|
||||
put("groups", JSONArray(
|
||||
Groups.entries.filter {
|
||||
put(
|
||||
"groups", JSONArray(
|
||||
Groups.entries.filter {
|
||||
template.groups.contains(it.gid)
|
||||
}.map {
|
||||
it.name
|
||||
@@ -291,8 +284,9 @@ fun TemplateViewModel.TemplateInfo.toJSON(): JSONObject {
|
||||
}
|
||||
|
||||
if (template.capabilities.isNotEmpty()) {
|
||||
put("capabilities", JSONArray(
|
||||
Capabilities.entries.filter {
|
||||
put(
|
||||
"capabilities", JSONArray(
|
||||
Capabilities.entries.filter {
|
||||
template.capabilities.contains(it.cap)
|
||||
}.map {
|
||||
it.name
|
||||
|
||||
@@ -43,4 +43,4 @@ object AppIconUtil {
|
||||
drawable.draw(canvas)
|
||||
return bmp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,4 +37,4 @@ data class Insets(
|
||||
appendLine("\t--f7-safe-area-right: var(--window-inset-right, 0px) !important;")
|
||||
append("}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.sukisu.ultra.ui.webui
|
||||
|
||||
import android.content.ServiceConnection
|
||||
import android.util.Log
|
||||
import com.dergoogler.mmrl.platform.Platform
|
||||
import com.dergoogler.mmrl.platform.model.IProvider
|
||||
import com.dergoogler.mmrl.platform.model.PlatformIntent
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.ksuApp
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class KsuLibSuProvider : IProvider {
|
||||
override val name = "KsuLibSu"
|
||||
|
||||
override fun isAvailable() = true
|
||||
|
||||
override suspend fun isAuthorized() = Natives.isManager
|
||||
|
||||
private val serviceIntent
|
||||
get() = PlatformIntent(
|
||||
ksuApp,
|
||||
Platform.KsuNext,
|
||||
SuService::class.java
|
||||
)
|
||||
|
||||
override fun bind(connection: ServiceConnection) {
|
||||
RootService.bind(serviceIntent.intent, connection)
|
||||
}
|
||||
|
||||
override fun unbind(connection: ServiceConnection) {
|
||||
RootService.stop(serviceIntent.intent)
|
||||
}
|
||||
}
|
||||
|
||||
// webui x
|
||||
suspend fun initPlatform() = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val active = Platform.init {
|
||||
this.context = ksuApp
|
||||
this.platform = Platform.KsuNext
|
||||
this.provider = from(KsuLibSuProvider())
|
||||
}
|
||||
|
||||
while (!active) {
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
return@withContext true
|
||||
} catch (e: Exception) {
|
||||
Log.e("KsuLibSu", "Failed to initialize platform", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.sukisu.ultra.ui.webui
|
||||
|
||||
import java.net.URLConnection
|
||||
|
||||
internal object MimeUtil {
|
||||
fun getMimeFromFileName(fileName: String?): String? {
|
||||
if (fileName == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val mimeType = URLConnection.guessContentTypeFromName(fileName)
|
||||
if (mimeType != null) {
|
||||
return mimeType
|
||||
}
|
||||
|
||||
return guessHardcodedMime(fileName)
|
||||
}
|
||||
|
||||
private fun guessHardcodedMime(fileName: String): String? {
|
||||
val finalFullStop = fileName.lastIndexOf('.')
|
||||
if (finalFullStop == -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
val extension = fileName.substring(finalFullStop + 1).lowercase()
|
||||
|
||||
return when (extension) {
|
||||
"webm" -> "video/webm"
|
||||
"mpeg", "mpg" -> "video/mpeg"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"wasm" -> "application/wasm"
|
||||
"xhtml", "xht", "xhtm" -> "application/xhtml+xml"
|
||||
"flac" -> "audio/flac"
|
||||
"ogg", "oga", "opus" -> "audio/ogg"
|
||||
"wav" -> "audio/wav"
|
||||
"m4a" -> "audio/x-m4a"
|
||||
"gif" -> "image/gif"
|
||||
"jpeg", "jpg", "jfif", "pjpeg", "pjp" -> "image/jpeg"
|
||||
"png" -> "image/png"
|
||||
"apng" -> "image/apng"
|
||||
"svg", "svgz" -> "image/svg+xml"
|
||||
"webp" -> "image/webp"
|
||||
"mht", "mhtml" -> "multipart/related"
|
||||
"css" -> "text/css"
|
||||
"html", "htm", "shtml", "shtm", "ehtml" -> "text/html"
|
||||
"js", "mjs" -> "application/javascript"
|
||||
"xml" -> "text/xml"
|
||||
"mp4", "m4v" -> "video/mp4"
|
||||
"ogv", "ogm" -> "video/ogg"
|
||||
"ico" -> "image/x-icon"
|
||||
"woff" -> "application/font-woff"
|
||||
"gz", "tgz" -> "application/gzip"
|
||||
"json" -> "application/json"
|
||||
"pdf" -> "application/pdf"
|
||||
"zip" -> "application/zip"
|
||||
"bmp" -> "image/bmp"
|
||||
"tiff", "tif" -> "image/tiff"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/**
|
||||
* Handler class to open files from file system by root access
|
||||
* For more information about android storage please refer to
|
||||
* <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;
|
||||
private final InsetsSupplier mInsetsSupplier;
|
||||
|
||||
public interface InsetsSupplier {
|
||||
@NonNull
|
||||
Insets get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 context {@link Context} that is used to access app's internal storage.
|
||||
* @param directory the absolute path of the exposed app internal storage directory from
|
||||
* which files can be loaded.
|
||||
* @param rootShell {@link Shell} instance with root access to read files.
|
||||
* @param insetsSupplier {@link InsetsSupplier} to provide window insets for styling web content.
|
||||
* @throws IllegalArgumentException if the directory is not allowed.
|
||||
*/
|
||||
public SuFilePathHandler(@NonNull Context context, @NonNull File directory, Shell rootShell, @NonNull InsetsSupplier insetsSupplier) {
|
||||
try {
|
||||
mInsetsSupplier = insetsSupplier;
|
||||
mDirectory = new File(getCanonicalDirPath(directory));
|
||||
if (!isAllowedInternalStorageDir(context)) {
|
||||
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(@NonNull Context context) 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) {
|
||||
if ("internal/insets.css".equals(path)) {
|
||||
String css = mInsetsSupplier.get().getCss();
|
||||
return new WebResourceResponse(
|
||||
"text/css",
|
||||
"utf-8",
|
||||
new ByteArrayInputStream(css.getBytes(StandardCharsets.UTF_8))
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package com.sukisu.ultra.ui.webui
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.webkit.WebResourceResponse
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.io.SuFile
|
||||
import com.topjohnwu.superuser.io.SuFileInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
/**
|
||||
* Handler class to open files from file system by root access
|
||||
* For more information about android storage please refer to
|
||||
* [Android Developers Docs: Data and file storage overview](https://developer.android.com/guide/topics/data/data-storage).
|
||||
*
|
||||
* To avoid leaking user or app data to the web, make sure to choose [directory]
|
||||
* carefully, and assume any file under this directory could be accessed by any web page subject
|
||||
* to same-origin rules.
|
||||
*
|
||||
* A typical usage would be like:
|
||||
* ```
|
||||
* val publicDir = File(context.filesDir, "public")
|
||||
* // Host "files/public/" in app's data directory under:
|
||||
* // http://appassets.androidplatform.net/public/...
|
||||
* val assetLoader = WebViewAssetLoader.Builder()
|
||||
* .addPathHandler("/public/", SuFilePathHandler(context, publicDir, shell, insetsSupplier))
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
class SuFilePathHandler(
|
||||
directory: File,
|
||||
private val shell: Shell,
|
||||
private val insetsSupplier: InsetsSupplier
|
||||
) : WebViewAssetLoader.PathHandler {
|
||||
|
||||
private val directory: File
|
||||
|
||||
init {
|
||||
try {
|
||||
this.directory = File(getCanonicalDirPath(directory))
|
||||
if (!isAllowedInternalStorageDir()) {
|
||||
throw IllegalArgumentException(
|
||||
"The given directory \"$directory\" doesn't exist under an allowed app internal storage directory"
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw IllegalArgumentException(
|
||||
"Failed to resolve the canonical path for the given directory: ${directory.path}",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface InsetsSupplier {
|
||||
fun get(): Insets
|
||||
}
|
||||
|
||||
private fun isAllowedInternalStorageDir(): Boolean {
|
||||
return try {
|
||||
val dir = getCanonicalDirPath(directory)
|
||||
FORBIDDEN_DATA_DIRS.none { dir.startsWith(it) }
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the requested file from the exposed data directory.
|
||||
*
|
||||
* The matched prefix path used shouldn't be a prefix of a real web path. Thus, if the
|
||||
* requested file cannot be found or is outside the mounted directory a
|
||||
* [WebResourceResponse] object with a `null` [InputStream] will be
|
||||
* returned instead of `null`. This saves the time of falling back to network and
|
||||
* trying to resolve a path that doesn't exist. A [WebResourceResponse] with
|
||||
* `null` [InputStream] will be received as an HTTP response with status code
|
||||
* `404` and no body.
|
||||
*
|
||||
* The MIME type for the file will be determined from the file's extension using
|
||||
* [java.net.URLConnection.guessContentTypeFromName]. Developers should ensure that
|
||||
* files are named using standard file extensions. If the file does not have a
|
||||
* recognised extension, `"text/plain"` will be used by default.
|
||||
*
|
||||
* @param path the suffix path to be handled.
|
||||
* @return [WebResourceResponse] for the requested file.
|
||||
*/
|
||||
@WorkerThread
|
||||
override fun handle(path: String): WebResourceResponse {
|
||||
if (path == "internal/insets.css") {
|
||||
val css = insetsSupplier.get().css
|
||||
return WebResourceResponse(
|
||||
"text/css",
|
||||
"utf-8",
|
||||
ByteArrayInputStream(css.toByteArray(StandardCharsets.UTF_8))
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
val file = getCanonicalFileIfChild(directory, path)
|
||||
if (file != null) {
|
||||
val inputStream = openFile(file, shell)
|
||||
val mimeType = guessMimeType(path)
|
||||
return WebResourceResponse(mimeType, null, inputStream)
|
||||
} else {
|
||||
Log.e(
|
||||
TAG,
|
||||
"The requested file: $path is outside the mounted directory: $directory"
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error opening the requested path: $path", e)
|
||||
}
|
||||
|
||||
return WebResourceResponse(null, null, null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SuFilePathHandler"
|
||||
|
||||
/**
|
||||
* Default value to be used as MIME type if guessing MIME type failed.
|
||||
*/
|
||||
const val DEFAULT_MIME_TYPE = "text/plain"
|
||||
|
||||
/**
|
||||
* Forbidden subdirectories of [Context.getDataDir] that cannot be exposed by this
|
||||
* handler. They are forbidden as they often contain sensitive information.
|
||||
*
|
||||
* Note: Any future addition to this list will be considered breaking changes to the API.
|
||||
*/
|
||||
private val FORBIDDEN_DATA_DIRS = arrayOf("/data/data", "/data/system")
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun getCanonicalDirPath(file: File): String {
|
||||
var canonicalPath = file.canonicalPath
|
||||
if (!canonicalPath.endsWith("/")) {
|
||||
canonicalPath += "/"
|
||||
}
|
||||
return canonicalPath
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun getCanonicalFileIfChild(parent: File, child: String): File? {
|
||||
val parentCanonicalPath = getCanonicalDirPath(parent)
|
||||
val childCanonicalPath = File(parent, child).canonicalPath
|
||||
return if (childCanonicalPath.startsWith(parentCanonicalPath)) {
|
||||
File(childCanonicalPath)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun handleSvgzStream(path: String, stream: InputStream): InputStream {
|
||||
return if (path.endsWith(".svgz")) {
|
||||
GZIPInputStream(stream)
|
||||
} else {
|
||||
stream
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun openFile(file: File, shell: Shell): InputStream {
|
||||
val suFile = SuFile(file.absolutePath).apply {
|
||||
setShell(shell)
|
||||
}
|
||||
val fis = SuFileInputStream.open(suFile)
|
||||
return handleSvgzStream(file.path, fis)
|
||||
}
|
||||
|
||||
/**
|
||||
* Use [MimeUtil.getMimeFromFileName] to guess MIME type or return the
|
||||
* [DEFAULT_MIME_TYPE] if it can't guess.
|
||||
*
|
||||
* @param filePath path of the file to guess its MIME type.
|
||||
* @return MIME type guessed from file extension or [DEFAULT_MIME_TYPE].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun guessMimeType(filePath: String): String {
|
||||
return MimeUtil.getMimeFromFileName(filePath) ?: DEFAULT_MIME_TYPE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -14,27 +14,26 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import com.dergoogler.mmrl.platform.model.ModId
|
||||
import com.dergoogler.mmrl.webui.interfaces.WXOptions
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.launch
|
||||
import com.sukisu.ultra.ui.util.createRootShell
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
|
||||
import java.io.File
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
class WebUIActivity : ComponentActivity() {
|
||||
private val rootShell by lazy { createRootShell(true) }
|
||||
private lateinit var webviewInterface: WebViewInterface
|
||||
|
||||
private var rootShell: Shell? = null
|
||||
private lateinit var insets: Insets
|
||||
private var webView = null as WebView?
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -51,24 +50,26 @@ class WebUIActivity : ComponentActivity() {
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
InfiniteProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
val superUserViewModel = ViewModelProvider(this)[SuperUserViewModel::class.java]
|
||||
|
||||
lifecycleScope.launch {
|
||||
SuperUserViewModel.isAppListLoaded.first { it }
|
||||
superUserViewModel.fetchAppList()
|
||||
setupWebView()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupWebView() {
|
||||
val moduleId = intent.getStringExtra("id") ?: finishAndRemoveTask().let { return }
|
||||
val name = intent.getStringExtra("name") ?: finishAndRemoveTask().let { return }
|
||||
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"))
|
||||
setTaskDescription(ActivityManager.TaskDescription("KernelSU - $name"))
|
||||
} else {
|
||||
val taskDescription =
|
||||
ActivityManager.TaskDescription.Builder().setLabel("SukiSU-Ultra - $name").build()
|
||||
val taskDescription = ActivityManager.TaskDescription.Builder().setLabel("KernelSU - $name").build()
|
||||
setTaskDescription(taskDescription)
|
||||
}
|
||||
|
||||
@@ -77,12 +78,14 @@ class WebUIActivity : ComponentActivity() {
|
||||
|
||||
val moduleDir = "/data/adb/modules/${moduleId}"
|
||||
val webRoot = File("${moduleDir}/webroot")
|
||||
val rootShell = createRootShell(true).also { this.rootShell = it }
|
||||
insets = Insets(0, 0, 0, 0)
|
||||
|
||||
val webViewAssetLoader = WebViewAssetLoader.Builder()
|
||||
.setDomain("mui.kernelsu.org")
|
||||
.addPathHandler(
|
||||
"/",
|
||||
SuFilePathHandler(webRoot, rootShell) { insets }
|
||||
SuFilePathHandler(this, webRoot, rootShell) { insets }
|
||||
)
|
||||
.build()
|
||||
|
||||
@@ -92,6 +95,7 @@ class WebUIActivity : ComponentActivity() {
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
val url = request.url
|
||||
|
||||
// Handle ksu://icon/[packageName] to serve app icon via WebView
|
||||
if (url.scheme.equals("ksu", ignoreCase = true) && url.host.equals("icon", ignoreCase = true)) {
|
||||
val packageName = url.path?.substring(1)
|
||||
@@ -105,13 +109,12 @@ class WebUIActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return webViewAssetLoader.shouldInterceptRequest(url)
|
||||
}
|
||||
}
|
||||
|
||||
val webView = WebView(this).apply {
|
||||
webView = this
|
||||
|
||||
setBackgroundColor(Color.TRANSPARENT)
|
||||
val density = resources.displayMetrics.density
|
||||
|
||||
@@ -128,7 +131,8 @@ class WebUIActivity : ComponentActivity() {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.allowFileAccess = false
|
||||
addJavascriptInterface(WebViewInterface(WXOptions(this@WebUIActivity, this, ModId(moduleId))), "ksu")
|
||||
webviewInterface = WebViewInterface(this@WebUIActivity, this, moduleDir)
|
||||
addJavascriptInterface(webviewInterface, "ksu")
|
||||
setWebViewClient(webViewClient)
|
||||
loadUrl("https://mui.kernelsu.org/index.html")
|
||||
}
|
||||
@@ -137,13 +141,7 @@ class WebUIActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
rootShell.runCatching { close() }
|
||||
webView?.apply {
|
||||
stopLoading()
|
||||
removeAllViews()
|
||||
destroy()
|
||||
webView = null
|
||||
}
|
||||
super.onDestroy()
|
||||
runCatching { rootShell?.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package com.sukisu.ultra.ui.webui
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.dergoogler.mmrl.platform.Platform
|
||||
import com.dergoogler.mmrl.platform.model.ModId
|
||||
import com.dergoogler.mmrl.ui.component.Loading
|
||||
import com.dergoogler.mmrl.webui.model.WebUIConfig
|
||||
import com.dergoogler.mmrl.webui.screen.WebUIScreen
|
||||
import com.dergoogler.mmrl.webui.util.rememberWebUIOptions
|
||||
import com.sukisu.ultra.BuildConfig
|
||||
import com.sukisu.ultra.ui.theme.KernelSUTheme
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class WebUIXActivity : ComponentActivity() {
|
||||
private lateinit var webView: WebView
|
||||
|
||||
private val userAgent
|
||||
get(): String {
|
||||
val ksuVersion = BuildConfig.VERSION_CODE
|
||||
|
||||
val platform = Platform.get("Unknown") {
|
||||
platform.name
|
||||
}
|
||||
|
||||
val platformVersion = Platform.get(-1) {
|
||||
moduleManager.versionCode
|
||||
}
|
||||
|
||||
val osVersion = Build.VERSION.RELEASE
|
||||
val deviceModel = Build.MODEL
|
||||
|
||||
return "SukiSU-Ultra /$ksuVersion (Linux; Android $osVersion; $deviceModel; $platform/$platformVersion)"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
webView = WebView(this)
|
||||
|
||||
lifecycleScope.launch {
|
||||
initPlatform()
|
||||
}
|
||||
|
||||
val moduleId = intent.getStringExtra("id")!!
|
||||
val name = intent.getStringExtra("name")!!
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
@Suppress("DEPRECATION")
|
||||
setTaskDescription(ActivityManager.TaskDescription("SukiSU-Ultra - $name"))
|
||||
} else {
|
||||
val taskDescription =
|
||||
ActivityManager.TaskDescription.Builder().setLabel("SukiSU-Ultra - $name").build()
|
||||
setTaskDescription(taskDescription)
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences("settings", MODE_PRIVATE)
|
||||
|
||||
setContent {
|
||||
KernelSUTheme {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Platform.isAlive) {
|
||||
while (!Platform.isAlive) {
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Loading()
|
||||
return@KernelSUTheme
|
||||
}
|
||||
|
||||
val webDebugging = prefs.getBoolean("enable_web_debugging", false)
|
||||
val erudaInject = prefs.getBoolean("use_webuix_eruda", false)
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
val options = rememberWebUIOptions(
|
||||
modId = ModId(moduleId),
|
||||
debug = webDebugging,
|
||||
appVersionCode = BuildConfig.VERSION_CODE,
|
||||
isDarkMode = dark,
|
||||
enableEruda = erudaInject,
|
||||
cls = WebUIXActivity::class.java,
|
||||
userAgentString = userAgent
|
||||
)
|
||||
|
||||
// idk why webuix not allow root impl change webuiConfig
|
||||
// so we use magic to force exitConfirm shutdown
|
||||
val field = WebUIConfig::class.java.getDeclaredField("exitConfirm")
|
||||
field.isAccessible = true
|
||||
field.set(options.config, false)
|
||||
field.isAccessible = false
|
||||
|
||||
WebUIScreen(
|
||||
webView = webView,
|
||||
options = options,
|
||||
interfaces = listOf(
|
||||
WebViewInterface.factory()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,35 @@
|
||||
package com.sukisu.ultra.ui.webui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.TextUtils
|
||||
import android.view.Window
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.dergoogler.mmrl.webui.interfaces.WXInterface
|
||||
import com.dergoogler.mmrl.webui.interfaces.WXOptions
|
||||
import com.dergoogler.mmrl.webui.model.JavaScriptInterface
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import com.topjohnwu.superuser.CallbackList
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
import com.topjohnwu.superuser.internal.UiThreadHandler
|
||||
import com.sukisu.ultra.ui.util.createRootShell
|
||||
import com.sukisu.ultra.ui.util.listModules
|
||||
import com.sukisu.ultra.ui.util.withNewRootShell
|
||||
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
|
||||
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}"
|
||||
val context: Context,
|
||||
private val webView: WebView,
|
||||
private val modDir: String
|
||||
) {
|
||||
|
||||
@JavascriptInterface
|
||||
fun exec(cmd: String): String {
|
||||
@@ -69,56 +64,56 @@ class WebViewInterface(
|
||||
options: String?,
|
||||
callbackFunc: String
|
||||
) {
|
||||
val finalCommand = buildString {
|
||||
processOptions(this, options)
|
||||
append(cmd)
|
||||
}
|
||||
val finalCommand = StringBuilder()
|
||||
processOptions(finalCommand, options)
|
||||
finalCommand.append(cmd)
|
||||
|
||||
val result = withNewRootShell(true) {
|
||||
newJob().add(finalCommand).to(ArrayList(), ArrayList()).exec()
|
||||
newJob().add(finalCommand.toString()).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}, ${
|
||||
"javascript: (function() { try { ${callbackFunc}(${result.code}, ${
|
||||
JSONObject.quote(
|
||||
stdout
|
||||
)
|
||||
}, ${JSONObject.quote(stderr)}); } catch(e) { console.error(e); } })();"
|
||||
webView.post {
|
||||
webView.evaluateJavascript(jsCode, null)
|
||||
webView.loadUrl(jsCode)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun spawn(command: String, args: String, options: String?, callbackFunc: String) {
|
||||
val finalCommand = buildString {
|
||||
processOptions(this, options)
|
||||
val finalCommand = StringBuilder()
|
||||
|
||||
if (!TextUtils.isEmpty(args)) {
|
||||
append(command).append(" ")
|
||||
JSONArray(args).let { argsArray ->
|
||||
for (i in 0 until argsArray.length()) {
|
||||
append("${argsArray.getString(i)} ")
|
||||
}
|
||||
processOptions(finalCommand, options)
|
||||
|
||||
if (!TextUtils.isEmpty(args)) {
|
||||
finalCommand.append(command).append(" ")
|
||||
JSONArray(args).let { argsArray ->
|
||||
for (i in 0 until argsArray.length()) {
|
||||
finalCommand.append(argsArray.getString(i))
|
||||
finalCommand.append(" ")
|
||||
}
|
||||
} else {
|
||||
append(command)
|
||||
}
|
||||
} else {
|
||||
finalCommand.append(command)
|
||||
}
|
||||
|
||||
val shell = createRootShell(true)
|
||||
|
||||
val emitData = fun(name: String, data: String) {
|
||||
val jsCode =
|
||||
"(function() { try { ${callbackFunc}.${name}.emit('data', ${
|
||||
"javascript: (function() { try { ${callbackFunc}.${name}.emit('data', ${
|
||||
JSONObject.quote(
|
||||
data
|
||||
)
|
||||
}); } catch(e) { console.error('emitData', e); } })();"
|
||||
webView.post {
|
||||
webView.evaluateJavascript(jsCode, null)
|
||||
webView.loadUrl(jsCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,21 +129,21 @@ class WebViewInterface(
|
||||
}
|
||||
}
|
||||
|
||||
val future = shell.newJob().add(finalCommand).to(stdout, stderr).enqueue()
|
||||
val future = shell.newJob().add(finalCommand.toString()).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}`); } })();"
|
||||
"javascript: (function() { try { ${callbackFunc}.emit('exit', ${result.code}); } catch(e) { console.error(`emitExit error: \${e}`); } })();"
|
||||
webView.post {
|
||||
webView.evaluateJavascript(emitExitCode, null)
|
||||
webView.loadUrl(emitExitCode)
|
||||
}
|
||||
|
||||
if (result.code != 0) {
|
||||
val emitErrCode =
|
||||
"(function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${
|
||||
"javascript: (function() { try { var err = new Error(); err.exitCode = ${result.code}; err.message = ${
|
||||
JSONObject.quote(
|
||||
result.err.joinToString(
|
||||
"\n"
|
||||
@@ -156,7 +151,7 @@ class WebViewInterface(
|
||||
)
|
||||
};${callbackFunc}.emit('error', err); } catch(e) { console.error('emitErr', e); } })();"
|
||||
webView.post {
|
||||
webView.evaluateJavascript(emitErrCode, null)
|
||||
webView.loadUrl(emitErrCode)
|
||||
}
|
||||
}
|
||||
}.whenComplete { _, _ ->
|
||||
@@ -176,9 +171,9 @@ class WebViewInterface(
|
||||
if (context is Activity) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (enable) {
|
||||
hideSystemUI(activity.window)
|
||||
hideSystemUI(context.window)
|
||||
} else {
|
||||
showSystemUI(activity.window)
|
||||
showSystemUI(context.window)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,7 +182,7 @@ class WebViewInterface(
|
||||
@JavascriptInterface
|
||||
fun moduleInfo(): String {
|
||||
val moduleInfos = JSONArray(listModules())
|
||||
val currentModuleInfo = JSONObject()
|
||||
var currentModuleInfo = JSONObject()
|
||||
currentModuleInfo.put("moduleDir", modDir)
|
||||
val moduleId = File(modDir).getName()
|
||||
for (i in 0 until moduleInfos.length()) {
|
||||
@@ -197,7 +192,7 @@ class WebViewInterface(
|
||||
continue
|
||||
}
|
||||
|
||||
val keys = currentInfo.keys()
|
||||
var keys = currentInfo.keys()
|
||||
for (key in keys) {
|
||||
currentModuleInfo.put(key, currentInfo.get(key))
|
||||
}
|
||||
@@ -255,18 +250,6 @@ class WebViewInterface(
|
||||
}
|
||||
return jsonArray.toString()
|
||||
}
|
||||
|
||||
// =================== KPM支持 =============================
|
||||
|
||||
@JavascriptInterface
|
||||
fun listAllKpm(): String {
|
||||
return listKpmModules()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun controlKpm(name: String, args: String): Int {
|
||||
return controlKpmModule(name, args)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideSystemUI(window: Window) =
|
||||
@@ -276,4 +259,4 @@ fun hideSystemUI(window: Window) =
|
||||
}
|
||||
|
||||
fun showSystemUI(window: Window) =
|
||||
WindowInsetsControllerCompat(window, window.decorView).show(WindowInsetsCompat.Type.systemBars())
|
||||
WindowInsetsControllerCompat(window, window.decorView).show(WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
package zako.zako.zako.zakoui.screen.kernelFlash
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.KeyEventBlocker
|
||||
import com.sukisu.ultra.ui.theme.CardConfig
|
||||
import com.sukisu.ultra.ui.util.LocalSnackbarHost
|
||||
import com.sukisu.ultra.ui.util.reboot
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.FlashState
|
||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelState
|
||||
import zako.zako.zako.zakoui.screen.kernelFlash.state.HorizonKernelWorker
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/5/31.
|
||||
*/
|
||||
private object KernelFlashStateHolder {
|
||||
var currentState: HorizonKernelState? = null
|
||||
var currentUri: Uri? = null
|
||||
var currentSlot: String? = null
|
||||
var currentKpmPatchEnabled: Boolean = false
|
||||
var currentKpmUndoPatch: Boolean = false
|
||||
var isFlashing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Kernel刷写界面
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun KernelFlashScreen(
|
||||
navigator: DestinationsNavigator,
|
||||
kernelUri: Uri,
|
||||
selectedSlot: String? = null,
|
||||
kpmPatchEnabled: Boolean = false,
|
||||
kpmUndoPatch: Boolean = false
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val shouldAutoExit = remember {
|
||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
sharedPref.getBoolean("auto_exit_after_flash", false)
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var logText by rememberSaveable { mutableStateOf("") }
|
||||
var showFloatAction by rememberSaveable { mutableStateOf(false) }
|
||||
val logContent = rememberSaveable { StringBuilder() }
|
||||
val horizonKernelState = remember {
|
||||
if (KernelFlashStateHolder.currentState != null &&
|
||||
KernelFlashStateHolder.currentUri == kernelUri &&
|
||||
KernelFlashStateHolder.currentSlot == selectedSlot &&
|
||||
KernelFlashStateHolder.currentKpmPatchEnabled == kpmPatchEnabled &&
|
||||
KernelFlashStateHolder.currentKpmUndoPatch == kpmUndoPatch) {
|
||||
KernelFlashStateHolder.currentState!!
|
||||
} else {
|
||||
HorizonKernelState().also {
|
||||
KernelFlashStateHolder.currentState = it
|
||||
KernelFlashStateHolder.currentUri = kernelUri
|
||||
KernelFlashStateHolder.currentSlot = selectedSlot
|
||||
KernelFlashStateHolder.currentKpmPatchEnabled = kpmPatchEnabled
|
||||
KernelFlashStateHolder.currentKpmUndoPatch = kpmUndoPatch
|
||||
KernelFlashStateHolder.isFlashing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val flashState by horizonKernelState.state.collectAsState()
|
||||
val logSavedString = stringResource(R.string.log_saved)
|
||||
|
||||
val onFlashComplete = {
|
||||
showFloatAction = true
|
||||
KernelFlashStateHolder.isFlashing = false
|
||||
|
||||
// 如果需要自动退出,延迟1.5秒后退出
|
||||
if (shouldAutoExit) {
|
||||
scope.launch {
|
||||
delay(1500)
|
||||
val sharedPref = context.getSharedPreferences("kernel_flash_prefs", Context.MODE_PRIVATE)
|
||||
sharedPref.edit { remove("auto_exit_after_flash") }
|
||||
(context as? ComponentActivity)?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开始刷写
|
||||
LaunchedEffect(Unit) {
|
||||
if (!KernelFlashStateHolder.isFlashing && !flashState.isCompleted && flashState.error.isEmpty()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
KernelFlashStateHolder.isFlashing = true
|
||||
val worker = HorizonKernelWorker(
|
||||
context = context,
|
||||
state = horizonKernelState,
|
||||
slot = selectedSlot,
|
||||
kpmPatchEnabled = kpmPatchEnabled,
|
||||
kpmUndoPatch = kpmUndoPatch
|
||||
)
|
||||
worker.uri = kernelUri
|
||||
worker.setOnFlashCompleteListener(onFlashComplete)
|
||||
worker.start()
|
||||
|
||||
// 监听日志更新
|
||||
while (flashState.error.isEmpty()) {
|
||||
if (flashState.logs.isNotEmpty()) {
|
||||
logText = flashState.logs.joinToString("\n")
|
||||
logContent.clear()
|
||||
logContent.append(logText)
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
|
||||
if (flashState.error.isNotEmpty()) {
|
||||
logText += "\n${flashState.error}\n"
|
||||
logContent.append("\n${flashState.error}\n")
|
||||
KernelFlashStateHolder.isFlashing = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logText = flashState.logs.joinToString("\n")
|
||||
if (flashState.error.isNotEmpty()) {
|
||||
logText += "\n${flashState.error}\n"
|
||||
} else if (flashState.isCompleted) {
|
||||
logText += "\n${context.getString(R.string.horizon_flash_complete)}\n\n\n"
|
||||
showFloatAction = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onBack: () -> Unit = {
|
||||
if (!flashState.isFlashing || flashState.isCompleted || flashState.error.isNotEmpty()) {
|
||||
// 清理全局状态
|
||||
if (flashState.isCompleted || flashState.error.isNotEmpty()) {
|
||||
KernelFlashStateHolder.currentState = null
|
||||
KernelFlashStateHolder.currentUri = null
|
||||
KernelFlashStateHolder.currentSlot = null
|
||||
KernelFlashStateHolder.currentKpmPatchEnabled = false
|
||||
KernelFlashStateHolder.currentKpmUndoPatch = false
|
||||
KernelFlashStateHolder.isFlashing = false
|
||||
}
|
||||
navigator.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(shouldAutoExit) {
|
||||
onDispose {
|
||||
if (shouldAutoExit) {
|
||||
KernelFlashStateHolder.currentState = null
|
||||
KernelFlashStateHolder.currentUri = null
|
||||
KernelFlashStateHolder.currentSlot = null
|
||||
KernelFlashStateHolder.currentKpmPatchEnabled = false
|
||||
KernelFlashStateHolder.currentKpmUndoPatch = false
|
||||
KernelFlashStateHolder.isFlashing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
onBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBar(
|
||||
flashState = flashState,
|
||||
onBack = onBack,
|
||||
onSave = {
|
||||
scope.launch {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.getDefault())
|
||||
val date = format.format(Date())
|
||||
val file = File(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
"KernelSU_kernel_flash_log_${date}.log"
|
||||
)
|
||||
file.writeText(logContent.toString())
|
||||
snackBarHost.showSnackbar(logSavedString.format(file.absolutePath))
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (showFloatAction) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
reboot()
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = stringResource(id = R.string.reboot)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(id = R.string.reboot))
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
expanded = true
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(hostState = snackBarHost) },
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) { innerPadding ->
|
||||
KeyEventBlocker {
|
||||
it.key == Key.VolumeDown || it.key == Key.VolumeUp
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) {
|
||||
FlashProgressIndicator(flashState, kpmPatchEnabled, kpmUndoPatch)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
LaunchedEffect(logText) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
text = logText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FlashProgressIndicator(
|
||||
flashState: FlashState,
|
||||
kpmPatchEnabled: Boolean = false,
|
||||
kpmUndoPatch: Boolean = false
|
||||
) {
|
||||
val progressColor = when {
|
||||
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
|
||||
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
val progress = animateFloatAsState(
|
||||
targetValue = flashState.progress,
|
||||
label = "FlashProgress"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
flashState.error.isNotEmpty() -> stringResource(R.string.flash_failed)
|
||||
flashState.isCompleted -> stringResource(R.string.flash_success)
|
||||
else -> stringResource(R.string.flashing)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = progressColor
|
||||
)
|
||||
|
||||
when {
|
||||
flashState.error.isNotEmpty() -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
flashState.isCompleted -> {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KPM状态显示
|
||||
if (kpmPatchEnabled || kpmUndoPatch) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (kpmUndoPatch) stringResource(R.string.kpm_undo_patch_mode)
|
||||
else stringResource(R.string.kpm_patch_mode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (flashState.currentStep.isNotEmpty()) {
|
||||
Text(
|
||||
text = flashState.currentStep,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.value },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp),
|
||||
color = progressColor,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
|
||||
if (flashState.error.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = flashState.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopBar(
|
||||
flashState: FlashState,
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null
|
||||
) {
|
||||
val statusColor = when {
|
||||
flashState.error.isNotEmpty() -> MaterialTheme.colorScheme.error
|
||||
flashState.isCompleted -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
||||
colorScheme.surfaceContainerLow
|
||||
} else {
|
||||
colorScheme.background
|
||||
}
|
||||
val cardAlpha = CardConfig.cardAlpha
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
when {
|
||||
flashState.error.isNotEmpty() -> R.string.flash_failed
|
||||
flashState.isCompleted -> R.string.flash_success
|
||||
else -> R.string.kernel_flashing
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = statusColor
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = onSave) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Save,
|
||||
contentDescription = stringResource(id = R.string.save_log),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package zako.zako.zako.zakoui.screen.kernelFlash.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SdStorage
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sukisu.ultra.R
|
||||
|
||||
/**
|
||||
* 槽位选择对话框组件
|
||||
* 用于Kernel刷写时选择目标槽位
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SlotSelectionDialog(
|
||||
show: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSlotSelected: (String) -> Unit
|
||||
) {
|
||||
var currentSlot by remember { mutableStateOf<String?>(null) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var selectedSlot by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
currentSlot = getCurrentSlot()
|
||||
// 设置默认选择为当前槽位
|
||||
selectedSlot = when (currentSlot) {
|
||||
"a" -> "a"
|
||||
"b" -> "b"
|
||||
else -> null
|
||||
}
|
||||
errorMessage = null
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message
|
||||
currentSlot = null
|
||||
}
|
||||
}
|
||||
|
||||
if (show) {
|
||||
val cardColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.select_slot_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = "Error: $errorMessage",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.current_slot,
|
||||
currentSlot ?: "Unknown"
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.select_slot_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Horizontal arrangement for slot options with highlighted current slot
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val slotOptions = listOf(
|
||||
ListOption(
|
||||
titleText = stringResource(id = R.string.slot_a),
|
||||
subtitleText = null,
|
||||
icon = Icons.Filled.SdStorage
|
||||
),
|
||||
ListOption(
|
||||
titleText = stringResource(id = R.string.slot_b),
|
||||
subtitleText = null,
|
||||
icon = Icons.Filled.SdStorage
|
||||
)
|
||||
)
|
||||
|
||||
slotOptions.forEachIndexed { index, option ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(
|
||||
color = if (selectedSlot == when(index) {
|
||||
0 -> "a"
|
||||
else -> "b"
|
||||
}) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
}
|
||||
)
|
||||
.clickable {
|
||||
selectedSlot = when(index) {
|
||||
0 -> "a"
|
||||
else -> "b"
|
||||
}
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = option.icon,
|
||||
contentDescription = null,
|
||||
tint = if (selectedSlot == when(index) {
|
||||
0 -> "a"
|
||||
else -> "b"
|
||||
}) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = option.titleText,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (selectedSlot == when(index) {
|
||||
0 -> "a"
|
||||
else -> "b"
|
||||
}) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
)
|
||||
option.subtitleText?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (selectedSlot == when(index) {
|
||||
0 -> "a"
|
||||
else -> "b"
|
||||
}) {
|
||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedSlot?.let { onSlotSelected(it) }
|
||||
onDismiss()
|
||||
},
|
||||
enabled = selectedSlot != null
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(android.R.string.ok),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(android.R.string.cancel),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
containerColor = cardColor,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 4.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Data class for list options
|
||||
data class ListOption(
|
||||
val titleText: String,
|
||||
val subtitleText: String?,
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
// Utility function to get current slot
|
||||
private fun getCurrentSlot(): String? {
|
||||
return runCommandGetOutput(true, "getprop ro.boot.slot_suffix")?.let {
|
||||
if (it.startsWith("_")) it.substring(1) else it
|
||||
}
|
||||
}
|
||||
|
||||
private fun runCommandGetOutput(su: Boolean, cmd: String): String? {
|
||||
return try {
|
||||
val process = ProcessBuilder(if (su) "su" else "sh").start()
|
||||
process.outputStream.bufferedWriter().use { writer ->
|
||||
writer.write("$cmd\n")
|
||||
writer.write("exit\n")
|
||||
writer.flush()
|
||||
}
|
||||
process.inputStream.bufferedReader().use { reader ->
|
||||
reader.readText().trim()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
package zako.zako.zako.zakoui.screen.kernelFlash.state
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.network.RemoteToolsDownloader
|
||||
import com.sukisu.ultra.ui.util.install
|
||||
import com.sukisu.ultra.ui.util.rootAvailable
|
||||
import com.sukisu.ultra.utils.AssetsUtil
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
|
||||
/**
|
||||
* @author ShirkNeko
|
||||
* @date 2025/5/31.
|
||||
*/
|
||||
data class FlashState(
|
||||
val isFlashing: Boolean = false,
|
||||
val isCompleted: Boolean = false,
|
||||
val progress: Float = 0f,
|
||||
val currentStep: String = "",
|
||||
val logs: List<String> = emptyList(),
|
||||
val error: String = ""
|
||||
)
|
||||
|
||||
class HorizonKernelState {
|
||||
private val _state = MutableStateFlow(FlashState())
|
||||
val state: StateFlow<FlashState> = _state.asStateFlow()
|
||||
|
||||
fun updateProgress(progress: Float) {
|
||||
_state.update { it.copy(progress = progress) }
|
||||
}
|
||||
|
||||
fun updateStep(step: String) {
|
||||
_state.update { it.copy(currentStep = step) }
|
||||
}
|
||||
|
||||
fun addLog(log: String) {
|
||||
_state.update {
|
||||
it.copy(logs = it.logs + log)
|
||||
}
|
||||
}
|
||||
|
||||
fun setError(error: String) {
|
||||
_state.update { it.copy(error = error) }
|
||||
}
|
||||
|
||||
fun startFlashing() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isFlashing = true,
|
||||
isCompleted = false,
|
||||
progress = 0f,
|
||||
currentStep = "under preparation...",
|
||||
logs = emptyList(),
|
||||
error = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun completeFlashing() {
|
||||
_state.update { it.copy(isCompleted = true, progress = 1f) }
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
_state.value = FlashState()
|
||||
}
|
||||
}
|
||||
|
||||
class HorizonKernelWorker(
|
||||
private val context: Context,
|
||||
private val state: HorizonKernelState,
|
||||
private val slot: String? = null,
|
||||
private val kpmPatchEnabled: Boolean = false,
|
||||
private val kpmUndoPatch: Boolean = false
|
||||
) : Thread() {
|
||||
var uri: Uri? = null
|
||||
private lateinit var filePath: String
|
||||
private lateinit var binaryPath: String
|
||||
private lateinit var workDir: String
|
||||
|
||||
private var onFlashComplete: (() -> Unit)? = null
|
||||
private var originalSlot: String? = null
|
||||
private var downloaderJob: Job? = null
|
||||
|
||||
fun setOnFlashCompleteListener(listener: () -> Unit) {
|
||||
onFlashComplete = listener
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
state.startFlashing()
|
||||
state.updateStep(context.getString(R.string.horizon_preparing))
|
||||
|
||||
filePath = "${context.filesDir.absolutePath}/${DocumentFile.fromSingleUri(context, uri!!)?.name}"
|
||||
binaryPath = "${context.filesDir.absolutePath}/META-INF/com/google/android/update-binary"
|
||||
workDir = "${context.filesDir.absolutePath}/work"
|
||||
|
||||
try {
|
||||
state.updateStep(context.getString(R.string.horizon_cleaning_files))
|
||||
state.updateProgress(0.1f)
|
||||
cleanup()
|
||||
|
||||
if (!rootAvailable()) {
|
||||
state.setError(context.getString(R.string.root_required))
|
||||
return
|
||||
}
|
||||
|
||||
state.updateStep(context.getString(R.string.horizon_copying_files))
|
||||
state.updateProgress(0.2f)
|
||||
copy()
|
||||
|
||||
if (!File(filePath).exists()) {
|
||||
state.setError(context.getString(R.string.horizon_copy_failed))
|
||||
return
|
||||
}
|
||||
|
||||
state.updateStep(context.getString(R.string.horizon_extracting_tool))
|
||||
state.updateProgress(0.4f)
|
||||
getBinary()
|
||||
|
||||
// KPM修补
|
||||
if (kpmPatchEnabled || kpmUndoPatch) {
|
||||
state.updateStep(context.getString(R.string.kpm_preparing_tools))
|
||||
state.updateProgress(0.5f)
|
||||
prepareKpmToolsWithDownload()
|
||||
|
||||
state.updateStep(
|
||||
if (kpmUndoPatch) context.getString(R.string.kpm_undoing_patch)
|
||||
else context.getString(R.string.kpm_applying_patch)
|
||||
)
|
||||
state.updateProgress(0.55f)
|
||||
performKpmPatch()
|
||||
}
|
||||
|
||||
state.updateStep(context.getString(R.string.horizon_patching_script))
|
||||
state.updateProgress(0.6f)
|
||||
patch()
|
||||
|
||||
state.updateStep(context.getString(R.string.horizon_flashing))
|
||||
state.updateProgress(0.7f)
|
||||
|
||||
val isAbDevice = isAbDevice()
|
||||
|
||||
if (isAbDevice && slot != null) {
|
||||
state.updateStep(context.getString(R.string.horizon_getting_original_slot))
|
||||
state.updateProgress(0.72f)
|
||||
originalSlot = runCommandGetOutput("getprop ro.boot.slot_suffix")
|
||||
|
||||
state.updateStep(context.getString(R.string.horizon_setting_target_slot))
|
||||
state.updateProgress(0.74f)
|
||||
runCommand(true, "resetprop -n ro.boot.slot_suffix _$slot")
|
||||
}
|
||||
|
||||
flash()
|
||||
|
||||
if (isAbDevice && !originalSlot.isNullOrEmpty()) {
|
||||
state.updateStep(context.getString(R.string.horizon_restoring_original_slot))
|
||||
state.updateProgress(0.8f)
|
||||
runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot")
|
||||
}
|
||||
|
||||
try {
|
||||
install()
|
||||
} catch (e: Exception) {
|
||||
state.updateStep("ksud update skipped: ${e.message}")
|
||||
}
|
||||
|
||||
state.updateStep(context.getString(R.string.horizon_flash_complete_status))
|
||||
state.completeFlashing()
|
||||
|
||||
(context as? Activity)?.runOnUiThread {
|
||||
onFlashComplete?.invoke()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state.setError(e.message ?: context.getString(R.string.horizon_unknown_error))
|
||||
|
||||
if (isAbDevice() && !originalSlot.isNullOrEmpty()) {
|
||||
state.updateStep(context.getString(R.string.horizon_restoring_original_slot))
|
||||
state.updateProgress(0.8f)
|
||||
runCommand(true, "resetprop ro.boot.slot_suffix $originalSlot")
|
||||
}
|
||||
} finally {
|
||||
// 取消下载任务并清理
|
||||
downloaderJob?.cancel()
|
||||
cleanupDownloader()
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareKpmToolsWithDownload() {
|
||||
try {
|
||||
File(workDir).mkdirs()
|
||||
val downloader = RemoteToolsDownloader(context, workDir)
|
||||
|
||||
val progressListener = object : RemoteToolsDownloader.DownloadProgressListener {
|
||||
override fun onProgress(fileName: String, progress: Int, total: Int) {
|
||||
val percentage = if (total > 0) (progress * 100) / total else 0
|
||||
state.addLog("Downloading $fileName: $percentage% ($progress/$total bytes)")
|
||||
}
|
||||
|
||||
override fun onLog(message: String) {
|
||||
state.addLog(message)
|
||||
}
|
||||
|
||||
override fun onError(fileName: String, error: String) {
|
||||
state.addLog("Warning: $fileName - $error")
|
||||
}
|
||||
|
||||
override fun onSuccess(fileName: String, isRemote: Boolean) {
|
||||
val source = if (isRemote) "remote" else "local"
|
||||
state.addLog("✓ $fileName $source version prepared successfully")
|
||||
}
|
||||
}
|
||||
|
||||
val downloadJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
downloader.downloadToolsAsync(progressListener)
|
||||
}
|
||||
|
||||
downloaderJob = downloadJob
|
||||
|
||||
runBlocking {
|
||||
downloadJob.join()
|
||||
}
|
||||
|
||||
val kptoolsPath = "$workDir/kptools"
|
||||
val kpimgPath = "$workDir/kpimg"
|
||||
|
||||
if (!File(kptoolsPath).exists()) {
|
||||
throw IOException("kptools file preparation failed")
|
||||
}
|
||||
|
||||
if (!File(kpimgPath).exists()) {
|
||||
throw IOException("kpimg file preparation failed")
|
||||
}
|
||||
|
||||
runCommand(true, "chmod a+rx $kptoolsPath")
|
||||
state.addLog("KPM tools preparation completed, starting patch operation")
|
||||
|
||||
} catch (_: CancellationException) {
|
||||
state.addLog("KPM tools download cancelled")
|
||||
throw IOException("Tool preparation process interrupted")
|
||||
} catch (e: Exception) {
|
||||
state.addLog("KPM tools preparation failed: ${e.message}")
|
||||
|
||||
state.addLog("Attempting to use legacy local file extraction...")
|
||||
try {
|
||||
prepareKpmToolsLegacy()
|
||||
state.addLog("Successfully used local backup files")
|
||||
} catch (legacyException: Exception) {
|
||||
state.addLog("Local file extraction also failed: ${legacyException.message}")
|
||||
throw IOException("Unable to prepare KPM tool files: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareKpmToolsLegacy() {
|
||||
File(workDir).mkdirs()
|
||||
|
||||
val kptoolsPath = "$workDir/kptools"
|
||||
val kpimgPath = "$workDir/kpimg"
|
||||
|
||||
AssetsUtil.exportFiles(context, "kptools", kptoolsPath)
|
||||
if (!File(kptoolsPath).exists()) {
|
||||
throw IOException("Local kptools file extraction failed")
|
||||
}
|
||||
|
||||
AssetsUtil.exportFiles(context, "kpimg", kpimgPath)
|
||||
if (!File(kpimgPath).exists()) {
|
||||
throw IOException("Local kpimg file extraction failed")
|
||||
}
|
||||
|
||||
runCommand(true, "chmod a+rx $kptoolsPath")
|
||||
}
|
||||
|
||||
private fun cleanupDownloader() {
|
||||
try {
|
||||
val downloader = RemoteToolsDownloader(context, workDir)
|
||||
downloader.cleanup()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行KPM修补操作
|
||||
*/
|
||||
private fun performKpmPatch() {
|
||||
try {
|
||||
// 创建临时解压目录
|
||||
val extractDir = "$workDir/extracted"
|
||||
File(extractDir).mkdirs()
|
||||
|
||||
// 解压压缩包到临时目录
|
||||
val unzipResult = runCommand(true, "cd $extractDir && unzip -o \"$filePath\"")
|
||||
if (unzipResult != 0) {
|
||||
throw IOException(context.getString(R.string.kpm_extract_zip_failed))
|
||||
}
|
||||
|
||||
// 查找Image文件
|
||||
val findImageResult = runCommandGetOutput("find $extractDir -name '*Image*' -type f")
|
||||
if (findImageResult.isBlank()) {
|
||||
throw IOException(context.getString(R.string.kpm_image_file_not_found))
|
||||
}
|
||||
|
||||
val imageFile = findImageResult.lines().first().trim()
|
||||
val imageDir = File(imageFile).parent
|
||||
val imageName = File(imageFile).name
|
||||
|
||||
state.addLog(context.getString(R.string.kpm_found_image_file, imageFile))
|
||||
|
||||
// 复制KPM工具到Image文件所在目录
|
||||
runCommand(true, "cp $workDir/kptools $imageDir/")
|
||||
runCommand(true, "cp $workDir/kpimg $imageDir/")
|
||||
|
||||
// 执行KPM修补命令
|
||||
val patchCommand = if (kpmUndoPatch) {
|
||||
"cd $imageDir && chmod a+rx kptools && ./kptools -u -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName"
|
||||
} else {
|
||||
"cd $imageDir && chmod a+rx kptools && ./kptools -p -s 123 -i $imageName -k kpimg -o oImage && mv oImage $imageName"
|
||||
}
|
||||
|
||||
val patchResult = runCommand(true, patchCommand)
|
||||
if (patchResult != 0) {
|
||||
throw IOException(
|
||||
if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_failed)
|
||||
else context.getString(R.string.kpm_patch_failed)
|
||||
)
|
||||
}
|
||||
|
||||
state.addLog(
|
||||
if (kpmUndoPatch) context.getString(R.string.kpm_undo_patch_success)
|
||||
else context.getString(R.string.kpm_patch_success)
|
||||
)
|
||||
|
||||
// 清理KPM工具文件
|
||||
runCommand(true, "rm -f $imageDir/kptools $imageDir/kpimg $imageDir/oImage")
|
||||
|
||||
// 重新打包ZIP文件
|
||||
val originalFileName = File(filePath).name
|
||||
val patchedFilePath = "$workDir/patched_$originalFileName"
|
||||
|
||||
repackZipFolder(extractDir, patchedFilePath)
|
||||
|
||||
// 替换原始文件
|
||||
runCommand(true, "mv \"$patchedFilePath\" \"$filePath\"")
|
||||
|
||||
state.addLog(context.getString(R.string.kpm_file_repacked))
|
||||
|
||||
} catch (e: Exception) {
|
||||
state.addLog(context.getString(R.string.kpm_patch_operation_failed, e.message))
|
||||
throw e
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
runCommand(true, "rm -rf $workDir")
|
||||
}
|
||||
}
|
||||
|
||||
private fun repackZipFolder(sourceDir: String, zipFilePath: String) {
|
||||
try {
|
||||
val buffer = ByteArray(1024)
|
||||
val sourceFolder = File(sourceDir)
|
||||
|
||||
FileOutputStream(zipFilePath).use { fos ->
|
||||
ZipOutputStream(fos).use { zos ->
|
||||
sourceFolder.walkTopDown().forEach { file ->
|
||||
if (file.isFile) {
|
||||
val relativePath = file.relativeTo(sourceFolder).path
|
||||
val zipEntry = ZipEntry(relativePath)
|
||||
zos.putNextEntry(zipEntry)
|
||||
|
||||
file.inputStream().use { fis ->
|
||||
var length: Int
|
||||
while (fis.read(buffer).also { length = it } > 0) {
|
||||
zos.write(buffer, 0, length)
|
||||
}
|
||||
}
|
||||
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IOException("Failed to create zip file: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查设备是否为AB分区设备
|
||||
private fun isAbDevice(): Boolean {
|
||||
val abUpdate = runCommandGetOutput("getprop ro.build.ab_update")
|
||||
if (!abUpdate.toBoolean()) return false
|
||||
|
||||
val slotSuffix = runCommandGetOutput("getprop ro.boot.slot_suffix")
|
||||
return slotSuffix.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
runCommand(false, "find ${context.filesDir.absolutePath} -type f ! -name '*.jpg' ! -name '*.png' -delete")
|
||||
runCommand(false, "rm -rf $workDir")
|
||||
}
|
||||
|
||||
private fun copy() {
|
||||
uri?.let { safeUri ->
|
||||
context.contentResolver.openInputStream(safeUri)?.use { input ->
|
||||
FileOutputStream(File(filePath)).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBinary() {
|
||||
runCommand(false, "unzip \"$filePath\" \"*/update-binary\" -d ${context.filesDir.absolutePath}")
|
||||
if (!File(binaryPath).exists()) {
|
||||
throw IOException("Failed to extract update-binary")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
private fun patch() {
|
||||
val kernelVersion = runCommandGetOutput("cat /proc/version")
|
||||
val versionRegex = """\d+\.\d+\.\d+""".toRegex()
|
||||
val version = kernelVersion.let { versionRegex.find(it) }?.value ?: ""
|
||||
val toolName = if (version.isNotEmpty()) {
|
||||
val parts = version.split('.')
|
||||
if (parts.size >= 2) {
|
||||
val major = parts[0].toIntOrNull() ?: 0
|
||||
val minor = parts[1].toIntOrNull() ?: 0
|
||||
if (major < 5 || (major == 5 && minor <= 10)) "5_10" else "5_15+"
|
||||
} else {
|
||||
"5_15+"
|
||||
}
|
||||
} else {
|
||||
"5_15+"
|
||||
}
|
||||
val toolPath = "${context.filesDir.absolutePath}/mkbootfs"
|
||||
AssetsUtil.exportFiles(context, "$toolName-mkbootfs", toolPath)
|
||||
state.addLog("${context.getString(R.string.kernel_version_log, version)} ${context.getString(R.string.tool_version_log, toolName)}")
|
||||
runCommand(false, "sed -i '/chmod -R 755 tools bin;/i cp -f $toolPath \$AKHOME/tools;' $binaryPath")
|
||||
}
|
||||
|
||||
private fun flash() {
|
||||
val process = ProcessBuilder("su")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
|
||||
try {
|
||||
process.outputStream.bufferedWriter().use { writer ->
|
||||
writer.write("export POSTINSTALL=${context.filesDir.absolutePath}\n")
|
||||
|
||||
// 写入槽位信息到临时文件
|
||||
slot?.let { selectedSlot ->
|
||||
writer.write("echo \"$selectedSlot\" > ${context.filesDir.absolutePath}/bootslot\n")
|
||||
}
|
||||
|
||||
// 构建刷写命令
|
||||
val flashCommand = buildString {
|
||||
append("sh $binaryPath 3 1 \"$filePath\"")
|
||||
if (slot != null) {
|
||||
append(" \"$(cat ${context.filesDir.absolutePath}/bootslot)\"")
|
||||
}
|
||||
append(" && touch ${context.filesDir.absolutePath}/done\n")
|
||||
}
|
||||
|
||||
writer.write(flashCommand)
|
||||
writer.write("exit\n")
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
process.inputStream.bufferedReader().use { reader ->
|
||||
reader.lineSequence().forEach { line ->
|
||||
if (line.startsWith("ui_print")) {
|
||||
val logMessage = line.removePrefix("ui_print").trim()
|
||||
state.addLog(logMessage)
|
||||
|
||||
when {
|
||||
logMessage.contains("extracting", ignoreCase = true) -> {
|
||||
state.updateProgress(0.75f)
|
||||
}
|
||||
logMessage.contains("installing", ignoreCase = true) -> {
|
||||
state.updateProgress(0.85f)
|
||||
}
|
||||
logMessage.contains("complete", ignoreCase = true) -> {
|
||||
state.updateProgress(0.95f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
process.destroy()
|
||||
}
|
||||
|
||||
if (!File("${context.filesDir.absolutePath}/done").exists()) {
|
||||
throw IOException(context.getString(R.string.flash_failed_message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun runCommand(su: Boolean, cmd: String): Int {
|
||||
val shell = if (su) "su" else "sh"
|
||||
val process = Runtime.getRuntime().exec(arrayOf(shell, "-c", cmd))
|
||||
|
||||
return try {
|
||||
process.waitFor()
|
||||
} finally {
|
||||
process.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runCommandGetOutput(cmd: String): String {
|
||||
return Shell.cmd(cmd).exec().out.joinToString("\n").trim()
|
||||
}
|
||||
}
|
||||
@@ -1,757 +0,0 @@
|
||||
package zako.zako.zako.zakoui.screen.moreSettings
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.theme.component.ImageEditorDialog
|
||||
import com.sukisu.ultra.ui.component.KsuIsValid
|
||||
import com.sukisu.ultra.ui.screen.SwitchItem
|
||||
import com.sukisu.ultra.ui.theme.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.component.ColorCircle
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.component.LanguageSelectionDialog
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.component.MoreSettingsDialogs
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.component.SettingItem
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.component.SettingsCard
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.component.SettingsDivider
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.component.SwitchSettingItem
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.component.UidScannerSection
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@SuppressLint("LocalContextConfigurationRead", "LocalContextResourcesRead", "ObsoleteSdkInt")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun MoreSettingsScreen(
|
||||
navigator: DestinationsNavigator
|
||||
) {
|
||||
// 顶部滚动行为
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
|
||||
val systemIsDark = isSystemInDarkTheme()
|
||||
|
||||
// 创建设置状态管理器
|
||||
val settingsState = remember { MoreSettingsState(context, prefs, systemIsDark) }
|
||||
val settingsHandlers = remember { MoreSettingsHandlers(context, prefs, settingsState) }
|
||||
|
||||
// 图片选择器
|
||||
val pickImageLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
settingsState.selectedImageUri = it
|
||||
settingsState.showImageEditor = true
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化设置
|
||||
LaunchedEffect(Unit) {
|
||||
settingsHandlers.initializeSettings()
|
||||
}
|
||||
|
||||
// 显示图片编辑对话框
|
||||
if (settingsState.showImageEditor && settingsState.selectedImageUri != null) {
|
||||
ImageEditorDialog(
|
||||
imageUri = settingsState.selectedImageUri!!,
|
||||
onDismiss = {
|
||||
settingsState.showImageEditor = false
|
||||
settingsState.selectedImageUri = null
|
||||
},
|
||||
onConfirm = { transformedUri ->
|
||||
settingsHandlers.handleCustomBackground(transformedUri)
|
||||
settingsState.showImageEditor = false
|
||||
settingsState.selectedImageUri = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 各种设置对话框
|
||||
MoreSettingsDialogs(
|
||||
state = settingsState,
|
||||
handlers = settingsHandlers
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.more_settings),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = CardConfig.cardAlpha),
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = CardConfig.cardAlpha)
|
||||
),
|
||||
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
},
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
// 外观设置
|
||||
AppearanceSettings(
|
||||
state = settingsState,
|
||||
handlers = settingsHandlers,
|
||||
pickImageLauncher = pickImageLauncher,
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
|
||||
// 自定义设置
|
||||
CustomizationSettings(
|
||||
state = settingsState,
|
||||
handlers = settingsHandlers
|
||||
)
|
||||
|
||||
// 高级设置
|
||||
KsuIsValid {
|
||||
AdvancedSettings(
|
||||
state = settingsState,
|
||||
handlers = settingsHandlers
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceSettings(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers,
|
||||
pickImageLauncher: ActivityResultLauncher<String>,
|
||||
coroutineScope: CoroutineScope
|
||||
) {
|
||||
SettingsCard(title = stringResource(R.string.appearance_settings)) {
|
||||
// 语言设置
|
||||
LanguageSetting(state = state)
|
||||
|
||||
// 主题模式
|
||||
SettingItem(
|
||||
icon = Icons.Default.DarkMode,
|
||||
title = stringResource(R.string.theme_mode),
|
||||
subtitle = state.themeOptions[state.themeMode],
|
||||
onClick = { state.showThemeModeDialog = true }
|
||||
)
|
||||
|
||||
// 动态颜色开关
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.ColorLens,
|
||||
title = stringResource(R.string.dynamic_color_title),
|
||||
summary = stringResource(R.string.dynamic_color_summary),
|
||||
checked = state.useDynamicColor,
|
||||
onChange = handlers::handleDynamicColorChange
|
||||
)
|
||||
}
|
||||
|
||||
// 主题色选择
|
||||
AnimatedVisibility(
|
||||
visible = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || !state.useDynamicColor,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
ThemeColorSelection(state = state)
|
||||
}
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// DPI 设置
|
||||
DpiSettings(state = state, handlers = handlers)
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// 自定义背景设置
|
||||
CustomBackgroundSettings(
|
||||
state = state,
|
||||
handlers = handlers,
|
||||
pickImageLauncher = pickImageLauncher,
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomizationSettings(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers
|
||||
) {
|
||||
SettingsCard(title = stringResource(R.string.custom_settings)) {
|
||||
// 图标切换
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Default.Android,
|
||||
title = stringResource(R.string.icon_switch_title),
|
||||
summary = stringResource(R.string.icon_switch_summary),
|
||||
checked = state.useAltIcon,
|
||||
onChange = handlers::handleIconChange
|
||||
)
|
||||
|
||||
// 显示更多模块信息
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.Info,
|
||||
title = stringResource(R.string.show_more_module_info),
|
||||
summary = stringResource(R.string.show_more_module_info_summary),
|
||||
checked = state.showMoreModuleInfo,
|
||||
onChange = handlers::handleShowMoreModuleInfoChange
|
||||
)
|
||||
|
||||
// 简洁模式开关
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.Brush,
|
||||
title = stringResource(R.string.simple_mode),
|
||||
summary = stringResource(R.string.simple_mode_summary),
|
||||
checked = state.isSimpleMode,
|
||||
onChange = handlers::handleSimpleModeChange
|
||||
)
|
||||
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.Brush,
|
||||
title = stringResource(R.string.kernel_simple_kernel),
|
||||
summary = stringResource(R.string.kernel_simple_kernel_summary),
|
||||
checked = state.isKernelSimpleMode,
|
||||
onChange = handlers::handleKernelSimpleModeChange
|
||||
)
|
||||
|
||||
// 各种隐藏选项
|
||||
HideOptionsSettings(state = state, handlers = handlers)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HideOptionsSettings(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers
|
||||
) {
|
||||
// 隐藏内核版本号
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.hide_kernel_kernelsu_version),
|
||||
summary = stringResource(R.string.hide_kernel_kernelsu_version_summary),
|
||||
checked = state.isHideVersion,
|
||||
onChange = handlers::handleHideVersionChange
|
||||
)
|
||||
|
||||
// 隐藏模块数量等信息
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.hide_other_info),
|
||||
summary = stringResource(R.string.hide_other_info_summary),
|
||||
checked = state.isHideOtherInfo,
|
||||
onChange = handlers::handleHideOtherInfoChange
|
||||
)
|
||||
|
||||
// SuSFS 状态信息
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.hide_susfs_status),
|
||||
summary = stringResource(R.string.hide_susfs_status_summary),
|
||||
checked = state.isHideSusfsStatus,
|
||||
onChange = handlers::handleHideSusfsStatusChange
|
||||
)
|
||||
|
||||
// Zygisk 实现状态信息
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.hide_zygisk_implement),
|
||||
summary = stringResource(R.string.hide_zygisk_implement_summary),
|
||||
checked = state.isHideZygiskImplement,
|
||||
onChange = handlers::handleHideZygiskImplementChange
|
||||
)
|
||||
|
||||
if (Natives.version >= Natives.MINIMAL_SUPPORTED_KPM) {
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.show_kpm_info),
|
||||
summary = stringResource(R.string.show_kpm_info_summary),
|
||||
checked = state.isShowKpmInfo,
|
||||
onChange = handlers::handleShowKpmInfoChange
|
||||
)
|
||||
}
|
||||
|
||||
// 隐藏链接信息
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.hide_link_card),
|
||||
summary = stringResource(R.string.hide_link_card_summary),
|
||||
checked = state.isHideLinkCard,
|
||||
onChange = handlers::handleHideLinkCardChange
|
||||
)
|
||||
|
||||
// 隐藏标签行
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
title = stringResource(R.string.hide_tag_card),
|
||||
summary = stringResource(R.string.hide_tag_card_summary),
|
||||
checked = state.isHideTagRow,
|
||||
onChange = handlers::handleHideTagRowChange
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdvancedSettings(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackBarHost = remember { SnackbarHostState() }
|
||||
val prefs = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
|
||||
|
||||
SettingsCard(title = stringResource(R.string.advanced_settings)) {
|
||||
// SELinux 开关
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.selinux),
|
||||
summary = if (state.selinuxEnabled)
|
||||
stringResource(R.string.selinux_enabled) else
|
||||
stringResource(R.string.selinux_disabled),
|
||||
checked = state.selinuxEnabled,
|
||||
onChange = handlers::handleSelinuxChange
|
||||
)
|
||||
|
||||
var forceSignatureVerification by rememberSaveable {
|
||||
mutableStateOf(prefs.getBoolean("force_signature_verification", false))
|
||||
}
|
||||
|
||||
// 强制签名验证开关
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.module_signature_verification),
|
||||
summary = stringResource(R.string.module_signature_verification_summary),
|
||||
checked = forceSignatureVerification,
|
||||
onCheckedChange = { enabled ->
|
||||
prefs.edit { putBoolean("force_signature_verification", enabled) }
|
||||
forceSignatureVerification = enabled
|
||||
}
|
||||
)
|
||||
|
||||
// UID 扫描开关
|
||||
if (Natives.version >= Natives.MINIMAL_SUPPORTED_UID_SCANNER && Natives.version >= Natives.MINIMAL_NEW_IOCTL_KERNEL) {
|
||||
UidScannerSection(prefs, snackBarHost, scope, context)
|
||||
}
|
||||
|
||||
// 动态管理器设置
|
||||
if (Natives.version >= Natives.MINIMAL_SUPPORTED_DYNAMIC_MANAGER && Natives.version >= Natives.MINIMAL_NEW_IOCTL_KERNEL) {
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.dynamic_manager_title),
|
||||
subtitle = if (state.isDynamicSignEnabled) {
|
||||
stringResource(R.string.dynamic_manager_enabled_summary, state.dynamicSignSize)
|
||||
} else {
|
||||
stringResource(R.string.dynamic_manager_disabled)
|
||||
},
|
||||
onClick = { state.showDynamicSignDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeColorSelection(state: MoreSettingsState) {
|
||||
SettingItem(
|
||||
icon = Icons.Default.Palette,
|
||||
title = stringResource(R.string.theme_color),
|
||||
subtitle = when (ThemeConfig.currentTheme) {
|
||||
is ThemeColors.Green -> stringResource(R.string.color_green)
|
||||
is ThemeColors.Purple -> stringResource(R.string.color_purple)
|
||||
is ThemeColors.Orange -> stringResource(R.string.color_orange)
|
||||
is ThemeColors.Pink -> stringResource(R.string.color_pink)
|
||||
is ThemeColors.Gray -> stringResource(R.string.color_gray)
|
||||
is ThemeColors.Yellow -> stringResource(R.string.color_yellow)
|
||||
else -> stringResource(R.string.color_default)
|
||||
},
|
||||
onClick = { state.showThemeColorDialog = true },
|
||||
trailingContent = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
) {
|
||||
val theme = ThemeConfig.currentTheme
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
ColorCircle(
|
||||
color = if (isDark) theme.primaryDark else theme.primaryLight,
|
||||
isSelected = false,
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
)
|
||||
ColorCircle(
|
||||
color = if (isDark) theme.secondaryDark else theme.secondaryLight,
|
||||
isSelected = false,
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
)
|
||||
ColorCircle(
|
||||
color = if (isDark) theme.tertiaryDark else theme.tertiaryLight,
|
||||
isSelected = false,
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DpiSettings(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers
|
||||
) {
|
||||
SettingItem(
|
||||
icon = Icons.Default.FormatSize,
|
||||
title = stringResource(R.string.app_dpi_title),
|
||||
subtitle = stringResource(R.string.app_dpi_summary),
|
||||
onClick = {},
|
||||
trailingContent = {
|
||||
Text(
|
||||
text = handlers.getDpiFriendlyName(state.tempDpi),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// DPI 滑动条和控制
|
||||
DpiSliderControls(state = state, handlers = handlers)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DpiSliderControls(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
val sliderValue by animateFloatAsState(
|
||||
targetValue = state.tempDpi.toFloat(),
|
||||
label = "DPI Slider Animation"
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { newValue ->
|
||||
state.tempDpi = newValue.toInt()
|
||||
state.isDpiCustom = !state.dpiPresets.containsValue(state.tempDpi)
|
||||
},
|
||||
valueRange = 160f..600f,
|
||||
steps = 11,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.primary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
|
||||
// DPI 预设按钮行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
) {
|
||||
state.dpiPresets.forEach { (name, dpi) ->
|
||||
val isSelected = state.tempDpi == dpi
|
||||
val buttonColor = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 2.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(buttonColor)
|
||||
.clickable {
|
||||
state.tempDpi = dpi
|
||||
state.isDpiCustom = false
|
||||
}
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isSelected)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (state.isDpiCustom)
|
||||
"${stringResource(R.string.dpi_size_custom)}: ${state.tempDpi}"
|
||||
else
|
||||
"${handlers.getDpiFriendlyName(state.tempDpi)}: ${state.tempDpi}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { state.showDpiConfirmDialog = true },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
enabled = state.tempDpi != state.currentDpi
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.dpi_apply_settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CustomBackgroundSettings(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers,
|
||||
pickImageLauncher: ActivityResultLauncher<String>,
|
||||
coroutineScope: CoroutineScope
|
||||
) {
|
||||
// 自定义背景开关
|
||||
SwitchSettingItem(
|
||||
icon = Icons.Filled.Wallpaper,
|
||||
title = stringResource(id = R.string.settings_custom_background),
|
||||
summary = stringResource(id = R.string.settings_custom_background_summary),
|
||||
checked = state.isCustomBackgroundEnabled,
|
||||
onChange = { isChecked ->
|
||||
if (isChecked) {
|
||||
pickImageLauncher.launch("image/*")
|
||||
} else {
|
||||
handlers.handleRemoveCustomBackground()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 透明度和亮度调节
|
||||
AnimatedVisibility(
|
||||
visible = ThemeConfig.customBackgroundUri != null,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically()
|
||||
) {
|
||||
BackgroundAdjustmentControls(
|
||||
state = state,
|
||||
handlers = handlers,
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundAdjustmentControls(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers,
|
||||
coroutineScope: CoroutineScope
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
// 透明度滑动条
|
||||
AlphaSlider(state = state, handlers = handlers, coroutineScope = coroutineScope)
|
||||
|
||||
// 亮度调节滑动条
|
||||
DimSlider(state = state, handlers = handlers, coroutineScope = coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlphaSlider(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers,
|
||||
coroutineScope: CoroutineScope
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Opacity,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_card_alpha),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "${(state.cardAlpha * 100).roundToInt()}%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
|
||||
val alphaSliderValue by animateFloatAsState(
|
||||
targetValue = state.cardAlpha,
|
||||
label = "Alpha Slider Animation"
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = alphaSliderValue,
|
||||
onValueChange = { newValue ->
|
||||
handlers.handleCardAlphaChange(newValue)
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
saveCardConfig(handlers.context)
|
||||
}
|
||||
},
|
||||
valueRange = 0f..1f,
|
||||
steps = 20,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.primary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DimSlider(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers,
|
||||
coroutineScope: CoroutineScope
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.LightMode,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_card_dim),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "${(state.cardDim * 100).roundToInt()}%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
|
||||
val dimSliderValue by animateFloatAsState(
|
||||
targetValue = state.cardDim,
|
||||
label = "Dim Slider Animation"
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = dimSliderValue,
|
||||
onValueChange = { newValue ->
|
||||
handlers.handleCardDimChange(newValue)
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
saveCardConfig(handlers.context)
|
||||
}
|
||||
},
|
||||
valueRange = 0f..1f,
|
||||
steps = 20,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.primary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun saveCardConfig(context: Context) {
|
||||
CardConfig.save(context)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageSetting(state: MoreSettingsState) {
|
||||
val context = LocalContext.current
|
||||
val language = stringResource(id = R.string.settings_language)
|
||||
|
||||
// Compute display name based on current app locale
|
||||
val currentLanguageDisplay = remember(state.currentAppLocale) {
|
||||
val locale = state.currentAppLocale
|
||||
if (locale != null) {
|
||||
locale.getDisplayName(locale)
|
||||
} else {
|
||||
context.getString(R.string.language_system_default)
|
||||
}
|
||||
}
|
||||
|
||||
SettingItem(
|
||||
icon = Icons.Filled.Translate,
|
||||
title = language,
|
||||
subtitle = currentLanguageDisplay,
|
||||
onClick = { state.showLanguageDialog = true }
|
||||
)
|
||||
|
||||
// Language Selection Dialog
|
||||
if (state.showLanguageDialog) {
|
||||
LanguageSelectionDialog(
|
||||
onLanguageSelected = { newLocale ->
|
||||
// Update local state immediately
|
||||
state.currentAppLocale = LocaleHelper.getCurrentAppLocale(context)
|
||||
// Apply locale change immediately for Android < 13
|
||||
LocaleHelper.restartActivity(context)
|
||||
},
|
||||
onDismiss = { state.showLanguageDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
package zako.zako.zako.zakoui.screen.moreSettings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CleaningServices
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Scanner
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import com.sukisu.ultra.Natives
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.ConfirmResult
|
||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
||||
import com.sukisu.ultra.ui.screen.SettingItem
|
||||
import com.sukisu.ultra.ui.screen.SwitchItem
|
||||
import com.sukisu.ultra.ui.theme.*
|
||||
import com.sukisu.ultra.ui.util.*
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.util.toggleLauncherIcon
|
||||
|
||||
/**
|
||||
* 更多设置处理器
|
||||
*/
|
||||
class MoreSettingsHandlers(
|
||||
val context: Context,
|
||||
private val prefs: SharedPreferences,
|
||||
private val state: MoreSettingsState
|
||||
) {
|
||||
|
||||
/**
|
||||
* 初始化设置
|
||||
*/
|
||||
fun initializeSettings() {
|
||||
// 加载设置
|
||||
CardConfig.load(context)
|
||||
state.cardAlpha = CardConfig.cardAlpha
|
||||
state.cardDim = CardConfig.cardDim
|
||||
state.isCustomBackgroundEnabled = ThemeConfig.customBackgroundUri != null
|
||||
|
||||
// 设置主题模式
|
||||
state.themeMode = when (ThemeConfig.forceDarkMode) {
|
||||
true -> 2
|
||||
false -> 1
|
||||
null -> 0
|
||||
}
|
||||
|
||||
// 确保卡片样式跟随主题模式
|
||||
when (state.themeMode) {
|
||||
2 -> { // 深色
|
||||
CardConfig.isUserDarkModeEnabled = true
|
||||
CardConfig.isUserLightModeEnabled = false
|
||||
}
|
||||
1 -> { // 浅色
|
||||
CardConfig.isUserDarkModeEnabled = false
|
||||
CardConfig.isUserLightModeEnabled = true
|
||||
}
|
||||
0 -> { // 跟随系统
|
||||
CardConfig.isUserDarkModeEnabled = false
|
||||
CardConfig.isUserLightModeEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用了系统跟随且系统是深色模式,应用深色模式默认值
|
||||
if (state.themeMode == 0 && state.systemIsDark) {
|
||||
CardConfig.setThemeDefaults(true)
|
||||
}
|
||||
|
||||
state.currentDpi = prefs.getInt("app_dpi", state.systemDpi)
|
||||
state.tempDpi = state.currentDpi
|
||||
|
||||
CardConfig.save(context)
|
||||
|
||||
// 初始化 SELinux 状态
|
||||
state.selinuxEnabled = Shell.cmd("getenforce").exec().out.firstOrNull() == "Enforcing"
|
||||
|
||||
// 初始化动态管理器配置
|
||||
state.dynamicSignConfig = Natives.getDynamicManager()
|
||||
state.dynamicSignConfig?.let { config ->
|
||||
if (config.isValid()) {
|
||||
state.isDynamicSignEnabled = true
|
||||
state.dynamicSignSize = config.size.toString()
|
||||
state.dynamicSignHash = config.hash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理主题模式变更
|
||||
*/
|
||||
fun handleThemeModeChange(index: Int) {
|
||||
state.themeMode = index
|
||||
val newThemeMode = when (index) {
|
||||
0 -> null // 跟随系统
|
||||
1 -> false // 浅色
|
||||
2 -> true // 深色
|
||||
else -> null
|
||||
}
|
||||
context.saveThemeMode(newThemeMode)
|
||||
ThemeConfig.updateTheme(darkMode = newThemeMode)
|
||||
|
||||
when (index) {
|
||||
2 -> { // 深色
|
||||
ThemeConfig.updateTheme(darkMode = true)
|
||||
CardConfig.updateThemePreference(darkMode = true, lightMode = false)
|
||||
CardConfig.setThemeDefaults(true)
|
||||
CardConfig.save(context)
|
||||
}
|
||||
1 -> { // 浅色
|
||||
ThemeConfig.updateTheme(darkMode = false)
|
||||
CardConfig.updateThemePreference(darkMode = false, lightMode = true)
|
||||
CardConfig.setThemeDefaults(false)
|
||||
CardConfig.save(context)
|
||||
}
|
||||
0 -> { // 跟随系统
|
||||
ThemeConfig.updateTheme(darkMode = null)
|
||||
CardConfig.updateThemePreference(darkMode = null, lightMode = null)
|
||||
val isNightModeActive = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
CardConfig.setThemeDefaults(isNightModeActive)
|
||||
CardConfig.save(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理主题色变更
|
||||
*/
|
||||
fun handleThemeColorChange(theme: ThemeColors) {
|
||||
context.saveThemeColors(when (theme) {
|
||||
ThemeColors.Green -> "green"
|
||||
ThemeColors.Purple -> "purple"
|
||||
ThemeColors.Orange -> "orange"
|
||||
ThemeColors.Pink -> "pink"
|
||||
ThemeColors.Gray -> "gray"
|
||||
ThemeColors.Yellow -> "yellow"
|
||||
else -> "default"
|
||||
})
|
||||
ThemeConfig.updateTheme(theme = theme)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理动态颜色变更
|
||||
*/
|
||||
fun handleDynamicColorChange(enabled: Boolean) {
|
||||
state.useDynamicColor = enabled
|
||||
context.saveDynamicColorState(enabled)
|
||||
ThemeConfig.updateTheme(dynamicColor = enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取DPI大小友好名称
|
||||
*/
|
||||
@Composable
|
||||
fun getDpiFriendlyName(dpi: Int): String {
|
||||
return when (dpi) {
|
||||
240 -> stringResource(R.string.dpi_size_small)
|
||||
320 -> stringResource(R.string.dpi_size_medium)
|
||||
420 -> stringResource(R.string.dpi_size_large)
|
||||
560 -> stringResource(R.string.dpi_size_extra_large)
|
||||
else -> stringResource(R.string.dpi_size_custom)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用 DPI 设置
|
||||
*/
|
||||
fun handleDpiApply() {
|
||||
if (state.tempDpi != state.currentDpi) {
|
||||
prefs.edit {
|
||||
putInt("app_dpi", state.tempDpi)
|
||||
}
|
||||
|
||||
state.currentDpi = state.tempDpi
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dpi_applied_success, state.tempDpi),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
|
||||
val restartIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
restartIntent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(restartIntent)
|
||||
|
||||
state.showDpiConfirmDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自定义背景
|
||||
*/
|
||||
fun handleCustomBackground(transformedUri: Uri) {
|
||||
context.saveAndApplyCustomBackground(transformedUri)
|
||||
state.isCustomBackgroundEnabled = true
|
||||
CardConfig.cardElevation = 0.dp
|
||||
CardConfig.isCustomBackgroundEnabled = true
|
||||
saveCardConfig(context)
|
||||
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.background_set_success),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理移除自定义背景
|
||||
*/
|
||||
fun handleRemoveCustomBackground() {
|
||||
context.saveCustomBackground(null)
|
||||
state.isCustomBackgroundEnabled = false
|
||||
CardConfig.cardAlpha = 1f
|
||||
CardConfig.cardDim = 0f
|
||||
CardConfig.isCustomAlphaSet = false
|
||||
CardConfig.isCustomDimSet = false
|
||||
CardConfig.isCustomBackgroundEnabled = false
|
||||
saveCardConfig(context)
|
||||
ThemeConfig.preventBackgroundRefresh = false
|
||||
|
||||
context.getSharedPreferences("theme_prefs", Context.MODE_PRIVATE).edit {
|
||||
putBoolean("prevent_background_refresh", false)
|
||||
}
|
||||
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.background_removed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理卡片透明度变更
|
||||
*/
|
||||
fun handleCardAlphaChange(newValue: Float) {
|
||||
state.cardAlpha = newValue
|
||||
CardConfig.cardAlpha = newValue
|
||||
CardConfig.isCustomAlphaSet = true
|
||||
prefs.edit {
|
||||
putBoolean("is_custom_alpha_set", true)
|
||||
putFloat("card_alpha", newValue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理卡片亮度变更
|
||||
*/
|
||||
fun handleCardDimChange(newValue: Float) {
|
||||
state.cardDim = newValue
|
||||
CardConfig.cardDim = newValue
|
||||
CardConfig.isCustomDimSet = true
|
||||
prefs.edit {
|
||||
putBoolean("is_custom_dim_set", true)
|
||||
putFloat("card_dim", newValue)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图标变更
|
||||
*/
|
||||
fun handleIconChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("use_alt_icon", newValue) }
|
||||
state.useAltIcon = newValue
|
||||
toggleLauncherIcon(context, newValue)
|
||||
Toast.makeText(context, context.getString(R.string.icon_switched), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理简洁模式变更
|
||||
*/
|
||||
fun handleSimpleModeChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("is_simple_mode", newValue) }
|
||||
state.isSimpleMode = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理内核简洁模式变更
|
||||
*/
|
||||
fun handleKernelSimpleModeChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("is_kernel_simple_mode", newValue) }
|
||||
state.isKernelSimpleMode = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理隐藏版本变更
|
||||
*/
|
||||
fun handleHideVersionChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("is_hide_version", newValue) }
|
||||
state.isHideVersion = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理隐藏其他信息变更
|
||||
*/
|
||||
fun handleHideOtherInfoChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("is_hide_other_info", newValue) }
|
||||
state.isHideOtherInfo = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理显示KPM信息变更
|
||||
*/
|
||||
fun handleShowKpmInfoChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("show_kpm_info", newValue) }
|
||||
state.isShowKpmInfo = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理隐藏SuSFS状态变更
|
||||
*/
|
||||
fun handleHideSusfsStatusChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("is_hide_susfs_status", newValue) }
|
||||
state.isHideSusfsStatus = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理隐藏Zygisk实现变更
|
||||
*/
|
||||
fun handleHideZygiskImplementChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("is_hide_zygisk_Implement", newValue) }
|
||||
state.isHideZygiskImplement = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理隐藏链接卡片变更
|
||||
*/
|
||||
fun handleHideLinkCardChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("is_hide_link_card", newValue) }
|
||||
state.isHideLinkCard = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理隐藏标签行变更
|
||||
*/
|
||||
fun handleHideTagRowChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("is_hide_tag_row", newValue) }
|
||||
state.isHideTagRow = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理显示更多模块信息变更
|
||||
*/
|
||||
fun handleShowMoreModuleInfoChange(newValue: Boolean) {
|
||||
prefs.edit { putBoolean("show_more_module_info", newValue) }
|
||||
state.showMoreModuleInfo = newValue
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理SELinux变更
|
||||
*/
|
||||
fun handleSelinuxChange(enabled: Boolean) {
|
||||
val command = if (enabled) "setenforce 1" else "setenforce 0"
|
||||
Shell.getShell().newJob().add(command).exec().let { result ->
|
||||
if (result.isSuccess) {
|
||||
state.selinuxEnabled = enabled
|
||||
val message = if (enabled)
|
||||
context.getString(R.string.selinux_enabled_toast)
|
||||
else
|
||||
context.getString(R.string.selinux_disabled_toast)
|
||||
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.selinux_change_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理动态管理器配置
|
||||
*/
|
||||
fun handleDynamicManagerConfig(enabled: Boolean, size: String, hash: String) {
|
||||
if (enabled) {
|
||||
val parsedSize = parseDynamicSignSize(size)
|
||||
if (parsedSize != null && parsedSize > 0 && hash.length == 64) {
|
||||
val success = Natives.setDynamicManager(parsedSize, hash)
|
||||
if (success) {
|
||||
state.dynamicSignConfig = Natives.DynamicManagerConfig(parsedSize, hash)
|
||||
state.isDynamicSignEnabled = true
|
||||
state.dynamicSignSize = size
|
||||
state.dynamicSignHash = hash
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dynamic_manager_set_success),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dynamic_manager_set_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.invalid_sign_config),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
val success = Natives.clearDynamicManager()
|
||||
if (success) {
|
||||
state.dynamicSignConfig = null
|
||||
state.isDynamicSignEnabled = false
|
||||
state.dynamicSignSize = ""
|
||||
state.dynamicSignHash = ""
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dynamic_manager_disabled_success),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dynamic_manager_clear_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析动态签名大小
|
||||
*/
|
||||
private fun parseDynamicSignSize(input: String): Int? {
|
||||
return try {
|
||||
when {
|
||||
input.startsWith("0x", true) -> input.substring(2).toInt(16)
|
||||
else -> input.toInt()
|
||||
}
|
||||
} catch (_: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package zako.zako.zako.zakoui.screen.moreSettings.component
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.sukisu.ultra.ui.theme.*
|
||||
|
||||
private val SETTINGS_GROUP_SPACING = 16.dp
|
||||
|
||||
@Composable
|
||||
fun SettingsCard(
|
||||
title: String,
|
||||
icon: ImageVector? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = SETTINGS_GROUP_SPACING),
|
||||
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
elevation = getCardElevation(),
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
onClick: () -> Unit,
|
||||
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||
trailingContent: @Composable (() -> Unit)? = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.NavigateNext,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
trailingContent?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwitchSettingItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
checked: Boolean,
|
||||
onChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChange(!checked) }
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
if (summary != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsDivider() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorCircle(
|
||||
color: Color,
|
||||
isSelected: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,620 +0,0 @@
|
||||
package zako.zako.zako.zakoui.screen.moreSettings.component
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CleaningServices
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Scanner
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import com.maxkeppeler.sheets.list.ListDialog
|
||||
import com.maxkeppeler.sheets.list.models.ListOption
|
||||
import com.maxkeppeler.sheets.list.models.ListSelection
|
||||
import com.sukisu.ultra.Natives
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.util.LocaleHelper
|
||||
import com.sukisu.ultra.R
|
||||
import com.sukisu.ultra.ui.component.ConfirmResult
|
||||
import com.sukisu.ultra.ui.component.rememberConfirmDialog
|
||||
import com.sukisu.ultra.ui.screen.SwitchItem
|
||||
import com.sukisu.ultra.ui.theme.*
|
||||
import com.sukisu.ultra.ui.util.cleanRuntimeEnvironment
|
||||
import com.sukisu.ultra.ui.util.getUidMultiUserScan
|
||||
import com.sukisu.ultra.ui.util.readUidScannerFile
|
||||
import com.sukisu.ultra.ui.util.setUidAutoScan
|
||||
import com.sukisu.ultra.ui.util.setUidMultiUserScan
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.MoreSettingsHandlers
|
||||
import zako.zako.zako.zakoui.screen.moreSettings.state.MoreSettingsState
|
||||
|
||||
@Composable
|
||||
fun MoreSettingsDialogs(
|
||||
state: MoreSettingsState,
|
||||
handlers: MoreSettingsHandlers
|
||||
) {
|
||||
// 主题模式选择对话框
|
||||
if (state.showThemeModeDialog) {
|
||||
SingleChoiceDialog(
|
||||
title = stringResource(R.string.theme_mode),
|
||||
options = state.themeOptions,
|
||||
selectedIndex = state.themeMode,
|
||||
onOptionSelected = { index ->
|
||||
handlers.handleThemeModeChange(index)
|
||||
},
|
||||
onDismiss = { state.showThemeModeDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
// DPI 设置确认对话框
|
||||
if (state.showDpiConfirmDialog) {
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.dpi_confirm_title),
|
||||
message = stringResource(R.string.dpi_confirm_message, state.currentDpi, state.tempDpi),
|
||||
summaryText = stringResource(R.string.dpi_confirm_summary),
|
||||
confirmText = stringResource(R.string.confirm),
|
||||
dismissText = stringResource(R.string.cancel),
|
||||
onConfirm = { handlers.handleDpiApply() },
|
||||
onDismiss = {
|
||||
state.showDpiConfirmDialog = false
|
||||
state.tempDpi = state.currentDpi
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 主题色选择对话框
|
||||
if (state.showThemeColorDialog) {
|
||||
ThemeColorDialog(
|
||||
onColorSelected = { theme ->
|
||||
handlers.handleThemeColorChange(theme)
|
||||
state.showThemeColorDialog = false
|
||||
},
|
||||
onDismiss = { state.showThemeColorDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
// 动态管理器配置对话框
|
||||
if (state.showDynamicSignDialog) {
|
||||
DynamicManagerDialog(
|
||||
state = state,
|
||||
onConfirm = { enabled, size, hash ->
|
||||
handlers.handleDynamicManagerConfig(enabled, size, hash)
|
||||
state.showDynamicSignDialog = false
|
||||
},
|
||||
onDismiss = { state.showDynamicSignDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SingleChoiceDialog(
|
||||
title: String,
|
||||
options: List<String>,
|
||||
selectedIndex: Int,
|
||||
onOptionSelected: (Int) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
options.forEachIndexed { index, option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onOptionSelected(index)
|
||||
onDismiss()
|
||||
}
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedIndex == index,
|
||||
onClick = null
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
summaryText: String? = null,
|
||||
confirmText: String = stringResource(R.string.confirm),
|
||||
dismissText: String = stringResource(R.string.cancel),
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
Text(message)
|
||||
if (summaryText != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
summaryText,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(confirmText)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(dismissText)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LanguageSelectionDialog(
|
||||
onLanguageSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
// Check if should use system language settings
|
||||
if (LocaleHelper.useSystemLanguageSettings) {
|
||||
// Android 13+ - Jump to system settings
|
||||
LocaleHelper.launchSystemLanguageSettings(context)
|
||||
onDismiss()
|
||||
} else {
|
||||
// Android < 13 - Show app language selector
|
||||
// Dynamically detect supported locales from resources
|
||||
val supportedLocales = remember {
|
||||
val locales = mutableListOf<java.util.Locale>()
|
||||
|
||||
// Add system default first
|
||||
locales.add(java.util.Locale.ROOT) // This will represent "System Default"
|
||||
|
||||
// Dynamically detect available locales by checking resource directories
|
||||
val resourceDirs = listOf(
|
||||
"ar", "bg", "de", "fa", "fr", "hu", "in", "it",
|
||||
"ja", "ko", "pl", "pt-rBR", "ru", "th", "tr",
|
||||
"uk", "vi", "zh-rCN", "zh-rTW"
|
||||
)
|
||||
|
||||
resourceDirs.forEach { dir ->
|
||||
try {
|
||||
val locale = when {
|
||||
dir.contains("-r") -> {
|
||||
val parts = dir.split("-r")
|
||||
java.util.Locale.Builder()
|
||||
.setLanguage(parts[0])
|
||||
.setRegion(parts[1])
|
||||
.build()
|
||||
}
|
||||
else -> java.util.Locale.Builder()
|
||||
.setLanguage(dir)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Test if this locale has translated resources
|
||||
val config = android.content.res.Configuration()
|
||||
config.setLocale(locale)
|
||||
val localizedContext = context.createConfigurationContext(config)
|
||||
|
||||
// Try to get a translated string to verify the locale is supported
|
||||
val testString = localizedContext.getString(R.string.settings_language)
|
||||
val defaultString = context.getString(R.string.settings_language)
|
||||
|
||||
// If the string is different or it's English, it's supported
|
||||
if (testString != defaultString || locale.language == "en") {
|
||||
locales.add(locale)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Skip unsupported locales
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by display name
|
||||
val sortedLocales = locales.drop(1).sortedBy { it.getDisplayName(it) }
|
||||
mutableListOf<java.util.Locale>().apply {
|
||||
add(locales.first()) // System default first
|
||||
addAll(sortedLocales)
|
||||
}
|
||||
}
|
||||
|
||||
val allOptions = supportedLocales.map { locale ->
|
||||
val tag = if (locale == java.util.Locale.ROOT) {
|
||||
"system"
|
||||
} else if (locale.country.isEmpty()) {
|
||||
locale.language
|
||||
} else {
|
||||
"${locale.language}_${locale.country}"
|
||||
}
|
||||
|
||||
val displayName = if (locale == java.util.Locale.ROOT) {
|
||||
context.getString(R.string.language_system_default)
|
||||
} else {
|
||||
locale.getDisplayName(locale)
|
||||
}
|
||||
|
||||
tag to displayName
|
||||
}
|
||||
|
||||
val currentLocale = prefs.getString("app_locale", "system") ?: "system"
|
||||
val options = allOptions.map { (tag, displayName) ->
|
||||
ListOption(
|
||||
titleText = displayName,
|
||||
selected = currentLocale == tag
|
||||
)
|
||||
}
|
||||
|
||||
var selectedIndex by remember {
|
||||
mutableIntStateOf(allOptions.indexOfFirst { (tag, _) -> currentLocale == tag })
|
||||
}
|
||||
|
||||
ListDialog(
|
||||
state = rememberUseCaseState(
|
||||
visible = true,
|
||||
onFinishedRequest = {
|
||||
if (selectedIndex >= 0 && selectedIndex < allOptions.size) {
|
||||
val newLocale = allOptions[selectedIndex].first
|
||||
prefs.edit { putString("app_locale", newLocale) }
|
||||
onLanguageSelected(newLocale)
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
onCloseRequest = {
|
||||
onDismiss()
|
||||
}
|
||||
),
|
||||
header = Header.Default(
|
||||
title = stringResource(R.string.settings_language),
|
||||
),
|
||||
selection = ListSelection.Single(
|
||||
showRadioButtons = true,
|
||||
options = options
|
||||
) { index, _ ->
|
||||
selectedIndex = index
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun ThemeColorDialog(
|
||||
onColorSelected: (ThemeColors) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val themeColorOptions = listOf(
|
||||
stringResource(R.string.color_default) to ThemeColors.Default,
|
||||
stringResource(R.string.color_green) to ThemeColors.Green,
|
||||
stringResource(R.string.color_purple) to ThemeColors.Purple,
|
||||
stringResource(R.string.color_orange) to ThemeColors.Orange,
|
||||
stringResource(R.string.color_pink) to ThemeColors.Pink,
|
||||
stringResource(R.string.color_gray) to ThemeColors.Gray,
|
||||
stringResource(R.string.color_yellow) to ThemeColors.Yellow
|
||||
)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.choose_theme_color)) },
|
||||
text = {
|
||||
Column {
|
||||
themeColorOptions.forEach { (name, theme) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onColorSelected(theme) }
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier.padding(end = 12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ColorCircle(
|
||||
color = if (isDark) theme.primaryDark else theme.primaryLight,
|
||||
isSelected = false,
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
)
|
||||
ColorCircle(
|
||||
color = if (isDark) theme.secondaryDark else theme.secondaryLight,
|
||||
isSelected = false,
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
)
|
||||
ColorCircle(
|
||||
color = if (isDark) theme.tertiaryDark else theme.tertiaryLight,
|
||||
isSelected = false,
|
||||
modifier = Modifier.padding(horizontal = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(name)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
// 当前选中的主题显示选中标记
|
||||
if (ThemeConfig.currentTheme::class == theme::class) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss
|
||||
) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DynamicManagerDialog(
|
||||
state: MoreSettingsState,
|
||||
onConfirm: (Boolean, String, String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var localEnabled by remember { mutableStateOf(state.isDynamicSignEnabled) }
|
||||
var localSize by remember { mutableStateOf(state.dynamicSignSize) }
|
||||
var localHash by remember { mutableStateOf(state.dynamicSignHash) }
|
||||
|
||||
fun parseDynamicSignSize(input: String): Int? {
|
||||
return try {
|
||||
when {
|
||||
input.startsWith("0x", true) -> input.substring(2).toInt(16)
|
||||
else -> input.toInt()
|
||||
}
|
||||
} catch (_: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.dynamic_manager_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// 启用开关
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { localEnabled = !localEnabled }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Switch(
|
||||
checked = localEnabled,
|
||||
onCheckedChange = { localEnabled = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(stringResource(R.string.enable_dynamic_manager))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 签名大小输入
|
||||
OutlinedTextField(
|
||||
value = localSize,
|
||||
onValueChange = { input ->
|
||||
val isValid = when {
|
||||
input.isEmpty() -> true
|
||||
input.matches(Regex("^\\d+$")) -> true
|
||||
input.matches(Regex("^0[xX][0-9a-fA-F]*$")) -> true
|
||||
else -> false
|
||||
}
|
||||
if (isValid) {
|
||||
localSize = input
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.signature_size)) },
|
||||
enabled = localEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 签名哈希输入
|
||||
OutlinedTextField(
|
||||
value = localHash,
|
||||
onValueChange = { hash ->
|
||||
if (hash.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) {
|
||||
localHash = hash
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.signature_hash)) },
|
||||
enabled = localEnabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
supportingText = {
|
||||
Text(stringResource(R.string.hash_must_be_64_chars))
|
||||
},
|
||||
isError = localEnabled && localHash.isNotEmpty() && localHash.length != 64
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onConfirm(localEnabled, localSize, localHash) },
|
||||
enabled = if (localEnabled) {
|
||||
parseDynamicSignSize(localSize)?.let { it > 0 } == true &&
|
||||
localHash.length == 64
|
||||
} else true
|
||||
) {
|
||||
Text(stringResource(R.string.confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UidScannerSection(
|
||||
prefs: SharedPreferences,
|
||||
snackBarHost: SnackbarHostState,
|
||||
scope: CoroutineScope,
|
||||
context: Context
|
||||
) {
|
||||
if (Natives.version < Natives.MINIMAL_SUPPORTED_UID_SCANNER) return
|
||||
|
||||
val realAuto = Natives.isUidScannerEnabled()
|
||||
val realMulti = getUidMultiUserScan()
|
||||
|
||||
var autoOn by remember { mutableStateOf(realAuto) }
|
||||
var multiOn by remember { mutableStateOf(realMulti) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
autoOn = realAuto
|
||||
multiOn = realMulti
|
||||
prefs.edit {
|
||||
putBoolean("uid_auto_scan", autoOn)
|
||||
putBoolean("uid_multi_user_scan", multiOn)
|
||||
}
|
||||
}
|
||||
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.Scanner,
|
||||
title = stringResource(R.string.uid_auto_scan_title),
|
||||
summary = stringResource(R.string.uid_auto_scan_summary),
|
||||
checked = autoOn,
|
||||
onCheckedChange = { target ->
|
||||
autoOn = target
|
||||
if (!target) multiOn = false
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
setUidAutoScan(target)
|
||||
val actual = Natives.isUidScannerEnabled() || readUidScannerFile()
|
||||
withContext(Dispatchers.Main) {
|
||||
autoOn = actual
|
||||
if (!actual) multiOn = false
|
||||
prefs.edit {
|
||||
putBoolean("uid_auto_scan", actual)
|
||||
putBoolean("uid_multi_user_scan", multiOn)
|
||||
}
|
||||
if (actual != target) {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.uid_scanner_setting_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = autoOn,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
SwitchItem(
|
||||
icon = Icons.Filled.Groups,
|
||||
title = stringResource(R.string.uid_multi_user_scan_title),
|
||||
summary = stringResource(R.string.uid_multi_user_scan_summary),
|
||||
checked = multiOn,
|
||||
onCheckedChange = { target ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val ok = setUidMultiUserScan(target)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (ok) {
|
||||
multiOn = target
|
||||
prefs.edit { putBoolean("uid_multi_user_scan", target) }
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.uid_scanner_setting_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = autoOn,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
val confirmDialog = rememberConfirmDialog()
|
||||
com.sukisu.ultra.ui.screen.SettingItem(
|
||||
icon = Icons.Filled.CleaningServices,
|
||||
title = stringResource(R.string.clean_runtime_environment),
|
||||
summary = stringResource(R.string.clean_runtime_environment_summary),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (confirmDialog.awaitConfirm(
|
||||
title = context.getString(R.string.clean_runtime_environment),
|
||||
content = context.getString(R.string.clean_runtime_environment_confirm)
|
||||
) == ConfirmResult.Confirmed
|
||||
) {
|
||||
if (cleanRuntimeEnvironment()) {
|
||||
autoOn = false
|
||||
multiOn = false
|
||||
prefs.edit {
|
||||
putBoolean("uid_auto_scan", false)
|
||||
putBoolean("uid_multi_user_scan", false)
|
||||
}
|
||||
Natives.setUidScannerEnabled(false)
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.clean_runtime_environment_success)
|
||||
)
|
||||
} else {
|
||||
snackBarHost.showSnackbar(
|
||||
context.getString(R.string.clean_runtime_environment_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user