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 b67c9593..867a0331 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/Natives.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/Natives.kt @@ -19,11 +19,38 @@ object Natives { // 12143: breaking: new supercall impl const val MINIMAL_SUPPORTED_KERNEL = 22000 + // Get full version + external fun getFullVersion(): String + const val MINIMAL_SUPPORTED_KERNEL_FULL = "v4.0.0" + + // 12040: Support disable sucompat mode const val KERNEL_SU_DOMAIN = "u:r:su:s0" const val ROOT_UID = 0 const val ROOT_GID = 0 + fun isVersionLessThan(v1Full: String, v2Full: String): Boolean { + fun extractVersionParts(version: String): List { + 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("kernelsu") } @@ -86,6 +113,67 @@ object Natives { */ external fun getUserName(uid: Int): String? + /** + * Su Log can be enabled/disabled. + * 0: disabled + * 1: enabled + * negative : error + */ + external fun isSuLogEnabled(): Boolean + external fun setSuLogEnabled(enabled: Boolean): Boolean + + external fun isKPMEnabled(): Boolean + external fun getHookType(): String + + /** + * 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? + + /** + * 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 + + + private const val NON_ROOT_DEFAULT_PROFILE_KEY = "$" private const val NOBODY_UID = 9999 @@ -107,9 +195,41 @@ object Natives { } fun requireNewKernel(): Boolean { - return version != -1 && version < MINIMAL_SUPPORTED_KERNEL + if (version != -1 && version < MINIMAL_SUPPORTED_KERNEL) return true + return isVersionLessThan(getFullVersion(), MINIMAL_SUPPORTED_KERNEL_FULL) } + @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 = emptyList() + ) : Parcelable + + @Immutable + @Parcelize + @Keep + data class ManagerInfo( + val uid: Int = 0, + val signatureIndex: Int = 0 + ) : Parcelable + @Immutable @Parcelize @Keep diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt index c382f6a1..fe67825c 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Home.kt @@ -2,6 +2,7 @@ package com.sukisu.ultra.ui.screen import android.content.Context import android.os.Build +import android.os.Process.myUid import android.system.Os import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility @@ -71,10 +72,7 @@ import com.sukisu.ultra.getKernelVersion import com.sukisu.ultra.ui.component.DropdownItem import com.sukisu.ultra.ui.component.RebootListPopup import com.sukisu.ultra.ui.component.rememberConfirmDialog -import com.sukisu.ultra.ui.util.checkNewVersion -import com.sukisu.ultra.ui.util.getModuleCount -import com.sukisu.ultra.ui.util.getSELinuxStatus -import com.sukisu.ultra.ui.util.getSuperuserCount +import com.sukisu.ultra.ui.util.* import com.sukisu.ultra.ui.util.module.LatestVersionInfo import com.sukisu.ultra.ui.util.reboot import com.sukisu.ultra.ui.util.rootAvailable @@ -323,7 +321,7 @@ private fun StatusCard( val workingMode = when (lkmMode) { null -> "" true -> " " - else -> " " + else -> " " } val workingText = "${stringResource(id = R.string.home_working)}$workingMode$safeMode" @@ -590,6 +588,8 @@ fun DonateCard() { @Composable private fun InfoCard() { + val manualHookText = stringResource(R.string.manual_hook) + val inlineHookText = stringResource(R.string.inline_hook) @Composable fun InfoText( title: String, @@ -609,10 +609,29 @@ private fun InfoCard() { modifier = Modifier.padding(top = 2.dp, bottom = bottomPadding) ) } + + val context = LocalContext.current + val uname = Os.uname() + val managerVersion = getManagerVersion(context) + val susfsPair by produceState(initialValue = "" to "") { + value = withContext(Dispatchers.IO) { + val rawFeature = getSuSFSFeatures() + val status = if (rawFeature.isNotEmpty() && !rawFeature.startsWith("[-]")) "Supported" else rawFeature + if (status == "Supported") { + val version = getSuSFSVersion() + val hook = when (Natives.getHookType()) { + "Manual" -> "($manualHookText)" + "Inline" -> "($inlineHookText)" + else -> "(${Natives.getHookType()})" + } + status to "$version $hook".trim() + } else { + "" to "" + } + } + } + Card { - val context = LocalContext.current - val uname = Os.uname() - val managerVersion = getManagerVersion(context) Column( modifier = Modifier .fillMaxWidth() @@ -626,15 +645,46 @@ private fun InfoCard() { title = stringResource(R.string.home_manager_version), content = "${managerVersion.first} (${managerVersion.second})" ) + val managersList = remember { Natives.getManagersList() } + val dynamicValid = remember { Natives.getDynamicManager()?.isValid() == true } + if (dynamicValid && managersList != null) { + val signatureMap = managersList.managers.groupBy { it.signatureIndex } + val showDetailed = signatureMap.size > 1 || signatureMap.keys.firstOrNull() != 0 + if (showDetailed) { + val managersText = buildString { + signatureMap.toSortedMap().forEach { (idx, list) -> + append(list.joinToString(", ") { "UID: ${it.uid}" }) + append( + when (idx) { + 0 -> " (${stringResource(R.string.default_signature)})" + 100 -> " (${stringResource(R.string.dynamic_managerature)})" + else -> " (${stringResource(R.string.signature_index, idx)})" + } + ) + append(" | ") + } + }.trimEnd(' ', '|') + InfoText( + title = stringResource(R.string.multi_manager_list), + content = managersText + ) + } + } InfoText( title = stringResource(R.string.home_fingerprint), content = Build.FINGERPRINT ) InfoText( title = stringResource(R.string.home_selinux_status), - content = getSELinuxStatus(), - bottomPadding = 0.dp + content = getSELinuxStatus() ) + if (susfsPair.first == "Supported" && susfsPair.second.isNotEmpty()) { + InfoText( + title = stringResource(R.string.home_susfs_version), + content = susfsPair.second, + bottomPadding = 0.dp + ) + } } } } diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/LogViewer.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/LogViewer.kt new file mode 100644 index 00000000..30393088 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/LogViewer.kt @@ -0,0 +1,901 @@ +package com.sukisu.ultra.ui.screen + +import android.content.Context +import android.os.Process.myUid +import androidx.compose.animation.* +import androidx.compose.foundation.background +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.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.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 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.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.* +import top.yukonga.miuix.kmp.extra.SuperArrow +import top.yukonga.miuix.kmp.extra.SuperDialog +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Back +import top.yukonga.miuix.kmp.icon.icons.useful.Delete +import top.yukonga.miuix.kmp.icon.icons.useful.Refresh +import top.yukonga.miuix.kmp.icon.icons.basic.Search +import top.yukonga.miuix.kmp.theme.MiuixTheme +import java.time.* +import java.time.format.DateTimeFormatter + +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) { + 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 { + 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() +} + +@Destination +@Composable +fun LogViewer(navigator: DestinationsNavigator) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var logEntries by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + var filterType by rememberSaveable { mutableStateOf(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 + + 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 + } + } + + var showClearDialog by remember { mutableStateOf(false) } + + 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 = { + TopAppBar( + title = stringResource(R.string.log_viewer_title), + navigationIcon = { + IconButton( + onClick = { navigator.navigateUp() }, + modifier = Modifier.padding(start = 12.dp) + ) { + Icon( + imageVector = MiuixIcons.Useful.Back, + contentDescription = stringResource(R.string.log_viewer_back) + ) + } + }, + actions = { + IconButton(onClick = { showSearchBar = !showSearchBar }) { + Icon( + imageVector = MiuixIcons.Basic.Search, + contentDescription = stringResource(R.string.log_viewer_search) + ) + } + IconButton(onClick = onManualRefresh) { + Icon( + imageVector = MiuixIcons.Useful.Refresh, + contentDescription = stringResource(R.string.log_viewer_refresh) + ) + } + IconButton( + onClick = { showClearDialog = true }, + modifier = Modifier.padding(end = 12.dp) + ) { + Icon( + imageVector = MiuixIcons.Useful.Delete, + contentDescription = stringResource(R.string.log_viewer_clear_logs) + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + AnimatedVisibility( + visible = showSearchBar, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM), + label = stringResource(R.string.log_viewer_search_placeholder) + ) + } + + 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() + ) + } + } + } + + val showClearDialogState = remember { mutableStateOf(showClearDialog) } + + LaunchedEffect(showClearDialog) { + showClearDialogState.value = showClearDialog + } + + LaunchedEffect(showClearDialogState.value) { + showClearDialog = showClearDialogState.value + } + + SuperDialog( + show = showClearDialogState, + onDismissRequest = { showClearDialog = false }, + content = { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp, bottom = 12.dp), + text = stringResource(R.string.log_viewer_clear_logs), + style = MiuixTheme.textStyles.title4, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + color = MiuixTheme.colorScheme.onSurface + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + text = stringResource(R.string.log_viewer_clear_logs_confirm), + style = MiuixTheme.textStyles.body2, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + text = stringResource(android.R.string.cancel), + onClick = { showClearDialog = false }, + modifier = Modifier.weight(1f) + ) + TextButton( + text = stringResource(android.R.string.ok), + onClick = { + showClearDialog = false + scope.launch { + clearLogs() + loadPage(0, true) + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary() + ) + } + } + ) +} + +private val CONTROL_PANEL_SPACING_SMALL = 4.dp +private val CONTROL_PANEL_SPACING_MEDIUM = 8.dp +private val CONTROL_PANEL_SPACING_LARGE = 12.dp + +@Composable +private fun LogControlPanel( + filterType: LogType?, + onFilterTypeSelected: (LogType?) -> Unit, + logCount: Int, + totalCount: Int, + pageInfo: LogPageInfo, + excludedSubTypes: Set, + onExcludeToggle: (LogExclType) -> Unit +) { + var isExpanded by rememberSaveable { mutableStateOf(true) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM) + ) { + Column { + SuperArrow( + title = stringResource(R.string.log_viewer_settings), + onClick = { isExpanded = !isExpanded }, + rightText = if (isExpanded) + stringResource(R.string.log_viewer_collapse) + else + stringResource(R.string.log_viewer_expand) + ) + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.padding(horizontal = SPACING_MEDIUM) + ) { + Text( + text = stringResource(R.string.log_viewer_filter_type), + style = MiuixTheme.textStyles.subtitle, + color = MiuixTheme.colorScheme.onSurfaceVariantActions + ) + Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_MEDIUM)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_MEDIUM)) { + item { + FilterChip( + text = stringResource(R.string.log_viewer_all_types), + selected = filterType == null, + onClick = { onFilterTypeSelected(null) } + ) + } + items(LogType.entries.toTypedArray()) { type -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(type.color, RoundedCornerShape(3.dp)) + ) + FilterChip( + text = type.displayName, + selected = filterType == type, + onClick = { onFilterTypeSelected(if (filterType == type) null else type) } + ) + } + } + } + + Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_LARGE)) + + Text( + text = stringResource(R.string.log_viewer_exclude_subtypes), + style = MiuixTheme.textStyles.subtitle, + color = MiuixTheme.colorScheme.onSurfaceVariantActions + ) + Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_MEDIUM)) + LazyRow(horizontalArrangement = Arrangement.spacedBy(CONTROL_PANEL_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 + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(excl.color, RoundedCornerShape(3.dp)) + ) + FilterChip( + text = label, + selected = excl in excludedSubTypes, + onClick = { onExcludeToggle(excl) } + ) + } + } + } + + Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_LARGE)) + + Column(verticalArrangement = Arrangement.spacedBy(CONTROL_PANEL_SPACING_SMALL)) { + Text( + text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + if (pageInfo.totalPages > 0) { + Text( + text = stringResource( + R.string.log_viewer_page_info, + pageInfo.currentPage + 1, + pageInfo.totalPages, + pageInfo.totalLogs + ), + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + } + if (pageInfo.totalLogs >= MAX_TOTAL_LOGS) { + Text( + text = stringResource(R.string.log_viewer_too_many_logs, MAX_TOTAL_LOGS), + style = MiuixTheme.textStyles.body2, + color = Color(0xFFE53935) + ) + } + } + + Spacer(modifier = Modifier.height(CONTROL_PANEL_SPACING_LARGE)) + } + } + } + } +} + +@Composable +private fun LogList( + entries: List, + 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_MEDIUM) + ) { + 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() + ) { + 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 = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + } + } + } + } +} + +@Composable +private fun LogEntryCard(entry: LogEntry) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { expanded = !expanded } + ) { + Column( + modifier = Modifier.padding(SPACING_LARGE) + ) { + 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 = MiuixTheme.textStyles.subtitle, + fontWeight = FontWeight.Bold + ) + } + Text( + text = entry.timestamp, + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + } + + Spacer(modifier = Modifier.height(SPACING_SMALL)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "UID: ${entry.uid}", + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + Text( + text = "PID: ${entry.pid}", + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + } + + Text( + text = entry.comm, + style = MiuixTheme.textStyles.body1, + 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 = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + 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() + Spacer(modifier = Modifier.height(SPACING_MEDIUM)) + Text( + text = stringResource(R.string.log_viewer_raw_log), + style = MiuixTheme.textStyles.subtitle, + color = MiuixTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(SPACING_SMALL)) + Text( + text = entry.rawLine, + style = MiuixTheme.textStyles.body2, + fontFamily = FontFamily.Monospace, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + } + } + } + } +} + +@Composable +private fun EmptyLogState( + hasLogs: Boolean, + onRefresh: () -> Unit +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(SPACING_LARGE) + ) { + Text( + text = stringResource( + if (hasLogs) R.string.log_viewer_no_matching_logs + else R.string.log_viewer_no_logs + ), + style = MiuixTheme.textStyles.headline2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + Button( + onClick = onRefresh + ) { + Text(stringResource(R.string.log_viewer_refresh)) + } + } + } +} + +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, 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 { + 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? { + 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) +} + +@Composable +private fun FilterChip( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + TextButton( + text = text, + onClick = onClick, + modifier = modifier, + colors = if (selected) { + ButtonDefaults.textButtonColorsPrimary() + } else { + ButtonDefaults.textButtonColors() + } + ) +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt index 77bbc722..197ec4eb 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Settings.kt @@ -47,6 +47,7 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.AboutScreenDestination import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination +import com.ramcosta.composedestinations.generated.destinations.LogViewerDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeStyle @@ -112,6 +113,7 @@ fun SettingPager( val uninstallDialog = UninstallDialog(showUninstallDialog, navigator) val showSendLogDialog = rememberSaveable { mutableStateOf(false) } val sendLogDialog = SendLogDialog(showSendLogDialog, loadingDialog) + var isSuLogEnabled by remember { mutableStateOf(Natives.isSuLogEnabled()) } LazyColumn( modifier = Modifier @@ -369,6 +371,59 @@ fun SettingPager( } } ) + + var SuLogMode by rememberSaveable { + mutableIntStateOf( + run { + val currentEnabled = Natives.isSuLogEnabled() + val savedPersist = prefs.getInt("sulog_mode", 0) + if (savedPersist == 2) 2 else if (!currentEnabled) 1 else 0 + } + ) + } + SuperDropdown( + title = stringResource(id = R.string.settings_disable_sulog), + summary = stringResource(id = R.string.settings_disable_sulog_summary), + items = modeItems, + leftAction = { + Icon( + Icons.Rounded.RemoveCircle, + modifier = Modifier.padding(end = 16.dp), + contentDescription = stringResource(id = R.string.settings_disable_sulog), + tint = colorScheme.onBackground + ) + }, + selectedIndex = SuLogMode, + onSelectedIndexChange = { index -> + when (index) { + // Default: enable and save to persist + 0 -> if (Natives.setSuLogEnabled(true)) { + execKsud("feature save", true) + prefs.edit { putInt("sulog_mode", 0) } + SuLogMode = 0 + isSuLogEnabled = true + } + + // Temporarily disable: save enabled state first, then disable + 1 -> if (Natives.setSuLogEnabled(true)) { + execKsud("feature save", true) + if (Natives.setSuLogEnabled(false)) { + prefs.edit { putInt("sulog_mode", 0) } + SuLogMode = 1 + isSuLogEnabled = false + } + } + + // Permanently disable: disable and save + 2 -> if (Natives.setSuLogEnabled(false)) { + execKsud("feature save", true) + prefs.edit { putInt("sulog_mode", 2) } + SuLogMode = 2 + isSuLogEnabled = false + } + } + } + ) } Card( @@ -452,6 +507,24 @@ fun SettingPager( .padding(vertical = 12.dp) .fillMaxWidth(), ) { + if (isSuLogEnabled) { + val sulog = stringResource(id = R.string.log_viewer_view_logs) + SuperArrow( + title = sulog, + leftAction = { + Icon( + Icons.Rounded.BugReport, + modifier = Modifier.padding(end = 16.dp), + contentDescription = sulog, + tint = colorScheme.onBackground + ) + }, + onClick = { + navigator.navigate(LogViewerDestination) { + } + } + ) + } SuperArrow( title = stringResource(id = R.string.send_log), leftAction = { diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt index 277f0fbc..e4b679ab 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt @@ -63,7 +63,7 @@ fun download( } fun checkNewVersion(): LatestVersionInfo { - val url = "https://api.github.com/repos/tiann/KernelSU/releases/latest" + val url = "https://api.github.com/repos/SukiSU-Ultra/SukiSU-Ultra/releases/latest" // default null value if failed val defaultValue = LatestVersionInfo() runCatching { diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt index 4492ff83..d82f595b 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/KsuCli.kt @@ -472,3 +472,251 @@ fun restartApp(packageName: String) { forceStopApp(packageName) launchApp(packageName) } + +// 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(), 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 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 +} diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 36d2398a..f12fd138 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -158,4 +158,39 @@ 成功撤销卸载 %s 撤销卸载 %s 失败 包含 %1$d 个应用 + SuSFS 版本 + Manual Hook + Inline Hook + 活跃管理器 + SukiSU + Dynamic + 签名 %1$d + 禁用超级用户日志 + 禁用 KernelSU 超级用户访问记录 + + 日志查看器 + 返回 + 搜索 + 清空日志 + 确定要清空选中的日志文件吗?此操作无法撤销。 + 日志清空成功 + 按类型筛选 + 所有类型 + 显示 %1$d / %2$d 条记录 + 未找到日志 + 未找到匹配的日志 + 刷新 + 原始日志 + 按 UID、命令或详情搜索… + 清除搜索 + 查看 KernelSU 超级用户访问日志 + 排除子类型 + 当前应用 + 页面: %1$d/%2$d | 总日志: %3$d + 日志过多,仅显示最新 %1$d 条 + 加载更多日志 + 已显示所有日志 + 设置 + 收起 + 展开 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 49810248..396ff26c 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -160,4 +160,39 @@ Successfully canceled uninstall of %s Failed to undo uninstall: %s Contains %d apps + SuSFS Version + Manual Hook + Inline Hook + Active Manager + SukiSU + Dynamic + Signature %1$d + Disable superuser logging + Disable KernelSU superuser access logging + + Log Viewer + Back + Search + Clear Logs + Are you sure you want to clear the selected log file? This action cannot be undone. + Logs cleared successfully + Filter by Type + All Types + Showing %1$d of %2$d entries + No logs found + No matching logs found + Refresh + Raw Log + Search by UID, command, or details… + Clear search + View KernelSU superuser access logs + Exclude sub-types + Current App + Page: %1$d/%2$d | Total logs: %3$d + Too many logs, showing only the latest %1$d entries + Load More Logs + All logs displayed + Settings + Collapse + Expand