From 684a5d1ccd710b5109fb6fb0bd28aa69b0bf7231 Mon Sep 17 00:00:00 2001 From: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com> Date: Sat, 15 Nov 2025 23:44:38 +0800 Subject: [PATCH] manager: display the same UID as a group Co-authored-by: YuKongA <70465933+YuKongA@users.noreply.github.com> Co-authored-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com> --- manager/app/src/main/cpp/jni.c | 9 + .../src/main/java/com/sukisu/ultra/Natives.kt | 2 + .../ui/component/VerticalExpandableFab.kt | 30 +- .../com/sukisu/ultra/ui/screen/SuperUser.kt | 977 +++++++++--------- .../com/sukisu/ultra/ui/screen/Template.kt | 28 +- .../ultra/ui/viewmodel/SuperUserViewModel.kt | 315 ++---- 6 files changed, 626 insertions(+), 735 deletions(-) diff --git a/manager/app/src/main/cpp/jni.c b/manager/app/src/main/cpp/jni.c index c28ea642..57153002 100644 --- a/manager/app/src/main/cpp/jni.c +++ b/manager/app/src/main/cpp/jni.c @@ -6,6 +6,7 @@ #include #include #include +#include NativeBridgeNP(getVersion, jint) { uint32_t version = get_version(); @@ -318,6 +319,14 @@ NativeBridge(setEnhancedSecurityEnabled, jboolean, jboolean enabled) { return set_enhanced_security_enabled(enabled); } +NativeBridge(getUserName, jstring, jint uid) { + struct passwd *pw = getpwuid((uid_t) uid); + if (pw && pw->pw_name && pw->pw_name[0] != '\0') { + return GetEnvironment()->NewStringUTF(env, pw->pw_name); + } + return NULL; +} + // Check if KPM is enabled NativeBridgeNP(isKPMEnabled, jboolean) { return is_KPM_enable(); diff --git a/manager/app/src/main/java/com/sukisu/ultra/Natives.kt b/manager/app/src/main/java/com/sukisu/ultra/Natives.kt index 157350dc..2f8aa33e 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/Natives.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/Natives.kt @@ -176,6 +176,8 @@ object Natives { */ external fun clearUidScannerEnvironment(): Boolean + external fun getUserName(uid: Int): String? + private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$" private const val NOBODY_UID = 9999 diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/VerticalExpandableFab.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/VerticalExpandableFab.kt index 914240ce..e37cd81a 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/component/VerticalExpandableFab.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/VerticalExpandableFab.kt @@ -21,7 +21,6 @@ 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, @@ -29,7 +28,6 @@ data class FabMenuItem( val onClick: () -> Unit ) -// 动画配置 object FabAnimationConfig { const val ANIMATION_DURATION = 300 const val STAGGER_DELAY = 50 @@ -53,23 +51,15 @@ fun VerticalExpandableFab( ) { var isExpanded by remember { mutableStateOf(false) } - // 主按钮旋转动画 val rotationAngle by animateFloatAsState( targetValue = if (isExpanded) 45f else 0f, - animationSpec = tween( - durationMillis = animationDurationMs, - easing = FastOutSlowInEasing - ), + animationSpec = tween(animationDurationMs, easing = FastOutSlowInEasing), label = "mainButtonRotation" ) - // 主按钮缩放动画 val mainButtonScale by animateFloatAsState( targetValue = if (isExpanded) 1.1f else 1f, - animationSpec = tween( - durationMillis = animationDurationMs, - easing = FastOutSlowInEasing - ), + animationSpec = tween(animationDurationMs, easing = FastOutSlowInEasing), label = "mainButtonScale" ) @@ -77,14 +67,9 @@ fun VerticalExpandableFab( modifier = modifier.wrapContentSize(), contentAlignment = Alignment.BottomEnd ) { - // 子菜单按钮 menuItems.forEachIndexed { index, menuItem -> val animatedOffsetY by animateFloatAsState( - targetValue = if (isExpanded) { - -(buttonSpacing.value * (index + 1)) - } else { - 0f - }, + targetValue = if (isExpanded) -(buttonSpacing.value * (index + 1)) else 0f, animationSpec = tween( durationMillis = animationDurationMs, delayMillis = if (isExpanded) { @@ -125,7 +110,6 @@ fun VerticalExpandableFab( label = "fabAlpha$index" ) - // 子按钮容器(包含标签) Row( modifier = Modifier .offset(y = animatedOffsetY.dp) @@ -134,7 +118,6 @@ fun VerticalExpandableFab( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { - // 标签 AnimatedVisibility( visible = isExpanded && animatedScale > 0.5f, enter = slideInHorizontally( @@ -161,7 +144,6 @@ fun VerticalExpandableFab( } } - // 子按钮 SmallFloatingActionButton( onClick = { menuItem.onClick() @@ -193,15 +175,12 @@ fun VerticalExpandableFab( } } - // 主按钮 FloatingActionButton( onClick = { onMainButtonClick?.invoke() isExpanded = !isExpanded }, - modifier = Modifier - .size(buttonSize) - .scale(mainButtonScale), + modifier = Modifier.size(buttonSize).scale(mainButtonScale), elevation = FloatingActionButtonDefaults.elevation( defaultElevation = 6.dp, pressedElevation = 8.dp, @@ -221,7 +200,6 @@ fun VerticalExpandableFab( } } -// 预设菜单项 object FabMenuPresets { fun getScrollMenuItems( onScrollToTop: () -> Unit, diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuperUser.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuperUser.kt index 49362f2a..9e74b31a 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuperUser.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/SuperUser.kt @@ -33,12 +33,10 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import coil.request.ImageRequest @@ -57,14 +55,11 @@ import com.sukisu.ultra.ui.util.module.ModuleModify import com.sukisu.ultra.ui.viewmodel.AppCategory import com.sukisu.ultra.ui.viewmodel.SortType import com.sukisu.ultra.ui.viewmodel.SuperUserViewModel +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import java.io.File -// 应用优先级枚举 enum class AppPriority(val value: Int) { - ROOT(1), // root权限应用 - CUSTOM(2), // 自定义应用 - DEFAULT(3) // 默认应用 + ROOT(1), CUSTOM(2), DEFAULT(3) } data class BottomSheetMenuItem( @@ -73,34 +68,6 @@ data class BottomSheetMenuItem( val onClick: () -> Unit ) -private fun getAppPriority(app: SuperUserViewModel.AppInfo): AppPriority { - return when { - app.allowSu -> AppPriority.ROOT - app.hasCustomProfile -> AppPriority.CUSTOM - else -> AppPriority.DEFAULT - } -} - -private fun getMultiSelectMainIcon(isExpanded: Boolean): ImageVector { - return if (isExpanded) { - Icons.Filled.Close - } else { - Icons.Filled.GridView - } -} - -private fun getSingleSelectMainIcon(isExpanded: Boolean): ImageVector { - return if (isExpanded) { - Icons.Filled.Close - } else { - Icons.Filled.Add - } -} - -/** - * @author ShirkNeko - * @date 2025/6/8 - */ @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Destination @Composable @@ -112,224 +79,93 @@ fun SuperUserScreen(navigator: DestinationsNavigator) { val context = LocalContext.current val snackBarHostState = remember { SnackbarHostState() } - val selectedCategory = viewModel.selectedCategory - val currentSortType = viewModel.currentSortType - - val bottomSheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) + val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) var showBottomSheet by remember { mutableStateOf(false) } val backupLauncher = ModuleModify.rememberAllowlistBackupLauncher(context, snackBarHostState) val restoreLauncher = ModuleModify.rememberAllowlistRestoreLauncher(context, snackBarHostState) - LaunchedEffect(key1 = navigator) { + LaunchedEffect(navigator) { viewModel.search = "" if (viewModel.appList.isEmpty()) { // viewModel.fetchAppList() } } - LaunchedEffect(viewModel.search) { - if (viewModel.search.isEmpty()) { - // Optional: scroll to top when clearing search - } - } - - // 监听选中应用的变化,如果在多选模式下没有选中任何应用,则自动退出多选模式 LaunchedEffect(viewModel.selectedApps, viewModel.showBatchActions) { if (viewModel.showBatchActions && viewModel.selectedApps.isEmpty()) { viewModel.showBatchActions = false } } - // 应用分类和排序逻辑 - val filteredAndSortedApps = remember( - viewModel.appList, - selectedCategory, - currentSortType, + val filteredAndSortedAppGroups = remember( + viewModel.appGroupList, + viewModel.selectedCategory, + viewModel.currentSortType, viewModel.search, viewModel.showSystemApps ) { - var apps = viewModel.appList + var groups = viewModel.appGroupList // 按分类筛选 - apps = when (selectedCategory) { - AppCategory.ALL -> apps - AppCategory.ROOT -> apps.filter { it.allowSu } - AppCategory.CUSTOM -> apps.filter { !it.allowSu && it.hasCustomProfile } - AppCategory.DEFAULT -> apps.filter { !it.allowSu && !it.hasCustomProfile } + groups = when (viewModel.selectedCategory) { + AppCategory.ALL -> groups + AppCategory.ROOT -> groups.filter { it.allowSu } + AppCategory.CUSTOM -> groups.filter { !it.allowSu && it.hasCustomProfile } + AppCategory.DEFAULT -> groups.filter { !it.allowSu && !it.hasCustomProfile } } - // 优先级排序 + 二次排序 - apps = apps.sortedWith { app1, app2 -> - val priority1 = getAppPriority(app1) - val priority2 = getAppPriority(app2) + // 排序 + groups.sortedWith { group1, group2 -> + val priority1 = when { + group1.allowSu -> AppPriority.ROOT + group1.hasCustomProfile -> AppPriority.CUSTOM + else -> AppPriority.DEFAULT + } + val priority2 = when { + group2.allowSu -> AppPriority.ROOT + group2.hasCustomProfile -> AppPriority.CUSTOM + else -> AppPriority.DEFAULT + } - // 首先按优先级排序 val priorityComparison = priority1.value.compareTo(priority2.value) - if (priorityComparison != 0) { priorityComparison } else { - // 在相同优先级内按指定排序方式排序 - when (currentSortType) { - SortType.NAME_ASC -> app1.label.lowercase().compareTo(app2.label.lowercase()) - SortType.NAME_DESC -> app2.label.lowercase().compareTo(app1.label.lowercase()) - SortType.INSTALL_TIME_NEW -> app2.packageInfo.firstInstallTime.compareTo(app1.packageInfo.firstInstallTime) - SortType.INSTALL_TIME_OLD -> app1.packageInfo.firstInstallTime.compareTo(app2.packageInfo.firstInstallTime) - SortType.SIZE_DESC -> { - val size1: Long = app1.packageInfo.applicationInfo?.let { - try { - File(context.packageManager.getApplicationInfo(it.packageName, 0).sourceDir).length() - } catch (_: Exception) { - 0L - } - } ?: 0L - val size2: Long = app2.packageInfo.applicationInfo?.let { - try { - File(context.packageManager.getApplicationInfo(it.packageName, 0).sourceDir).length() - } catch (_: Exception) { - 0L - } - } ?: 0L - size2.compareTo(size1) - } - SortType.SIZE_ASC -> { - val size1: Long = app1.packageInfo.applicationInfo?.let { - try { - File(context.packageManager.getApplicationInfo(it.packageName, 0).sourceDir).length() - } catch (_: Exception) { - 0L - } - } ?: 0L - val size2: Long = app2.packageInfo.applicationInfo?.let { - try { - File(context.packageManager.getApplicationInfo(it.packageName, 0).sourceDir).length() - } catch (_: Exception) { - 0L - } - } ?: 0L - size1.compareTo(size2) - } - SortType.USAGE_FREQ -> app1.label.lowercase().compareTo(app2.label.lowercase()) // 默认按名称排序 + when (viewModel.currentSortType) { + SortType.NAME_ASC -> group1.mainApp.label.lowercase() + .compareTo(group2.mainApp.label.lowercase()) + SortType.NAME_DESC -> group2.mainApp.label.lowercase() + .compareTo(group1.mainApp.label.lowercase()) + SortType.INSTALL_TIME_NEW -> group2.mainApp.packageInfo.firstInstallTime + .compareTo(group1.mainApp.packageInfo.firstInstallTime) + SortType.INSTALL_TIME_OLD -> group1.mainApp.packageInfo.firstInstallTime + .compareTo(group2.mainApp.packageInfo.firstInstallTime) + else -> group1.mainApp.label.lowercase() + .compareTo(group2.mainApp.label.lowercase()) } } } - - apps } - val appCounts = remember(viewModel.appList, viewModel.showSystemApps) { + val appCounts = remember(viewModel.appGroupList, viewModel.showSystemApps) { mapOf( - AppCategory.ALL to viewModel.appList.size, - AppCategory.ROOT to viewModel.appList.count { it.allowSu }, - AppCategory.CUSTOM to viewModel.appList.count { !it.allowSu && it.hasCustomProfile }, - AppCategory.DEFAULT to viewModel.appList.count { !it.allowSu && !it.hasCustomProfile } + AppCategory.ALL to viewModel.appGroupList.size, + AppCategory.ROOT to viewModel.appGroupList.count { it.allowSu }, + AppCategory.CUSTOM to viewModel.appGroupList.count { !it.allowSu && it.hasCustomProfile }, + AppCategory.DEFAULT to viewModel.appGroupList.count { !it.allowSu && !it.hasCustomProfile } ) } - val bottomSheetMenuItems = remember(viewModel.showSystemApps) { - listOf( - BottomSheetMenuItem( - icon = Icons.Filled.Refresh, - titleRes = R.string.refresh, - onClick = { - scope.launch { - viewModel.fetchAppList() - bottomSheetState.hide() - showBottomSheet = false - } - } - ), - BottomSheetMenuItem( - icon = if (viewModel.showSystemApps) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - titleRes = if (viewModel.showSystemApps) { - R.string.hide_system_apps - } else { - R.string.show_system_apps - }, - onClick = { - viewModel.updateShowSystemApps(!viewModel.showSystemApps) - scope.launch { - kotlinx.coroutines.delay(100) - bottomSheetState.hide() - showBottomSheet = false - } - } - ), - BottomSheetMenuItem( - icon = Icons.Filled.Save, - titleRes = R.string.backup_allowlist, - onClick = { - backupLauncher.launch(ModuleModify.createAllowlistBackupIntent()) - scope.launch { - bottomSheetState.hide() - showBottomSheet = false - } - } - ), - BottomSheetMenuItem( - icon = Icons.Filled.RestoreFromTrash, - titleRes = R.string.restore_allowlist, - onClick = { - restoreLauncher.launch(ModuleModify.createAllowlistRestoreIntent()) - scope.launch { - bottomSheetState.hide() - showBottomSheet = false - } - } - ) - ) - } - - var isFabExpanded by remember { mutableStateOf(false) } - Scaffold( topBar = { SearchAppBar( - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(stringResource(R.string.superuser)) - - if (selectedCategory != AppCategory.ALL) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.padding(start = 4.dp) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = stringResource(selectedCategory.displayNameRes), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = "(${appCounts[selectedCategory] ?: 0})", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - } - } - }, + title = { TopBarTitle(viewModel.selectedCategory, appCounts) }, searchText = viewModel.search, onSearchTextChange = { viewModel.search = it }, onClearClick = { viewModel.search = "" }, dropdownContent = { - IconButton( - onClick = { - showBottomSheet = true - }, - ) { + IconButton(onClick = { showBottomSheet = true }) { Icon( imageVector = Icons.Filled.MoreVert, contentDescription = stringResource(id = R.string.settings), @@ -342,175 +178,324 @@ fun SuperUserScreen(navigator: DestinationsNavigator) { snackbarHost = { SnackbarHost(snackBarHostState) }, contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), floatingActionButton = { - VerticalExpandableFab( - menuItems = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) { - FabMenuPresets.getBatchActionMenuItems( - onCancel = { - viewModel.selectedApps = emptySet() - viewModel.showBatchActions = false - }, - onDeny = { - scope.launch { - viewModel.updateBatchPermissions(false) - } - }, - onAllow = { - scope.launch { - viewModel.updateBatchPermissions(true) - } - }, - onUnmountModules = { - scope.launch { - viewModel.updateBatchPermissions( - allowSu = false, - umountModules = true - ) - } - }, - onDisableUnmount = { - scope.launch { - viewModel.updateBatchPermissions( - allowSu = false, - umountModules = false - ) - } - } - ) - } else { - FabMenuPresets.getScrollMenuItems( - onScrollToTop = { - scope.launch { - listState.animateScrollToItem(0) - } - }, - onScrollToBottom = { - scope.launch { - val lastIndex = filteredAndSortedApps.size - 1 - if (lastIndex >= 0) { - listState.animateScrollToItem(lastIndex) - } - } - } - ) - }, - buttonSpacing = 72.dp, - animationDurationMs = 300, - staggerDelayMs = 50, - mainButtonIcon = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) { - getMultiSelectMainIcon(isFabExpanded) - } else { - getSingleSelectMainIcon(isFabExpanded) - }, - mainButtonExpandedIcon = Icons.Filled.Close - ) + SuperUserFab(viewModel, filteredAndSortedAppGroups, listState, scope) } ) { innerPadding -> - PullToRefreshBox( - modifier = Modifier.padding(innerPadding), - onRefresh = { - scope.launch { viewModel.fetchAppList() } - }, - isRefreshing = viewModel.isRefreshing - ) { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection) + SuperUserContent( + innerPadding = innerPadding, + viewModel = viewModel, + filteredAndSortedAppGroups = filteredAndSortedAppGroups, + listState = listState, + scrollBehavior = scrollBehavior, + navigator = navigator, + scope = scope + ) + + if (showBottomSheet) { + SuperUserBottomSheet( + bottomSheetState = bottomSheetState, + onDismiss = { showBottomSheet = false }, + viewModel = viewModel, + appCounts = appCounts, + backupLauncher = backupLauncher, + restoreLauncher = restoreLauncher, + scope = scope, + listState = listState + ) + } + } +} + +@Composable +private fun TopBarTitle( + selectedCategory: AppCategory, + appCounts: Map +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(stringResource(R.string.superuser)) + + if (selectedCategory != AppCategory.ALL) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.padding(start = 4.dp) ) { - items(filteredAndSortedApps, key = { it.packageName + it.uid }) { app -> - AppItem( - app = app, - isSelected = viewModel.selectedApps.contains(app.packageName), - onToggleSelection = { viewModel.toggleAppSelection(app.packageName) }, + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(selectedCategory.displayNameRes), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "(${appCounts[selectedCategory] ?: 0})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } +} + +@Composable +private fun SuperUserFab( + viewModel: SuperUserViewModel, + filteredAndSortedAppGroups: List, + listState: androidx.compose.foundation.lazy.LazyListState, + scope: CoroutineScope +) { + VerticalExpandableFab( + menuItems = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) { + FabMenuPresets.getBatchActionMenuItems( + onCancel = { + viewModel.selectedApps = emptySet() + viewModel.showBatchActions = false + }, + onDeny = { scope.launch { viewModel.updateBatchPermissions(false) } }, + onAllow = { scope.launch { viewModel.updateBatchPermissions(true) } }, + onUnmountModules = { + scope.launch { viewModel.updateBatchPermissions( + allowSu = false, + umountModules = true + ) } + }, + onDisableUnmount = { + scope.launch { viewModel.updateBatchPermissions( + allowSu = false, + umountModules = false + ) } + } + ) + } else { + FabMenuPresets.getScrollMenuItems( + onScrollToTop = { scope.launch { listState.animateScrollToItem(0) } }, + onScrollToBottom = { + scope.launch { + val lastIndex = filteredAndSortedAppGroups.size - 1 + if (lastIndex >= 0) listState.animateScrollToItem(lastIndex) + } + } + ) + }, + mainButtonIcon = if (viewModel.showBatchActions && viewModel.selectedApps.isNotEmpty()) { + Icons.Filled.GridView + } else { + Icons.Filled.Add + }, + mainButtonExpandedIcon = Icons.Filled.Close + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SuperUserContent( + innerPadding: PaddingValues, + viewModel: SuperUserViewModel, + filteredAndSortedAppGroups: List, + listState: androidx.compose.foundation.lazy.LazyListState, + scrollBehavior: TopAppBarScrollBehavior, + navigator: DestinationsNavigator, + scope: CoroutineScope +) { + val expandedGroups = remember { mutableStateOf(setOf()) } + + PullToRefreshBox( + modifier = Modifier.padding(innerPadding), + onRefresh = { scope.launch { viewModel.fetchAppList() } }, + isRefreshing = viewModel.isRefreshing + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + filteredAndSortedAppGroups.forEachIndexed { _, appGroup -> + item(key = appGroup.uid) { + AppGroupItem( + appGroup = appGroup, + isSelected = appGroup.packageNames.any { viewModel.selectedApps.contains(it) }, + onToggleSelection = { + appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) } + }, onClick = { if (viewModel.showBatchActions) { - viewModel.toggleAppSelection(app.packageName) + appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) } + } else if (appGroup.apps.size > 1) { + expandedGroups.value = if (expandedGroups.value.contains(appGroup.uid)) { + expandedGroups.value - appGroup.uid + } else { + expandedGroups.value + appGroup.uid + } } else { - navigator.navigate(AppProfileScreenDestination(app)) + navigator.navigate(AppProfileScreenDestination(appGroup.mainApp)) } }, onLongClick = { if (!viewModel.showBatchActions) { viewModel.toggleBatchMode() - viewModel.toggleAppSelection(app.packageName) + appGroup.packageNames.forEach { viewModel.toggleAppSelection(it) } } }, - viewModel = viewModel + viewModel = viewModel, + navigator = navigator, + isExpanded = expandedGroups.value.contains(appGroup.uid) ) } - if (filteredAndSortedApps.isEmpty()) { - item { - Box( + if (expandedGroups.value.contains(appGroup.uid) && appGroup.apps.size > 1) { + items(appGroup.apps.drop(1), key = { it.packageName }) { app -> + ListItem( modifier = Modifier .fillMaxWidth() - .height(400.dp), - contentAlignment = Alignment.Center - ) { - if ((viewModel.isRefreshing || viewModel.appList.isEmpty()) && viewModel.search.isEmpty()) { - LoadingAnimation( - isLoading = true - ) - } else { - EmptyState( - selectedCategory = selectedCategory, - isSearchEmpty = viewModel.search.isNotEmpty() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f)) + .clickable { + navigator.navigate(AppProfileScreenDestination(app)) + }, + headlineContent = { Text(app.label, style = MaterialTheme.typography.bodyMedium) }, + supportingContent = { Text(app.packageName, style = MaterialTheme.typography.bodySmall) }, + leadingContent = { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(app.packageInfo) + .crossfade(true) + .build(), + contentDescription = app.label, + modifier = Modifier.padding(4.dp).width(36.dp).height(36.dp) ) } - } - } - } - } - } - - if (showBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - showBottomSheet = false - }, - sheetState = bottomSheetState, - dragHandle = { - Surface( - modifier = Modifier.padding(vertical = 11.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - shape = RoundedCornerShape(16.dp) - ) { - Box( - Modifier.size( - width = 32.dp, - height = 4.dp - ) ) } } - ) { - BottomSheetContent( - menuItems = bottomSheetMenuItems, - currentSortType = currentSortType, - onSortTypeChanged = { newSortType -> - viewModel.updateCurrentSortType(newSortType) - scope.launch { - bottomSheetState.hide() - showBottomSheet = false + } + + if (filteredAndSortedAppGroups.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().height(400.dp), + contentAlignment = Alignment.Center + ) { + if ((viewModel.isRefreshing || viewModel.appGroupList.isEmpty()) && viewModel.search.isEmpty()) { + LoadingAnimation(isLoading = true) + } else { + EmptyState( + selectedCategory = viewModel.selectedCategory, + isSearchEmpty = viewModel.search.isNotEmpty() + ) } - }, - selectedCategory = selectedCategory, - onCategorySelected = { newCategory -> - viewModel.updateSelectedCategory(newCategory) - scope.launch { - listState.animateScrollToItem(0) - bottomSheetState.hide() - showBottomSheet = false - } - }, - appCounts = appCounts - ) + } + } } } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SuperUserBottomSheet( + bottomSheetState: SheetState, + onDismiss: () -> Unit, + viewModel: SuperUserViewModel, + appCounts: Map, + backupLauncher: androidx.activity.result.ActivityResultLauncher, + restoreLauncher: androidx.activity.result.ActivityResultLauncher, + scope: CoroutineScope, + listState: androidx.compose.foundation.lazy.LazyListState +) { + val bottomSheetMenuItems = remember(viewModel.showSystemApps) { + listOf( + BottomSheetMenuItem( + icon = Icons.Filled.Refresh, + titleRes = R.string.refresh, + onClick = { + scope.launch { + viewModel.fetchAppList() + bottomSheetState.hide() + onDismiss() + } + } + ), + BottomSheetMenuItem( + icon = if (viewModel.showSystemApps) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + titleRes = if (viewModel.showSystemApps) R.string.hide_system_apps else R.string.show_system_apps, + onClick = { + viewModel.updateShowSystemApps(!viewModel.showSystemApps) + scope.launch { + kotlinx.coroutines.delay(100) + bottomSheetState.hide() + onDismiss() + } + } + ), + BottomSheetMenuItem( + icon = Icons.Filled.Save, + titleRes = R.string.backup_allowlist, + onClick = { + backupLauncher.launch(ModuleModify.createAllowlistBackupIntent()) + scope.launch { + bottomSheetState.hide() + onDismiss() + } + } + ), + BottomSheetMenuItem( + icon = Icons.Filled.RestoreFromTrash, + titleRes = R.string.restore_allowlist, + onClick = { + restoreLauncher.launch(ModuleModify.createAllowlistRestoreIntent()) + scope.launch { + bottomSheetState.hide() + onDismiss() + } + } + ) + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = bottomSheetState, + dragHandle = { + Surface( + modifier = Modifier.padding(vertical = 11.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(16.dp) + ) { + Box(Modifier.size(width = 32.dp, height = 4.dp)) + } + } + ) { + BottomSheetContent( + menuItems = bottomSheetMenuItems, + currentSortType = viewModel.currentSortType, + onSortTypeChanged = { newSortType -> + viewModel.updateCurrentSortType(newSortType) + scope.launch { + bottomSheetState.hide() + onDismiss() + } + }, + selectedCategory = viewModel.selectedCategory, + onCategorySelected = { newCategory -> + viewModel.updateSelectedCategory(newCategory) + scope.launch { + listState.animateScrollToItem(0) + bottomSheetState.hide() + onDismiss() + } + }, + appCounts = appCounts + ) + } +} + @Composable private fun BottomSheetContent( menuItems: List, @@ -521,11 +506,8 @@ private fun BottomSheetContent( appCounts: Map ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp) + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp) ) { - // 标题 Text( text = stringResource(R.string.menu_options), style = MaterialTheme.typography.headlineSmall, @@ -533,7 +515,6 @@ private fun BottomSheetContent( modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) ) - // 菜单选项网格 LazyVerticalGrid( columns = GridCells.Fixed(4), modifier = Modifier.fillMaxWidth(), @@ -542,13 +523,10 @@ private fun BottomSheetContent( verticalArrangement = Arrangement.spacedBy(16.dp) ) { items(menuItems) { menuItem -> - BottomSheetMenuItemView( - menuItem = menuItem - ) + BottomSheetMenuItemView(menuItem = menuItem) } } - // 排序选项 Spacer(modifier = Modifier.height(24.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp)) @@ -626,10 +604,7 @@ private fun CategoryChip( modifier = modifier .fillMaxWidth() .scale(scale) - .clickable( - interactionSource = interactionSource, - indication = null - ) { onClick() }, + .clickable(interactionSource = interactionSource, indication = null) { onClick() }, shape = RoundedCornerShape(12.dp), color = if (isSelected) { MaterialTheme.colorScheme.primaryContainer @@ -639,13 +614,10 @@ private fun CategoryChip( tonalElevation = if (isSelected) 4.dp else 0.dp ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // 分类信息行 Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -665,7 +637,6 @@ private fun CategoryChip( overflow = TextOverflow.Ellipsis ) - // 选中指示器 AnimatedVisibility( visible = isSelected, enter = scaleIn() + fadeIn(), @@ -711,10 +682,7 @@ private fun BottomSheetMenuItemView(menuItem: BottomSheetMenuItem) { modifier = Modifier .fillMaxWidth() .scale(scale) - .clickable( - interactionSource = interactionSource, - indication = null - ) { menuItem.onClick() } + .clickable(interactionSource = interactionSource, indication = null) { menuItem.onClick() } .padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -724,9 +692,7 @@ private fun BottomSheetMenuItemView(menuItem: BottomSheetMenuItem) { color = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) { - Box( - contentAlignment = Alignment.Center - ) { + Box(contentAlignment = Alignment.Center) { Icon( imageVector = menuItem.icon, contentDescription = stringResource(menuItem.titleRes), @@ -746,133 +712,6 @@ private fun BottomSheetMenuItemView(menuItem: BottomSheetMenuItem) { } } -@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) -@Composable -private fun AppItem( - app: SuperUserViewModel.AppInfo, - isSelected: Boolean, - onToggleSelection: () -> Unit, - onClick: () -> Unit, - onLongClick: () -> Unit, - viewModel: SuperUserViewModel -) { - ListItem( - modifier = Modifier - .pointerInput(Unit) { - detectTapGestures( - onLongPress = { onLongClick() }, - onTap = { onClick() } - ) - }, - headlineContent = { Text(app.label) }, - supportingContent = { - Column { - Text(app.packageName) - - Spacer(modifier = Modifier.height(4.dp)) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - if (app.allowSu) { - LabelItem( - text = "ROOT", - ) - } else { - if (Natives.uidShouldUmount(app.uid)) { - LabelItem( - text = "UMOUNT", - style = LabelItemDefaults.style.copy( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ) - ) - } - } - if (app.hasCustomProfile) { - LabelItem( - text = "CUSTOM", - style = LabelItemDefaults.style.copy( - containerColor = MaterialTheme.colorScheme.onTertiary, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - ) - ) - } else if (!app.allowSu) { - LabelItem( - text = "DEFAULT", - style = LabelItemDefaults.style.copy( - containerColor = Color.Gray - ) - ) - } - } - } - }, - leadingContent = { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(app.packageInfo) - .crossfade(true) - .build(), - contentDescription = app.label, - modifier = Modifier - .padding(4.dp) - .width(48.dp) - .height(48.dp) - ) - }, - trailingContent = { - if (viewModel.showBatchActions) { - val checkboxInteractionSource = remember { MutableInteractionSource() } - val isCheckboxPressed by checkboxInteractionSource.collectIsPressedAsState() - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - AnimatedVisibility( - visible = isCheckboxPressed, - enter = expandHorizontally() + fadeIn(), - exit = shrinkHorizontally() + fadeOut() - ) { - Text( - text = if (isSelected) stringResource(R.string.selected) else stringResource(R.string.select), - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(end = 4.dp) - ) - } - Checkbox( - checked = isSelected, - onCheckedChange = { onToggleSelection() }, - interactionSource = checkboxInteractionSource, - ) - } - } - } - ) -} - -@Composable -fun LabelText(label: String) { - Box( - modifier = Modifier - .padding(top = 4.dp, end = 4.dp) - .background( - Color.Black, - shape = RoundedCornerShape(4.dp) - ) - ) { - Text( - text = label, - modifier = Modifier.padding(vertical = 2.dp, horizontal = 5.dp), - style = TextStyle( - fontSize = 8.sp, - color = Color.White, - ) - ) - } -} - @Composable private fun LoadingAnimation( modifier: Modifier = Modifier, @@ -901,9 +740,7 @@ private fun LoadingAnimation( verticalArrangement = Arrangement.Center ) { LinearProgressIndicator( - modifier = Modifier - .width(200.dp) - .height(4.dp), + modifier = Modifier.width(200.dp).height(4.dp), color = MaterialTheme.colorScheme.primary.copy(alpha = alpha), trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) ) @@ -927,9 +764,7 @@ private fun EmptyState( imageVector = if (isSearchEmpty) Icons.Filled.SearchOff else Icons.Filled.Archive, contentDescription = null, tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), - modifier = Modifier - .size(96.dp) - .padding(bottom = 16.dp) + modifier = Modifier.size(96.dp).padding(bottom = 16.dp) ) Text( text = if (isSearchEmpty || selectedCategory == AppCategory.ALL) { @@ -941,4 +776,134 @@ private fun EmptyState( style = MaterialTheme.typography.bodyLarge, ) } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +private fun AppGroupItem( + appGroup: SuperUserViewModel.AppGroup, + isSelected: Boolean, + onToggleSelection: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + viewModel: SuperUserViewModel, + navigator: DestinationsNavigator, + isExpanded: Boolean = false +) { + val mainApp = appGroup.mainApp + + ListItem( + modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onLongPress = { onLongClick() }, + onTap = { onClick() } + ) + }, + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(mainApp.label) + if (appGroup.apps.size > 1) { + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = "${appGroup.apps.size} apps", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + Icon( + imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + } + }, + supportingContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("UID: ${appGroup.uid}") + } + if (appGroup.apps.size == 1) { + Text(mainApp.packageName) + } + + Spacer(modifier = Modifier.height(4.dp)) + + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (appGroup.allowSu) { + LabelItem(text = "ROOT") + } else { + if (Natives.uidShouldUmount(appGroup.uid)) { + LabelItem( + text = "UMOUNT", + style = LabelItemDefaults.style.copy( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) + } + } + if (appGroup.hasCustomProfile) { + LabelItem( + text = "CUSTOM", + style = LabelItemDefaults.style.copy( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) + ) + } else if (!appGroup.allowSu) { + LabelItem( + text = "DEFAULT", + style = LabelItemDefaults.style.copy( + containerColor = Color.Gray + ) + ) + } + } + } + }, + leadingContent = { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(mainApp.packageInfo) + .crossfade(true) + .build(), + contentDescription = mainApp.label, + modifier = Modifier.padding(4.dp).width(48.dp).height(48.dp) + ) + }, + trailingContent = { + if (viewModel.showBatchActions) { + val checkboxInteractionSource = remember { MutableInteractionSource() } + val isCheckboxPressed by checkboxInteractionSource.collectIsPressedAsState() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + AnimatedVisibility( + visible = isCheckboxPressed, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut() + ) { + Text( + text = if (isSelected) stringResource(R.string.selected) else stringResource(R.string.select), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(end = 4.dp) + ) + } + Checkbox( + checked = isSelected, + onCheckedChange = { onToggleSelection() }, + interactionSource = checkboxInteractionSource, + ) + } + } + } + ) } \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt index 41eb32ff..6aa1defa 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Template.kt @@ -3,10 +3,12 @@ package com.sukisu.ultra.ui.screen import android.content.ClipData import android.content.ClipboardManager import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -17,10 +19,13 @@ import androidx.compose.material3.* import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.content.getSystemService import androidx.lifecycle.compose.dropUnlessResumed import androidx.lifecycle.viewmodel.compose.viewModel @@ -253,4 +258,25 @@ private fun TopBar( windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), scrollBehavior = scrollBehavior ) -} \ No newline at end of file +} + +@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, + ) + ) + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt index f0f98101..fc873c57 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/SuperUserViewModel.kt @@ -4,12 +4,12 @@ import android.content.* import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.graphics.drawable.Drawable -import android.os.* +import android.os.IBinder +import android.os.Parcelable import android.util.Log import androidx.compose.runtime.* import androidx.core.content.edit import androidx.lifecycle.ViewModel -import java.io.* import com.sukisu.ultra.Natives import com.sukisu.ultra.ksuApp import com.sukisu.ultra.ui.KsuService @@ -27,7 +27,7 @@ import java.util.concurrent.TimeUnit import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import com.sukisu.zako.IKsuInterface -// 应用分类 + 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"), @@ -35,13 +35,10 @@ enum class AppCategory(val displayNameRes: Int, val persistKey: String) { DEFAULT(com.sukisu.ultra.R.string.category_default_apps, "DEFAULT"); companion object { - fun fromPersistKey(key: String): AppCategory { - return entries.find { it.persistKey == key } ?: ALL - } + 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"), @@ -52,28 +49,24 @@ enum class SortType(val displayNameRes: Int, val persistKey: String) { USAGE_FREQ(com.sukisu.ultra.R.string.sort_usage_freq, "USAGE_FREQ"); companion object { - fun fromPersistKey(key: String): SortType { - return entries.find { it.persistKey == key } ?: NAME_ASC - } + fun fromPersistKey(key: String): SortType = entries.find { it.persistKey == key } ?: NAME_ASC } } -/** - * @author ShirkNeko - * @date 2025/5/31. - */ class SuperUserViewModel : ViewModel() { companion object { private const val TAG = "SuperUserViewModel" private val appsLock = Any() var apps by mutableStateOf>(emptyList()) + var appGroups by mutableStateOf>(emptyList()) @JvmStatic fun getAppIconDrawable(context: Context, packageName: String): Drawable? { val appList = synchronized(appsLock) { apps } - val appDetail = appList.find { it.packageName == packageName } - return appDetail?.packageInfo?.applicationInfo?.loadIcon(context.packageManager) + return appList.find { it.packageName == packageName } + ?.packageInfo?.applicationInfo?.loadIcon(context.packageManager) } + private const val PREFS_NAME = "settings" private const val KEY_SHOW_SYSTEM_APPS = "show_system_apps" private const val KEY_SELECTED_CATEGORY = "selected_category" @@ -90,31 +83,34 @@ class SuperUserViewModel : ViewModel() { val packageInfo: PackageInfo, val profile: Natives.Profile?, ) : Parcelable { - val packageName: String - get() = packageInfo.packageName - val uid: Int - get() = packageInfo.applicationInfo!!.uid - - val allowSu: Boolean - get() = profile != null && profile.allowSu + val packageName: String get() = packageInfo.packageName + val uid: Int get() = packageInfo.applicationInfo!!.uid + val allowSu: Boolean get() = profile?.allowSu == true val hasCustomProfile: Boolean - get() { - if (profile == null) { - return false - } - return if (profile.allowSu) { - !profile.rootUseDefault - } else { - !profile.nonRootUseDefault - } - } + get() = profile?.let { + if (it.allowSu) !it.rootUseDefault else !it.nonRootUseDefault + } ?: false + } + + @Parcelize + data class AppGroup( + val uid: Int, + val apps: List, + val profile: Natives.Profile? + ) : Parcelable { + val mainApp: AppInfo get() = apps.first() + val packageNames: List get() = apps.map { it.packageName } + val allowSu: Boolean get() = profile?.allowSu == true + + val userName: String? get() = Natives.getUserName(uid) + val hasCustomProfile: Boolean + get() = 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, + CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, LinkedBlockingQueue() ) { runnable -> Thread(runnable, "AppProcessing-${System.currentTimeMillis()}").apply { @@ -124,63 +120,40 @@ class SuperUserViewModel : ViewModel() { }.asCoroutineDispatcher() private val appListMutex = Mutex() - private val configChangeListeners = mutableSetOf<(String) -> Unit>() - - private val prefs: SharedPreferences = ksuApp.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val prefs = ksuApp.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) var search by mutableStateOf("") - - var showSystemApps by mutableStateOf(loadShowSystemApps()) + var showSystemApps by mutableStateOf(prefs.getBoolean(KEY_SHOW_SYSTEM_APPS, false)) private set - var selectedCategory by mutableStateOf(loadSelectedCategory()) private set - var currentSortType by mutableStateOf(loadCurrentSortType()) private set var isRefreshing by mutableStateOf(false) private set - - // 批量操作相关状态 var showBatchActions by mutableStateOf(false) internal set var selectedApps by mutableStateOf>(emptySet()) internal set - - // 加载进度状态 var loadingProgress by mutableFloatStateOf(0f) private set - /** - * 从SharedPreferences加载显示系统应用设置 - */ - private fun loadShowSystemApps(): Boolean { - return prefs.getBoolean(KEY_SHOW_SYSTEM_APPS, false) - } - - /** - * 从SharedPreferences加载选择的应用分类 - */ private fun loadSelectedCategory(): AppCategory { - val categoryKey = prefs.getString(KEY_SELECTED_CATEGORY, AppCategory.ALL.persistKey) ?: AppCategory.ALL.persistKey + val categoryKey = prefs.getString(KEY_SELECTED_CATEGORY, AppCategory.ALL.persistKey) + ?: AppCategory.ALL.persistKey return AppCategory.fromPersistKey(categoryKey) } - /** - * 从SharedPreferences加载当前排序方式 - */ private fun loadCurrentSortType(): SortType { - val sortKey = prefs.getString(KEY_CURRENT_SORT_TYPE, SortType.NAME_ASC.persistKey) ?: SortType.NAME_ASC.persistKey + val sortKey = prefs.getString(KEY_CURRENT_SORT_TYPE, SortType.NAME_ASC.persistKey) + ?: SortType.NAME_ASC.persistKey return SortType.fromPersistKey(sortKey) } - /** - * 更新显示系统应用设置并保存到SharedPreferences - */ fun updateShowSystemApps(newValue: Boolean) { showSystemApps = newValue - saveShowSystemApps(newValue) + prefs.edit { putBoolean(KEY_SHOW_SYSTEM_APPS, newValue) } notifyAppListChanged() } @@ -190,50 +163,14 @@ class SuperUserViewModel : ViewModel() { apps = currentApps } - /** - * 更新选择的应用分类并保存到SharedPreferences - */ fun updateSelectedCategory(newCategory: AppCategory) { selectedCategory = newCategory - saveSelectedCategory(newCategory) + prefs.edit { putString(KEY_SELECTED_CATEGORY, newCategory.persistKey) } } - /** - * 更新当前排序方式并保存到SharedPreferences - */ fun updateCurrentSortType(newSortType: SortType) { currentSortType = newSortType - saveCurrentSortType(newSortType) - } - - /** - * 保存显示系统应用设置到SharedPreferences - */ - private fun saveShowSystemApps(value: Boolean) { - prefs.edit { - putBoolean(KEY_SHOW_SYSTEM_APPS, value) - } - Log.d(TAG, "Saved show system apps: $value") - } - - /** - * 保存选择的应用分类到SharedPreferences - */ - private fun saveSelectedCategory(category: AppCategory) { - prefs.edit { - putString(KEY_SELECTED_CATEGORY, category.persistKey) - } - Log.d(TAG, "Saved selected category: ${category.persistKey}") - } - - /** - * 保存当前排序方式到SharedPreferences - */ - private fun saveCurrentSortType(sortType: SortType) { - prefs.edit { - putString(KEY_CURRENT_SORT_TYPE, sortType.persistKey) - } - Log.d(TAG, "Saved current sort type: ${sortType.persistKey}") + prefs.edit { putString(KEY_CURRENT_SORT_TYPE, newSortType.persistKey) } } private val sortedList by derivedStateOf { @@ -244,34 +181,25 @@ class SuperUserViewModel : ViewModel() { else -> 2 } }.then(compareBy(Collator.getInstance(Locale.getDefault()), AppInfo::label)) - apps.sortedWith(comparator).also { - isRefreshing = false - } + apps.sortedWith(comparator).also { isRefreshing = false } } val appList by derivedStateOf { - val filtered = sortedList.filter { - it.label.contains(search, true) || it.packageName.contains( - search, - true - ) || HanziToPinyin.getInstance() - .toPinyinString(it.label).contains(search, true) + sortedList.filter { + it.label.contains(search, true) || + it.packageName.contains(search, true) || + HanziToPinyin.getInstance().toPinyinString(it.label).contains(search, true) }.filter { - it.uid == 2000 || showSystemApps || it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0 + it.uid == 2000 || showSystemApps || + it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0 } - - filtered } - // 切换批量操作模式 fun toggleBatchMode() { showBatchActions = !showBatchActions - if (!showBatchActions) { - clearSelection() - } + if (!showBatchActions) clearSelection() } - // 切换应用选择状态 fun toggleAppSelection(packageName: String) { selectedApps = if (selectedApps.contains(packageName)) { selectedApps - packageName @@ -280,35 +208,14 @@ class SuperUserViewModel : ViewModel() { } } - // 清除所有选择 fun clearSelection() { selectedApps = emptySet() } - // 批量更新权限 - suspend fun updateBatchPermissions(allowSu: Boolean) { - selectedApps.forEach { packageName -> - val app = apps.find { it.packageName == packageName } - app?.let { - val profile = Natives.getAppProfile(packageName, it.uid) - val updatedProfile = profile.copy(allowSu = allowSu) - if (Natives.setAppProfile(updatedProfile)) { - updateAppProfileLocally(packageName, updatedProfile) - notifyConfigChange(packageName) - } - } - } - clearSelection() - showBatchActions = false - refreshAppConfigurations() - } - - // 批量更新权限和umount模块设置 suspend fun updateBatchPermissions(allowSu: Boolean, umountModules: Boolean? = null) { selectedApps.forEach { packageName -> - val app = apps.find { it.packageName == packageName } - app?.let { - val profile = Natives.getAppProfile(packageName, it.uid) + apps.find { it.packageName == packageName }?.let { app -> + val profile = Natives.getAppProfile(packageName, app.uid) val updatedProfile = profile.copy( allowSu = allowSu, umountModules = umountModules ?: profile.umountModules, @@ -325,7 +232,6 @@ class SuperUserViewModel : ViewModel() { refreshAppConfigurations() } - // 更新本地应用配置 fun updateAppProfileLocally(packageName: String, updatedProfile: Natives.Profile) { appListMutex.tryLock().let { locked -> if (locked) { @@ -333,9 +239,7 @@ class SuperUserViewModel : ViewModel() { apps = apps.map { app -> if (app.packageName == packageName) { app.copy(profile = updatedProfile) - } else { - app - } + } else app } } finally { appListMutex.unlock() @@ -354,15 +258,11 @@ class SuperUserViewModel : ViewModel() { } } - /** - * 刷新应用配置状态 - */ suspend fun refreshAppConfigurations() { withContext(appProcessingThreadPool) { supervisorScope { val currentApps = apps.toList() val batches = currentApps.chunked(BATCH_SIZE) - loadingProgress = 0f val updatedApps = batches.mapIndexed { batchIndex, batch -> @@ -376,59 +276,45 @@ class SuperUserViewModel : ViewModel() { app } } - - val progress = (batchIndex + 1).toFloat() / batches.size - loadingProgress = progress - + loadingProgress = (batchIndex + 1).toFloat() / batches.size batchResult } }.awaitAll().flatten() - appListMutex.withLock { - apps = updatedApps - } - + appListMutex.withLock { apps = updatedApps } loadingProgress = 1f - - Log.i(TAG, "Refreshed configurations for ${updatedApps.size} apps") } } } private var serviceConnection: ServiceConnection? = null - private suspend fun connectKsuService( - onDisconnect: () -> Unit = {} - ): IBinder? = suspendCoroutine { continuation -> - val connection = object : ServiceConnection { - override fun onServiceDisconnected(name: ComponentName?) { - onDisconnect() - serviceConnection = null + 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) + } } - - 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) } } - 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 { _ -> + serviceConnection?.let { try { val intent = Intent(ksuApp, KsuService::class.java) com.topjohnwu.superuser.ipc.RootService.stop(intent) @@ -443,9 +329,7 @@ class SuperUserViewModel : ViewModel() { isRefreshing = true loadingProgress = 0f - val binder = connectKsuService() ?: run { - isRefreshing = false; return - } + val binder = connectKsuService() ?: run { isRefreshing = false; return } withContext(Dispatchers.IO) { val pm = ksuApp.packageManager @@ -468,27 +352,54 @@ class SuperUserViewModel : ViewModel() { ) } } - start += page.size loadingProgress = start.toFloat() / total } - synchronized(appsLock) { - apps - } - stopKsuService() appListMutex.withLock { - apps = result.filter { it.packageName != ksuApp.packageName } + 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) + } + }.filter { group -> + group.uid == 2000 || showSystemApps || + group.apps.any { it.packageInfo.applicationInfo!!.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0 } + } + } + + private fun groupAppsByUid(appList: List): List { + 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 { + when { + it.allowSu -> 0 + it.hasCustomProfile -> 1 + else -> 2 + } + }.thenBy(Collator.getInstance(Locale.getDefault())) { + it.userName?.takeIf { name -> name.isNotBlank() } ?: it.uid.toString() + }.thenBy(Collator.getInstance(Locale.getDefault())) { it.mainApp.label } + ) +} override fun onCleared() { super.onCleared() try {