manager: Reorganizing the backup and restore functionality of SuSFS configurations

- Add checking for the existence of a data catalog for applications in SUS Path

- Modify the loading and caching mechanism of the application information class to avoid repeated refreshes,Finish loading with SuperUser.
This commit is contained in:
ShirkNeko
2025-07-04 17:57:54 +08:00
parent ea3a0cf73b
commit 6a60b72e21
5 changed files with 195 additions and 106 deletions

View File

@@ -2,7 +2,6 @@ package com.sukisu.ultra.ui.component
import android.annotation.SuppressLint
import android.content.pm.PackageInfo
import android.graphics.drawable.Drawable
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
@@ -55,6 +54,7 @@ import coil.request.ImageRequest
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.util.SuSFSManager
import com.sukisu.ultra.ui.screen.extensions.AppInfoCache
/**
* 添加路径对话框
@@ -391,17 +391,30 @@ fun AppIcon(
modifier = modifier.clip(RoundedCornerShape(8.dp))
)
} else {
var appIcon by remember(packageName) { mutableStateOf<Drawable?>(null) }
var appIcon by remember(packageName) {
mutableStateOf(
AppInfoCache.getAppInfo(packageName)?.drawable
)
}
LaunchedEffect(packageName) {
if (appIcon == null && !AppInfoCache.hasCache(packageName)) {
try {
val packageManager = context.packageManager
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
appIcon = packageManager.getApplicationIcon(applicationInfo)
val drawable = packageManager.getApplicationIcon(applicationInfo)
appIcon = drawable
val cachedInfo = AppInfoCache.CachedAppInfo(
appName = packageName,
packageInfo = null,
drawable = drawable
)
AppInfoCache.putAppInfo(packageName, cachedInfo)
} catch (_: Exception) {
Log.d("获取应用图标失败", packageName)
}
}
}
Image(
painter = rememberDrawablePainter(appIcon),
contentDescription = null,

View File

@@ -32,6 +32,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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
@@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.screen.extensions.AddKstatPathItemCard
import com.sukisu.ultra.ui.screen.extensions.AppInfoCache
import com.sukisu.ultra.ui.screen.extensions.AppPathGroupCard
import com.sukisu.ultra.ui.screen.extensions.EmptyStateCard
import com.sukisu.ultra.ui.screen.extensions.FeatureStatusCard
@@ -50,6 +52,7 @@ import com.sukisu.ultra.ui.screen.extensions.SectionHeader
import com.sukisu.ultra.ui.screen.extensions.SusMountHidingControlCard
import com.sukisu.ultra.ui.util.SuSFSManager
import com.sukisu.ultra.ui.util.SuSFSManager.isSusVersion_1_5_8
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
/**
* SUS路径内容组件
@@ -61,8 +64,24 @@ fun SusPathsContent(
onAddPath: () -> Unit,
onAddAppPath: () -> Unit,
onRemovePath: (String) -> Unit,
onEditPath: ((String) -> Unit)? = null
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 appPathMap = mutableMapOf<String, MutableList<String>>()

View File

@@ -84,6 +84,7 @@ import com.sukisu.ultra.ui.util.SuSFSManager
import com.sukisu.ultra.ui.util.SuSFSManager.isSusVersion_1_5_8
import com.sukisu.ultra.ui.util.isAbDevice
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
@@ -205,21 +206,22 @@ fun SuSFSConfigScreen(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { fileUri ->
val fileName = SuSFSManager.getRecommendedBackupPath(context)
val fileName = SuSFSManager.getDefaultBackupFileName()
val tempFile = File(context.cacheDir, fileName)
coroutineScope.launch {
isLoading = true
val success = SuSFSManager.createBackup(context, fileName)
val success = SuSFSManager.createBackup(context, tempFile.absolutePath)
if (success) {
// 复制到用户选择的位置
try {
context.contentResolver.openOutputStream(fileUri)?.use { outputStream ->
java.io.File(fileName).inputStream().use { inputStream ->
tempFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
tempFile.delete()
}
isLoading = false
showBackupDialog = false
@@ -233,8 +235,7 @@ fun SuSFSConfigScreen(
uri?.let { fileUri ->
coroutineScope.launch {
try {
// 复制到临时文件
val tempFile = java.io.File(context.cacheDir, "temp_restore.susfs_backup")
val tempFile = File(context.cacheDir, "temp_restore.susfs_backup")
context.contentResolver.openInputStream(fileUri)?.use { inputStream ->
tempFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
@@ -247,8 +248,6 @@ fun SuSFSConfigScreen(
selectedBackupFile = tempFile.absolutePath
backupInfo = backup
showRestoreConfirmDialog = true
} else {
// 显示错误消息
}
tempFile.deleteOnExit()
} catch (e: Exception) {
@@ -1173,7 +1172,8 @@ fun SuSFSConfigScreen(
onEditPath = { path ->
editingPath = path
showAddPathDialog = true
}
},
forceRefreshApps = selectedTab == SuSFSTab.SUS_PATHS
)
}
SuSFSTab.SUS_MOUNTS -> {

View File

@@ -3,6 +3,7 @@ package com.sukisu.ultra.ui.screen.extensions
import android.annotation.SuppressLint
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -56,8 +57,47 @@ import androidx.compose.ui.unit.sp
import com.sukisu.ultra.R
import com.sukisu.ultra.ui.component.AppIcon
import com.sukisu.ultra.ui.util.SuSFSManager
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import kotlinx.coroutines.launch
// 应用信息缓存
object AppInfoCache {
private val appInfoMap = mutableMapOf<String, CachedAppInfo>()
data class CachedAppInfo(
val appName: String,
val packageInfo: PackageInfo?,
val drawable: Drawable?,
val timestamp: Long = System.currentTimeMillis()
)
fun getAppInfo(packageName: String): CachedAppInfo? {
return appInfoMap[packageName]
}
fun putAppInfo(packageName: String, appInfo: CachedAppInfo) {
appInfoMap[packageName] = appInfo
}
fun clearCache() {
appInfoMap.clear()
}
fun hasCache(packageName: String): Boolean {
return appInfoMap.containsKey(packageName)
}
fun getAppInfoFromSuperUser(packageName: String): CachedAppInfo? {
val superUserApp = SuperUserViewModel.apps.find { it.packageName == packageName }
return superUserApp?.let { app ->
CachedAppInfo(
appName = app.label,
packageInfo = app.packageInfo,
drawable = null
)
}
}
}
/**
* 空状态显示组件
@@ -652,36 +692,78 @@ fun AppPathGroupCard(
isLoading: Boolean
) {
val context = LocalContext.current
var appName by remember(packageName) { mutableStateOf("") }
var packageInfo by remember(packageName) { mutableStateOf<PackageInfo?>(null) }
val coroutineScope = rememberCoroutineScope()
val superUserApps = SuperUserViewModel.apps
var cachedAppInfo by remember(packageName, superUserApps.size) {
mutableStateOf(AppInfoCache.getAppInfo(packageName))
}
var isLoadingAppInfo by remember(packageName, superUserApps.size) { mutableStateOf(false) }
LaunchedEffect(packageName) {
LaunchedEffect(packageName, superUserApps.size) {
if (cachedAppInfo == null || superUserApps.isNotEmpty()) {
isLoadingAppInfo = true
coroutineScope.launch {
try {
val superUserAppInfo = AppInfoCache.getAppInfoFromSuperUser(packageName)
if (superUserAppInfo != null) {
val packageManager = context.packageManager
val drawable = try {
superUserAppInfo.packageInfo?.applicationInfo?.let {
packageManager.getApplicationIcon(it)
}
} catch (_: Exception) {
null
}
val newCachedInfo = AppInfoCache.CachedAppInfo(
appName = superUserAppInfo.appName,
packageInfo = superUserAppInfo.packageInfo,
drawable = drawable
)
AppInfoCache.putAppInfo(packageName, newCachedInfo)
cachedAppInfo = newCachedInfo
} else {
val packageManager = context.packageManager
val appInfo = packageManager.getPackageInfo(packageName, PackageManager.GET_META_DATA)
packageInfo = appInfo
appName = try {
val appName = try {
appInfo.applicationInfo?.let {
packageManager.getApplicationLabel(it).toString()
} ?: packageName
} catch (_: Exception) {
packageName
}
} catch (_: Exception) {
try {
val installedApps = SuSFSManager.getInstalledApps()
val foundApp = installedApps.find { it.packageName == packageName }
if (foundApp != null) {
appName = foundApp.appName
packageInfo = foundApp.packageInfo
} else {
appName = packageName
packageInfo = null
val drawable = try {
appInfo.applicationInfo?.let {
packageManager.getApplicationIcon(it)
}
} catch (_: Exception) {
appName = packageName
packageInfo = null
null
}
val newCachedInfo = AppInfoCache.CachedAppInfo(
appName = appName,
packageInfo = appInfo,
drawable = drawable
)
AppInfoCache.putAppInfo(packageName, newCachedInfo)
cachedAppInfo = newCachedInfo
}
} catch (_: Exception) {
val newCachedInfo = AppInfoCache.CachedAppInfo(
appName = packageName,
packageInfo = null,
drawable = null
)
AppInfoCache.putAppInfo(packageName, newCachedInfo)
cachedAppInfo = newCachedInfo
} finally {
isLoadingAppInfo = false
}
}
}
}
@@ -703,20 +785,22 @@ fun AppPathGroupCard(
// 应用图标
AppIcon(
packageName = packageName,
packageInfo = packageInfo,
packageInfo = cachedAppInfo?.packageInfo,
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
val displayName = cachedAppInfo?.appName?.ifEmpty { packageName } ?: packageName
Text(
text = appName.ifEmpty { packageName },
text = displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
if (appName.isNotEmpty() && appName != packageName) {
if (!isLoadingAppInfo && cachedAppInfo?.appName?.isNotEmpty() == true &&
cachedAppInfo?.appName != packageName) {
Text(
text = packageName,
style = MaterialTheme.typography.bodySmall,

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.util.Log
import android.widget.Toast
import com.dergoogler.mmrl.platform.Platform.Companion.context
@@ -21,6 +20,8 @@ import java.io.FileOutputStream
import java.io.IOException
import androidx.core.content.edit
import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.*
@@ -57,6 +58,7 @@ object SuSFSManager {
private const val MODULE_PATH = "/data/adb/modules/$MODULE_ID"
private const val MIN_VERSION_FOR_HIDE_MOUNT = "1.5.8"
private const val BACKUP_FILE_EXTENSION = ".susfs_backup"
private const val MEDIA_DATA_PATH = "/data/media/0/Android/data"
data class SlotInfo(val slotName: String, val uname: String, val buildTime: String)
data class CommandResult(val isSuccess: Boolean, val output: String, val errorOutput: String = "")
@@ -370,7 +372,6 @@ object SuSFSManager {
@SuppressLint("QueryPermissionsNeeded")
suspend fun getInstalledApps(): List<AppInfo> = withContext(Dispatchers.IO) {
try {
val pm = context.packageManager
val allApps = mutableMapOf<String, AppInfo>()
// 从SuperUser中获取应用
@@ -391,54 +392,30 @@ object SuSFSManager {
}
}
try {
// 尝试从PackageManager获取所有应用
val installedPackages = pm.getInstalledPackages(PackageManager.GET_META_DATA)
installedPackages.forEach { packageInfo ->
val packageName = packageInfo.packageName
val isSystemApp = packageInfo.applicationInfo?.let {
(it.flags and ApplicationInfo.FLAG_SYSTEM) != 0
} ?: false
// 只处理非系统应用且不在SuperUser列表中的应用
if (!isSystemApp && !allApps.containsKey(packageName)) {
try {
val appName = packageInfo.applicationInfo?.loadLabel(pm)?.toString() ?: packageName
allApps[packageName] = AppInfo(
packageName = packageName,
appName = appName,
packageInfo = packageInfo,
isSystemApp = false
)
} catch (_: Exception) {
allApps[packageName] = AppInfo(
packageName = packageName,
appName = packageName,
packageInfo = packageInfo,
isSystemApp = false
)
}
}
}
} catch (e: Exception) {
Log.e("SuSFSManager", "Error getting installed packages", e)
}
// 添加可能遗漏的当前应用
val currentPackageName = context.packageName
if (!allApps.containsKey(currentPackageName)) {
try {
val currentAppInfo = pm.getPackageInfo(currentPackageName, 0)
val currentAppName = currentAppInfo.applicationInfo?.loadLabel(pm)?.toString() ?: "com.sukisu.ultra"
allApps[currentPackageName] = AppInfo(
packageName = currentPackageName,
appName = currentAppName,
packageInfo = currentAppInfo,
isSystemApp = false
)
} catch (_: Exception) {
}
}
// 检查每个应用的数据目录是否存在
val filteredApps = allApps.values.map { appInfo ->
async(Dispatchers.IO) {
val dataPath = "$MEDIA_DATA_PATH/${appInfo.packageName}"
val exists = try {
val shell = getRootShell()
val outputList = mutableListOf<String>()
val errorList = mutableListOf<String>()
allApps.values.sortedBy { it.appName }
val result = shell.newJob()
.add("[ -d \"$dataPath\" ] && echo 'exists' || echo 'not_exists'")
.to(outputList, errorList)
.exec()
result.isSuccess && outputList.isNotEmpty() && outputList[0].trim() == "exists"
} catch (e: Exception) {
Log.w("SuSFSManager", "Failed to check directory for ${appInfo.packageName}: ${e.message}")
false
}
if (exists) appInfo else null
}
}.awaitAll().filterNotNull()
filteredApps.sortedBy { it.appName }
} catch (e: Exception) {
e.printStackTrace()
emptyList()
@@ -454,7 +431,7 @@ object SuSFSManager {
getSdcardPath(context)
val path1 = "$androidDataPath/$packageName"
val path2 = "/data/media/0/Android/data/$packageName"
val path2 = "$MEDIA_DATA_PATH/$packageName"
var successCount = 0
var totalCount = 0
@@ -623,13 +600,9 @@ object SuSFSManager {
}
}
//获取备份文件路径
fun getRecommendedBackupPath(context: Context): String {
val documentsDir = File(context.getExternalFilesDir(null), "SuSFS_Backups")
if (!documentsDir.exists()) {
documentsDir.mkdirs()
}
return File(documentsDir, generateBackupFileName()).absolutePath
// 获取备份文件路径
fun getDefaultBackupFileName(): String {
return generateBackupFileName()
}
// 槽位信息获取