Step 1: Import susfs and sulog to modify

This commit is contained in:
ShirkNeko
2025-11-19 18:45:00 +08:00
parent a14551b3ec
commit 4f79c94ab9
8 changed files with 1474 additions and 12 deletions

View File

@@ -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<Int> {
val match = Regex("""v\d+(\.\d+)*""").find(version)
val simpleVersion = match?.value ?: version
return simpleVersion.trimStart('v').split('.').map { it.toIntOrNull() ?: 0 }
}
val v1Parts = extractVersionParts(v1Full)
val v2Parts = extractVersionParts(v2Full)
val maxLength = maxOf(v1Parts.size, v2Parts.size)
for (i in 0 until maxLength) {
val num1 = v1Parts.getOrElse(i) { 0 }
val num2 = v2Parts.getOrElse(i) { 0 }
if (num1 != num2) return num1 < num2
}
return false
}
fun getSimpleVersionFull(): String = getFullVersion().let { version ->
Regex("""v\d+(\.\d+)*""").find(version)?.value ?: version
}
init {
System.loadLibrary("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<ManagerInfo> = emptyList()
) : Parcelable
@Immutable
@Parcelize
@Keep
data class ManagerInfo(
val uid: Int = 0,
val signatureIndex: Int = 0
) : Parcelable
@Immutable
@Parcelize
@Keep

View File

@@ -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 -> " <LKM>"
else -> " <GKI>"
else -> " <Built-in>"
}
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
)
}
}
}
}

View File

@@ -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<LogExclType>) {
val prefs = context.getSharedPreferences("sulog", Context.MODE_PRIVATE)
val nameSet = types.map { it.name }.toSet()
prefs.edit { putStringSet("excluded_subtypes", nameSet) }
}
private fun loadExcludedSubTypes(context: Context): Set<LogExclType> {
val prefs = context.getSharedPreferences("sulog", Context.MODE_PRIVATE)
val nameSet = prefs.getStringSet("excluded_subtypes", emptySet()) ?: emptySet()
return nameSet.mapNotNull { name ->
LogExclType.entries.firstOrNull { it.name == name }
}.toSet()
}
@Destination<RootGraph>
@Composable
fun LogViewer(navigator: DestinationsNavigator) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var logEntries by remember { mutableStateOf<List<LogEntry>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
var filterType by rememberSaveable { mutableStateOf<LogType?>(null) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var showSearchBar by rememberSaveable { mutableStateOf(false) }
var pageInfo by remember { mutableStateOf(LogPageInfo()) }
var lastLogFileHash by remember { mutableStateOf("") }
val currentUid = remember { myUid().toString() }
val initialExcluded = remember {
loadExcludedSubTypes(context)
}
var excludedSubTypes by rememberSaveable { mutableStateOf(initialExcluded) }
LaunchedEffect(excludedSubTypes) {
saveExcludedSubTypes(context, excludedSubTypes)
}
val filteredEntries = remember(
logEntries, filterType, searchQuery, excludedSubTypes
) {
logEntries.filter { entry ->
val matchesSearch = searchQuery.isEmpty() ||
entry.comm.contains(searchQuery, ignoreCase = true) ||
entry.details.contains(searchQuery, ignoreCase = true) ||
entry.uid.contains(searchQuery, ignoreCase = true)
if (LogExclType.CURRENT_APP in excludedSubTypes && entry.uid == currentUid) return@filter false
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<LogExclType>,
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<LogEntry>,
pageInfo: LogPageInfo,
isLoading: Boolean,
onLoadMore: () -> Unit,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = modifier,
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
verticalArrangement = Arrangement.spacedBy(SPACING_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<LogEntry>, LogPageInfo, String) -> Unit
) {
withContext(Dispatchers.IO) {
try {
val shell = getRootShell()
val statResult = runCmd(shell, "stat -c '%Y %s' $LOGS_PATCH 2>/dev/null || echo '0 0'")
val currentHash = statResult.trim()
if (!forceRefresh && currentHash == lastHash && currentHash != "0 0") {
withContext(Dispatchers.Main) {
onLoaded(emptyList(), LogPageInfo(), currentHash)
}
return@withContext
}
val totalLinesResult = runCmd(shell, "wc -l < $LOGS_PATCH 2>/dev/null || echo '0'")
val totalLines = totalLinesResult.trim().toIntOrNull() ?: 0
if (totalLines == 0) {
withContext(Dispatchers.Main) {
onLoaded(emptyList(), LogPageInfo(), currentHash)
}
return@withContext
}
val effectiveTotal = minOf(totalLines, MAX_TOTAL_LOGS)
val totalPages = (effectiveTotal + PAGE_SIZE - 1) / PAGE_SIZE
val startLine = if (page == 0) {
maxOf(1, totalLines - effectiveTotal + 1)
} else {
val skipLines = page * PAGE_SIZE
maxOf(1, totalLines - effectiveTotal + 1 + skipLines)
}
val endLine = minOf(startLine + PAGE_SIZE - 1, totalLines)
if (startLine > totalLines) {
withContext(Dispatchers.Main) {
onLoaded(emptyList(), LogPageInfo(page, totalPages, effectiveTotal, false), currentHash)
}
return@withContext
}
val result = runCmd(shell, "sed -n '${startLine},${endLine}p' $LOGS_PATCH 2>/dev/null || echo ''")
val entries = parseLogEntries(result)
val hasMore = endLine < totalLines
val pageInfo = LogPageInfo(page, totalPages, effectiveTotal, hasMore)
withContext(Dispatchers.Main) {
onLoaded(entries, pageInfo, currentHash)
}
} catch (_: Exception) {
withContext(Dispatchers.Main) {
onLoaded(emptyList(), LogPageInfo(), lastHash)
}
}
}
}
private suspend fun clearLogs() {
withContext(Dispatchers.IO) {
try {
val shell = getRootShell()
runCmd(shell, "echo '' > $LOGS_PATCH")
} catch (_: Exception) {
}
}
}
private fun parseLogEntries(logContent: String): List<LogEntry> {
if (logContent.isBlank()) return emptyList()
val entries = logContent.lines()
.filter { it.isNotBlank() && it.startsWith("[") }
.mapNotNull { line ->
try {
parseLogLine(line)
} catch (_: Exception) {
null
}
}
return entries.reversed()
}
private fun utcToLocal(utc: String): String {
return try {
val instant = LocalDateTime.parse(utc, utcFormatter).atOffset(ZoneOffset.UTC).toInstant()
val local = instant.atZone(ZoneId.systemDefault())
local.format(localFormatter)
} catch (_: Exception) {
utc
}
}
private fun parseLogLine(line: String): LogEntry? {
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()
}
)
}

View File

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

View File

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

View File

@@ -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<String>(), null)
.exec().out
.joinToString("\n")
}
fun listKpmModules(): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm list"
return try {
runCmd(shell, cmd).trim()
} catch (e: Exception) {
Log.e(TAG, "Failed to list KPM modules", e)
""
}
}
fun getKpmModuleInfo(name: String): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm info $name"
return try {
runCmd(shell, cmd).trim()
} catch (e: Exception) {
Log.e(TAG, "Failed to get KPM module info: $name", e)
""
}
}
fun controlKpmModule(name: String, args: String? = null): Int {
val shell = getRootShell()
val cmd = """${getKsuDaemonPath()} kpm control $name "${args ?: ""}""""
val result = runCmd(shell, cmd)
return result.trim().toIntOrNull() ?: -1
}
fun getKpmVersion(): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} kpm version"
val result = ShellUtils.fastCmd(shell, cmd)
return result.trim()
}
fun getSuSFSDaemonPath(): String {
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libksu_susfs.so"
}
fun getSuSFSVersion(): String {
val shell = getRootShell()
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} show version")
return result
}
fun getSuSFSVariant(): String {
val shell = getRootShell()
val result = ShellUtils.fastCmd(shell, "${getSuSFSDaemonPath()} show variant")
return result
}
fun getSuSFSFeatures(): String {
val shell = getRootShell()
val cmd = "${getSuSFSDaemonPath()} show enabled_features"
return runCmd(shell, cmd)
}
fun getZygiskImplement(): String {
val shell = getRootShell()
val zygiskModuleIds = listOf(
"zygisksu",
"rezygisk",
"shirokozygisk"
)
for (moduleId in zygiskModuleIds) {
val modulePath = "/data/adb/modules/$moduleId"
when {
ShellUtils.fastCmdResult(shell, "test -f $modulePath/module.prop && test ! -f $modulePath/disable") -> {
val result = ShellUtils.fastCmd(shell, "grep '^name=' $modulePath/module.prop | cut -d'=' -f2")
Log.i(TAG, "Zygisk implement: $result")
return result
}
}
}
Log.i(TAG, "Zygisk implement: None")
return "None"
}
fun getUidScannerDaemonPath(): String {
return ksuApp.applicationInfo.nativeLibraryDir + File.separator + "libuid_scanner.so"
}
private const val targetPath = "/data/adb/uid_scanner"
fun ensureUidScannerExecutable(): Boolean {
val shell = getRootShell()
val uidScannerPath = getUidScannerDaemonPath()
if (!ShellUtils.fastCmdResult(shell, "test -f $targetPath")) {
val copyResult = ShellUtils.fastCmdResult(shell, "cp $uidScannerPath $targetPath")
if (!copyResult) {
return false
}
}
val result = ShellUtils.fastCmdResult(shell, "chmod 755 $targetPath")
return result
}
fun setUidAutoScan(enabled: Boolean): Boolean {
val shell = getRootShell()
if (!ensureUidScannerExecutable()) {
return false
}
val enableValue = if (enabled) 1 else 0
val cmd = "$targetPath --auto-scan $enableValue && $targetPath reload"
val result = ShellUtils.fastCmdResult(shell, cmd)
val throneResult = Natives.setUidScannerEnabled(enabled)
return result && throneResult
}
fun setUidMultiUserScan(enabled: Boolean): Boolean {
val shell = getRootShell()
if (!ensureUidScannerExecutable()) {
return false
}
val enableValue = if (enabled) 1 else 0
val cmd = "$targetPath --multi-user $enableValue && $targetPath reload"
val result = ShellUtils.fastCmdResult(shell, cmd)
return result
}
fun getUidMultiUserScan(): Boolean {
val shell = getRootShell()
val cmd = "grep 'multi_user_scan=' /data/misc/user_uid/uid_scanner.conf | cut -d'=' -f2"
val result = ShellUtils.fastCmd(shell, cmd).trim()
return try {
result.toInt() == 1
} catch (_: NumberFormatException) {
false
}
}
fun cleanRuntimeEnvironment(): Boolean {
val shell = getRootShell()
return try {
try {
ShellUtils.fastCmd(shell, "/data/adb/uid_scanner stop")
} catch (_: Exception) {
}
ShellUtils.fastCmdResult(shell, "rm -rf /data/misc/user_uid")
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/uid_scanner")
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/ksu/bin/user_uid")
ShellUtils.fastCmdResult(shell, "rm -rf /data/adb/service.d/uid_scanner.sh")
Natives.clearUidScannerEnvironment()
true
} catch (_: Exception) {
false
}
}
fun readUidScannerFile(): Boolean {
val shell = getRootShell()
return try {
ShellUtils.fastCmd(shell, "cat /data/adb/ksu/.uid_scanner").trim() == "1"
} catch (_: Exception) {
false
}
}
fun addUmountPath(path: String, flags: Int): Boolean {
val shell = getRootShell()
val flagsArg = if (flags >= 0) "--flags $flags" else ""
val cmd = "${getKsuDaemonPath()} umount add $path $flagsArg"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "add umount path $path result: $result")
return result
}
fun removeUmountPath(path: String): Boolean {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount remove $path"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "remove umount path $path result: $result")
return result
}
fun listUmountPaths(): String {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount list"
return try {
runCmd(shell, cmd).trim()
} catch (e: Exception) {
Log.e(TAG, "Failed to list umount paths", e)
""
}
}
fun clearCustomUmountPaths(): Boolean {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount clear-custom"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "clear custom umount paths result: $result")
return result
}
fun saveUmountConfig(): Boolean {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount save"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "save umount config result: $result")
return result
}
fun applyUmountConfigToKernel(): Boolean {
val shell = getRootShell()
val cmd = "${getKsuDaemonPath()} umount apply"
val result = ShellUtils.fastCmdResult(shell, cmd)
Log.i(TAG, "apply umount config to kernel result: $result")
return result
}

View File

@@ -158,4 +158,39 @@
<string name="module_undo_uninstall_success">成功撤销卸载 %s</string>
<string name="module_undo_uninstall_failed">撤销卸载 %s 失败</string>
<string name="group_contains_apps">包含 %1$d 个应用</string>
<string name="home_susfs_version">SuSFS 版本</string>
<string name="manual_hook">Manual Hook</string>
<string name="inline_hook">Inline Hook</string>
<string name="multi_manager_list">活跃管理器</string>
<string name="default_signature">SukiSU</string>
<string name="dynamic_managerature">Dynamic</string>
<string name="signature_index">签名 %1$d</string>
<string name="settings_disable_sulog"> 禁用超级用户日志 </string>
<string name="settings_disable_sulog_summary">禁用 KernelSU 超级用户访问记录</string>
<!-- Log Viewer -->
<string name="log_viewer_title">日志查看器</string>
<string name="log_viewer_back">返回</string>
<string name="log_viewer_search">搜索</string>
<string name="log_viewer_clear_logs">清空日志</string>
<string name="log_viewer_clear_logs_confirm">确定要清空选中的日志文件吗?此操作无法撤销。</string>
<string name="log_viewer_logs_cleared">日志清空成功</string>
<string name="log_viewer_filter_type">按类型筛选</string>
<string name="log_viewer_all_types">所有类型</string>
<string name="log_viewer_showing_entries">显示 %1$d / %2$d 条记录</string>
<string name="log_viewer_no_logs">未找到日志</string>
<string name="log_viewer_no_matching_logs">未找到匹配的日志</string>
<string name="log_viewer_refresh">刷新</string>
<string name="log_viewer_raw_log">原始日志</string>
<string name="log_viewer_search_placeholder">按 UID、命令或详情搜索…</string>
<string name="log_viewer_clear_search">清除搜索</string>
<string name="log_viewer_view_logs">查看 KernelSU 超级用户访问日志</string>
<string name="log_viewer_exclude_subtypes">排除子类型</string>
<string name="log_viewer_exclude_current_app">当前应用</string>
<string name="log_viewer_page_info">页面: %1$d/%2$d | 总日志: %3$d</string>
<string name="log_viewer_too_many_logs">日志过多,仅显示最新 %1$d 条</string>
<string name="log_viewer_load_more">加载更多日志</string>
<string name="log_viewer_all_logs_loaded">已显示所有日志</string>
<string name="log_viewer_settings">设置</string>
<string name="log_viewer_collapse">收起</string>
<string name="log_viewer_expand">展开</string>
</resources>

View File

@@ -160,4 +160,39 @@
<string name="module_undo_uninstall_success">Successfully canceled uninstall of %s</string>
<string name="module_undo_uninstall_failed">Failed to undo uninstall: %s</string>
<string name="group_contains_apps">Contains %d apps</string>
<string name="home_susfs_version">SuSFS Version</string>
<string name="manual_hook">Manual Hook</string>
<string name="inline_hook">Inline Hook</string>
<string name="multi_manager_list">Active Manager</string>
<string name="default_signature">SukiSU</string>
<string name="dynamic_managerature">Dynamic</string>
<string name="signature_index">Signature %1$d</string>
<string name="settings_disable_sulog">Disable superuser logging</string>
<string name="settings_disable_sulog_summary">Disable KernelSU superuser access logging</string>
<!-- Log Viewer -->
<string name="log_viewer_title">Log Viewer</string>
<string name="log_viewer_back">Back</string>
<string name="log_viewer_search">Search</string>
<string name="log_viewer_clear_logs">Clear Logs</string>
<string name="log_viewer_clear_logs_confirm">Are you sure you want to clear the selected log file? This action cannot be undone.</string>
<string name="log_viewer_logs_cleared">Logs cleared successfully</string>
<string name="log_viewer_filter_type">Filter by Type</string>
<string name="log_viewer_all_types">All Types</string>
<string name="log_viewer_showing_entries">Showing %1$d of %2$d entries</string>
<string name="log_viewer_no_logs">No logs found</string>
<string name="log_viewer_no_matching_logs">No matching logs found</string>
<string name="log_viewer_refresh">Refresh</string>
<string name="log_viewer_raw_log">Raw Log</string>
<string name="log_viewer_search_placeholder">Search by UID, command, or details…</string>
<string name="log_viewer_clear_search">Clear search</string>
<string name="log_viewer_view_logs">View KernelSU superuser access logs</string>
<string name="log_viewer_exclude_subtypes">Exclude sub-types</string>
<string name="log_viewer_exclude_current_app">Current App</string>
<string name="log_viewer_page_info">Page: %1$d/%2$d | Total logs: %3$d</string>
<string name="log_viewer_too_many_logs">Too many logs, showing only the latest %1$d entries</string>
<string name="log_viewer_load_more">Load More Logs</string>
<string name="log_viewer_all_logs_loaded">All logs displayed</string>
<string name="log_viewer_settings">Settings</string>
<string name="log_viewer_collapse">Collapse</string>
<string name="log_viewer_expand">Expand</string>
</resources>