Introducing miuix

Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com>
This commit is contained in:
ShirkNeko
2025-11-19 16:11:33 +08:00
parent 2ea748dac1
commit a14551b3ec
156 changed files with 10788 additions and 42788 deletions

View File

@@ -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)
}

View File

@@ -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.** { *; }

View File

@@ -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"

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
private fun initializeViewModels() {
superUserViewModel = SuperUserViewModel()
homeViewModel = HomeViewModel()
// 设置主题变化监听器
themeChangeObserver = ThemeUtils.registerThemeChangeObserver(this)
}
private fun initializeData() {
lifecycleScope.launch {
try {
superUserViewModel.fetchAppList()
} catch (e: Exception) {
e.printStackTrace()
}
}
// 数据刷新协程
DataRefreshUtils.startDataRefreshCoroutine(lifecycleScope)
DataRefreshUtils.startSettingsMonitorCoroutine(lifecycleScope, this, settingsStateFlow)
// 初始化主题相关设置
ThemeUtils.initializeThemeSettings(this, settingsStateFlow)
}
override fun onResume() {
try {
super.onResume()
ThemeUtils.onActivityResume()
// 仅在需要时刷新数据
if (isInitialized) {
refreshData()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun refreshData() {
lifecycleScope.launch {
try {
superUserViewModel.fetchAppList()
DataRefreshUtils.refreshData(lifecycleScope)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
override fun onPause() {
try {
super.onPause()
ThemeUtils.onActivityPause(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onDestroy() {
try {
ThemeUtils.unregisterThemeChangeObserver(this, themeChangeObserver)
super.onDestroy()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
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) }
}
}
BackHandler {
if (pagerState.currentPage != 0) {
coroutineScope.launch {
pagerState.animateScrollToPage(0)
}
} else {
activity?.finishAndRemoveTask()
}
}
CompositionLocalProvider(
LocalPagerState provides pagerState,
LocalHandlePageChange provides handlePageChange
) {
Scaffold(
bottomBar = {
BottomBar(hazeState, hazeStyle)
},
) { innerPadding ->
HorizontalPager(
modifier = Modifier.hazeSource(state = hazeState),
state = pagerState,
beyondViewportPageCount = 2,
userScrollEnabled = false
) {
when (it) {
0 -> HomePager(pagerState, navController, innerPadding.calculateBottomPadding())
1 -> SuperUserPager(navController, innerPadding.calculateBottomPadding())
2 -> ModulePager(navController, innerPadding.calculateBottomPadding())
3 -> SettingPager(navController, innerPadding.calculateBottomPadding())
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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() {
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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
)
)
}
}
}
}

View File

@@ -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
) {}
}

View File

@@ -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)
}

View File

@@ -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)
)
}
)
}

View File

@@ -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)
}
)
}

View File

@@ -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,
)
}
}

View File

@@ -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
}

View File

@@ -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()
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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(

View File

@@ -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 = "" }
)
}

View File

@@ -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)
)
}
)
}

View File

@@ -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)
}
)
}

View File

@@ -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
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
)
},
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
}
)
}
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,
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = colors.selectedContentColor,
modifier = Modifier.size(20.dp)
)
}
Image(
modifier = Modifier
.padding(start = 12.dp)
.size(20.dp),
imageVector = MiuixIcons.Basic.Check,
colorFilter = BlendModeColorFilter(checkColor, BlendMode.SrcIn),
contentDescription = null,
)
}
}
@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
)
}
}

View File

@@ -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()
)
}
}
}

View File

@@ -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 = { }
)
}

View File

@@ -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)
}
}

View File

@@ -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
)
)
}

View File

@@ -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)
}
}
}

View File

@@ -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
)
}
}

View File

@@ -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) {

View File

@@ -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 }
)
}

View File

@@ -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)
}
}
)
}
}
}
})
}
}

View File

@@ -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
)
}
}
}
}
}

View 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()
}

View File

@@ -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),
}

View File

@@ -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,81 +101,108 @@ 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
)
}
}

View File

@@ -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("")) { // 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("")) { // 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

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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,
)
)
}
}

View File

@@ -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 {

View File

@@ -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()
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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
)
)

View File

@@ -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()
)
}
}

View File

@@ -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
}
}

View File

@@ -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")
}

View File

@@ -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 downloadID: $downloadId")
monitorDownload(context, downloadManager, downloadId, url, fileName, description, onDownloaded, onDownloading, onError)
} catch (e: Exception) {
Log.e(TAG, "Download startup failure", e)
onError("Download startup failure: ${e.message}")
}
}
private fun monitorDownload(
context: Context,
downloadManager: DownloadManager,
downloadId: Long,
url: String,
fileName: String,
description: String,
onDownloaded: (Uri) -> Unit,
onDownloading: () -> Unit,
onError: (String) -> Unit,
retryCount: Int = 0
) {
val handler = Handler(Looper.getMainLooper())
val query = DownloadManager.Query().setFilterById(downloadId)
var lastProgress = -1
var stuckCounter = 0
val runnable = object : Runnable {
override fun run() {
downloadManager.query(query).use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
@SuppressLint("Range")
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> {
@SuppressLint("Range")
val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
Log.d(TAG, "Download Successfully: $localUri")
onDownloaded(localUri.toUri())
return
}
DownloadManager.STATUS_FAILED -> {
@SuppressLint("Range")
val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON))
Log.d(TAG, "Download failed with reason code: $reason")
if (retryCount < MAX_RETRY_COUNT) {
Log.d(TAG, "Attempts to re download, number of retries: ${retryCount + 1}")
handler.postDelayed({
downloadManager.remove(downloadId)
download(context, url, fileName, description, onDownloaded, onDownloading, onError)
}, RETRY_DELAY_MS)
} else {
onError("Download failed, please check network connection or storage space")
}
return
}
DownloadManager.STATUS_RUNNING, DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED -> {
@SuppressLint("Range")
val totalBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
@SuppressLint("Range")
val downloadedBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
if (totalBytes > 0) {
val progress = (downloadedBytes * 100 / totalBytes).toInt()
if (progress == lastProgress) {
stuckCounter++
if (stuckCounter > 30) {
if (retryCount < MAX_RETRY_COUNT) {
Log.d(TAG, "Download stalled and restarted")
downloadManager.remove(downloadId)
download(context, url, fileName, description, onDownloaded, onDownloading, onError)
return
}
}
} else {
lastProgress = progress
stuckCounter = 0
Log.d(TAG, "Download progress: $progress% ($downloadedBytes/$totalBytes)")
}
}
}
}
}
}
handler.postDelayed(this, 1000)
}
}
handler.post(runnable)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
if (id == downloadId) {
handler.removeCallbacks(runnable)
val query = DownloadManager.Query().setFilterById(downloadId)
downloadManager.query(query).use { cursor ->
if (cursor != null && cursor.moveToFirst()) {
@SuppressLint("Range")
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
if (status == DownloadManager.STATUS_SUCCESSFUL) {
@SuppressLint("Range")
val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
onDownloaded(localUri.toUri())
} else {
if (retryCount < MAX_RETRY_COUNT) {
download(context!!, url, fileName, description, onDownloaded, onDownloading, onError)
} else {
onError("Download failed, please try again later")
}
}
}
}
context?.unregisterReceiver(this)
}
}
}
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
ContextCompat.RECEIVER_EXPORTED
)
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))
}
}
}

View File

@@ -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();
}
}

View File

@@ -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
}
}
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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() }
}

View File

@@ -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 = ""
)

View File

@@ -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"
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
)
}

View File

@@ -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]
}

View File

@@ -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
}
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
withContext(Dispatchers.Main) {
synchronized(appsLock) {
apps = allPackagesSlice.first
}
}.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)
_appList.value = allPackagesSlice.second
isRefreshing = false
stopKsuService()
}
}
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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;
};
}
}

View File

@@ -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
}
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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() }
}
}

View File

@@ -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()
)
)
}
}
}
}

View File

@@ -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) =

View File

@@ -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)
}
}
}
}
}

View File

@@ -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
)
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
)
)
}

View File

@@ -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)
)
}
}
}
}
)
}
}

View File

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

View File

@@ -1,154 +0,0 @@
package zako.zako.zako.zakoui.screen.moreSettings.util
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.provider.Settings
import java.util.*
object LocaleHelper {
/**
* Check if should use system language settings (Android 13+)
*/
val useSystemLanguageSettings: Boolean
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
/**
* Launch system app locale settings (Android 13+)
*/
fun launchSystemLanguageSettings(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
try {
val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
} catch (_: Exception) {
// Fallback to app language settings if system settings not available
}
}
}
/**
* Apply saved language setting to context (for Android < 13)
*/
fun applyLanguage(context: Context): Context {
// On Android 13+, language is handled by system
if (useSystemLanguageSettings) {
return context
}
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val localeTag = prefs.getString("app_locale", "system") ?: "system"
return if (localeTag == "system") {
context
} else {
val locale = parseLocaleTag(localeTag)
setLocale(context, locale)
}
}
/**
* Set locale for context (Android < 13)
*/
@SuppressLint("ObsoleteSdkInt")
private fun setLocale(context: Context, locale: Locale): Context {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
updateResources(context, locale)
} else {
updateResourcesLegacy(context, locale)
}
}
@SuppressLint("UseRequiresApi", "ObsoleteSdkInt")
@TargetApi(Build.VERSION_CODES.N)
private fun updateResources(context: Context, locale: Locale): Context {
val configuration = Configuration()
configuration.setLocale(locale)
configuration.setLayoutDirection(locale)
return context.createConfigurationContext(configuration)
}
@Suppress("DEPRECATION")
@SuppressWarnings("deprecation")
private fun updateResourcesLegacy(context: Context, locale: Locale): Context {
Locale.setDefault(locale)
val resources = context.resources
val configuration = resources.configuration
configuration.locale = locale
configuration.setLayoutDirection(locale)
resources.updateConfiguration(configuration, resources.displayMetrics)
return context
}
/**
* Parse locale tag to Locale object
*/
private fun parseLocaleTag(tag: String): Locale {
return try {
if (tag.contains("_")) {
val parts = tag.split("_")
Locale.Builder()
.setLanguage(parts[0])
.setRegion(parts.getOrNull(1) ?: "")
.build()
} else {
Locale.Builder()
.setLanguage(tag)
.build()
}
} catch (_: Exception) {
Locale.getDefault()
}
}
/**
* Restart activity to apply language change (Android < 13)
*/
fun restartActivity(context: Context) {
if (context is Activity && !useSystemLanguageSettings) {
context.recreate()
}
}
/**
* Get current app locale
*/
@SuppressLint("ObsoleteSdkInt")
fun getCurrentAppLocale(context: Context): Locale? {
return if (useSystemLanguageSettings) {
// Android 13+ - get from system app locale settings
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
try {
val localeManager = context.getSystemService(Context.LOCALE_SERVICE) as? android.app.LocaleManager
val locales = localeManager?.applicationLocales
if (locales != null && !locales.isEmpty) {
locales.get(0)
} else {
null // System default
}
} catch (_: Exception) {
null // System default
}
} else {
null // System default
}
} else {
// Android < 13 - get from SharedPreferences
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val localeTag = prefs.getString("app_locale", "system") ?: "system"
if (localeTag == "system") {
null // System default
} else {
parseLocaleTag(localeTag)
}
}
}
}

View File

@@ -1,27 +0,0 @@
package zako.zako.zako.zakoui.screen.moreSettings.util
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import com.sukisu.ultra.ui.MainActivity
/**
* 刷新启动器图标
*/
fun toggleLauncherIcon(context: Context, useAlt: Boolean) {
val pm = context.packageManager
val main = ComponentName(context, MainActivity::class.java.name)
val alias = ComponentName(context, "${MainActivity::class.java.name}Alias")
pm.setComponentEnabledSetting(
if (useAlt) alias else main,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP
)
pm.setComponentEnabledSetting(
if (useAlt) main else alias,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}

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