manager: Add the sulog log viewer interface and functionality
This commit is contained in:
@@ -0,0 +1,640 @@
|
|||||||
|
package com.sukisu.ultra.ui.screen
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.ramcosta.composedestinations.annotation.Destination
|
||||||
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
|
import com.sukisu.ultra.R
|
||||||
|
import com.sukisu.ultra.ui.component.*
|
||||||
|
import com.sukisu.ultra.ui.theme.CardConfig
|
||||||
|
import com.sukisu.ultra.ui.theme.CardConfig.cardAlpha
|
||||||
|
import com.sukisu.ultra.ui.theme.getCardColors
|
||||||
|
import com.sukisu.ultra.ui.theme.getCardElevation
|
||||||
|
import com.sukisu.ultra.ui.util.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private val SPACING_SMALL = 4.dp
|
||||||
|
private val SPACING_MEDIUM = 8.dp
|
||||||
|
private val SPACING_LARGE = 16.dp
|
||||||
|
|
||||||
|
data class LogEntry(
|
||||||
|
val timestamp: String,
|
||||||
|
val type: LogType,
|
||||||
|
val uid: String,
|
||||||
|
val comm: String,
|
||||||
|
val details: String,
|
||||||
|
val pid: String,
|
||||||
|
val rawLine: String
|
||||||
|
)
|
||||||
|
|
||||||
|
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)),
|
||||||
|
MANAGER_OP("MANAGER_OP", Color(0xFF9C27B0)),
|
||||||
|
UNKNOWN("UNKNOWN", Color(0xFF757575))
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Destination<RootGraph>
|
||||||
|
@Composable
|
||||||
|
fun LogViewerScreen(navigator: DestinationsNavigator) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
val snackBarHost = LocalSnackbarHost.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var logEntries by remember { mutableStateOf<List<LogEntry>>(emptyList()) }
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
var selectedLogFile by rememberSaveable { mutableStateOf("current") }
|
||||||
|
var filterType by rememberSaveable { mutableStateOf<LogType?>(null) }
|
||||||
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
|
var showSearchBar by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val filteredEntries = remember(logEntries, filterType, searchQuery) {
|
||||||
|
logEntries.filter { entry ->
|
||||||
|
val matchesFilter = filterType == null || entry.type == filterType
|
||||||
|
val matchesSearch = searchQuery.isEmpty() ||
|
||||||
|
entry.comm.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.details.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.uid.contains(searchQuery, ignoreCase = true)
|
||||||
|
matchesFilter && matchesSearch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadingDialog = rememberLoadingDialog()
|
||||||
|
val confirmDialog = rememberConfirmDialog()
|
||||||
|
|
||||||
|
LaunchedEffect(selectedLogFile) {
|
||||||
|
loadLogs(selectedLogFile) { entries ->
|
||||||
|
logEntries = entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
LogViewerTopBar(
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
onBackClick = { navigator.navigateUp() },
|
||||||
|
showSearchBar = showSearchBar,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
onSearchQueryChange = { searchQuery = it },
|
||||||
|
onSearchToggle = { showSearchBar = !showSearchBar },
|
||||||
|
onClearLogs = {
|
||||||
|
scope.launch {
|
||||||
|
val result = confirmDialog.awaitConfirm(
|
||||||
|
title = context.getString(R.string.log_viewer_clear_logs),
|
||||||
|
content = context.getString(R.string.log_viewer_clear_logs_confirm)
|
||||||
|
)
|
||||||
|
if (result == ConfirmResult.Confirmed) {
|
||||||
|
loadingDialog.withLoading {
|
||||||
|
clearLogs(selectedLogFile)
|
||||||
|
loadLogs(selectedLogFile) { entries ->
|
||||||
|
logEntries = entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snackBarHost.showSnackbar(context.getString(R.string.log_viewer_logs_cleared))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackBarHost) },
|
||||||
|
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||||
|
) {
|
||||||
|
// 控制面板
|
||||||
|
LogControlPanel(
|
||||||
|
selectedLogFile = selectedLogFile,
|
||||||
|
onLogFileSelected = { selectedLogFile = it },
|
||||||
|
filterType = filterType,
|
||||||
|
onFilterTypeSelected = { filterType = it },
|
||||||
|
logCount = filteredEntries.size,
|
||||||
|
totalCount = logEntries.size
|
||||||
|
)
|
||||||
|
|
||||||
|
// 日志列表
|
||||||
|
if (isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else if (filteredEntries.isEmpty()) {
|
||||||
|
EmptyLogState(
|
||||||
|
hasLogs = logEntries.isNotEmpty(),
|
||||||
|
onRefresh = {
|
||||||
|
scope.launch {
|
||||||
|
loadLogs(selectedLogFile) { entries ->
|
||||||
|
logEntries = entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LogList(
|
||||||
|
entries = filteredEntries,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogControlPanel(
|
||||||
|
selectedLogFile: String,
|
||||||
|
onLogFileSelected: (String) -> Unit,
|
||||||
|
filterType: LogType?,
|
||||||
|
onFilterTypeSelected: (LogType?) -> Unit,
|
||||||
|
logCount: Int,
|
||||||
|
totalCount: Int
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||||
|
colors = getCardColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||||
|
elevation = getCardElevation()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(SPACING_LARGE)
|
||||||
|
) {
|
||||||
|
// 文件选择
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.log_viewer_select_file),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
onClick = { onLogFileSelected("current") },
|
||||||
|
label = { Text(stringResource(R.string.log_viewer_current_log)) },
|
||||||
|
selected = selectedLogFile == "current"
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
onClick = { onLogFileSelected("old") },
|
||||||
|
label = { Text(stringResource(R.string.log_viewer_old_log)) },
|
||||||
|
selected = selectedLogFile == "old"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_LARGE))
|
||||||
|
|
||||||
|
// 类型过滤
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.log_viewer_filter_type),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
FilterChip(
|
||||||
|
onClick = { onFilterTypeSelected(null) },
|
||||||
|
label = { Text(stringResource(R.string.log_viewer_all_types)) },
|
||||||
|
selected = filterType == null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(LogType.entries.toTypedArray()) { type ->
|
||||||
|
FilterChip(
|
||||||
|
onClick = { onFilterTypeSelected(if (filterType == type) null else type) },
|
||||||
|
label = { Text(type.displayName) },
|
||||||
|
selected = filterType == type,
|
||||||
|
leadingIcon = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.background(type.color, RoundedCornerShape(4.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.log_viewer_showing_entries, logCount, totalCount),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogList(
|
||||||
|
entries: List<LogEntry>,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = modifier,
|
||||||
|
contentPadding = PaddingValues(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(SPACING_SMALL)
|
||||||
|
) {
|
||||||
|
items(entries) { entry ->
|
||||||
|
LogEntryCard(entry = entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogEntryCard(entry: LogEntry) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = !expanded },
|
||||||
|
colors = getCardColors(MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(SPACING_MEDIUM)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(SPACING_MEDIUM)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(12.dp)
|
||||||
|
.background(entry.type.color, RoundedCornerShape(6.dp))
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = entry.type.displayName,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = entry.timestamp,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "UID: ${entry.uid}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "PID: ${entry.pid}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = entry.comm,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
maxLines = if (expanded) Int.MAX_VALUE else 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
if (entry.details.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||||
|
Text(
|
||||||
|
text = entry.details,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = if (expanded) Int.MAX_VALUE else 2,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_MEDIUM))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.log_viewer_raw_log),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(SPACING_SMALL))
|
||||||
|
Text(
|
||||||
|
text = entry.rawLine,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyLogState(
|
||||||
|
hasLogs: Boolean,
|
||||||
|
onRefresh: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(SPACING_LARGE)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (hasLogs) Icons.Filled.FilterList else Icons.Filled.Description,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (hasLogs) R.string.log_viewer_no_matching_logs
|
||||||
|
else R.string.log_viewer_no_logs
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Button(onClick = onRefresh) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Refresh,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(SPACING_MEDIUM))
|
||||||
|
Text(stringResource(R.string.log_viewer_refresh))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun LogViewerTopBar(
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
showSearchBar: Boolean,
|
||||||
|
searchQuery: String,
|
||||||
|
onSearchQueryChange: (String) -> Unit,
|
||||||
|
onSearchToggle: () -> Unit,
|
||||||
|
onClearLogs: () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val cardColor = if (CardConfig.isCustomBackgroundEnabled) {
|
||||||
|
colorScheme.surfaceContainerLow
|
||||||
|
} else {
|
||||||
|
colorScheme.background
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.log_viewer_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBackClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.log_viewer_back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onSearchToggle) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (showSearchBar) Icons.Filled.SearchOff else Icons.Filled.Search,
|
||||||
|
contentDescription = stringResource(R.string.log_viewer_search)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onClearLogs) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.DeleteSweep,
|
||||||
|
contentDescription = stringResource(R.string.log_viewer_clear_logs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = cardColor.copy(alpha = cardAlpha),
|
||||||
|
scrolledContainerColor = cardColor.copy(alpha = cardAlpha)
|
||||||
|
),
|
||||||
|
windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||||
|
scrollBehavior = scrollBehavior
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSearchBar,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = onSearchQueryChange,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = SPACING_LARGE, vertical = SPACING_MEDIUM),
|
||||||
|
placeholder = { Text(stringResource(R.string.log_viewer_search_placeholder)) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Search,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onSearchQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Clear,
|
||||||
|
contentDescription = stringResource(R.string.log_viewer_clear_search)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadLogs(
|
||||||
|
logFile: String,
|
||||||
|
onLoaded: (List<LogEntry>) -> Unit
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val shell = getRootShell()
|
||||||
|
val logPath = if (logFile == "current") {
|
||||||
|
"/data/adb/ksu/log/sulog.log"
|
||||||
|
} else {
|
||||||
|
"/data/adb/ksu/log/sulog.log.old"
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = runCmd(shell, "cat $logPath 2>/dev/null || echo ''")
|
||||||
|
val entries = parseLogEntries(result)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onLoaded(entries)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
onLoaded(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun clearLogs(logFile: String) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val shell = getRootShell()
|
||||||
|
val logPath = if (logFile == "current") {
|
||||||
|
"/data/adb/ksu/log/sulog.log"
|
||||||
|
} else {
|
||||||
|
"/data/adb/ksu/log/sulog.log.old"
|
||||||
|
}
|
||||||
|
|
||||||
|
runCmd(shell, "echo '' > $logPath")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseLogEntries(logContent: String): List<LogEntry> {
|
||||||
|
if (logContent.isBlank()) return emptyList()
|
||||||
|
|
||||||
|
return logContent.lines()
|
||||||
|
.filter { it.isNotBlank() && it.startsWith("[") }
|
||||||
|
.mapNotNull { line ->
|
||||||
|
try {
|
||||||
|
parseLogLine(line)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.reversed() // 最新的日志在前面
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseLogLine(line: String): LogEntry? {
|
||||||
|
// 解析格式: [timestamp] TYPE: UID=xxx COMM=xxx ...
|
||||||
|
val timestampRegex = """\[(.*?)]""".toRegex()
|
||||||
|
val timestampMatch = timestampRegex.find(line) ?: return null
|
||||||
|
val timestamp = 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
|
||||||
|
"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.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)
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import com.ramcosta.composedestinations.annotation.Destination
|
|||||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||||
import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination
|
import com.ramcosta.composedestinations.generated.destinations.AppProfileTemplateScreenDestination
|
||||||
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
|
||||||
|
import com.ramcosta.composedestinations.generated.destinations.LogViewerScreenDestination
|
||||||
import com.ramcosta.composedestinations.generated.destinations.MoreSettingsScreenDestination
|
import com.ramcosta.composedestinations.generated.destinations.MoreSettingsScreenDestination
|
||||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||||
import com.sukisu.ultra.BuildConfig
|
import com.sukisu.ultra.BuildConfig
|
||||||
@@ -398,6 +399,18 @@ fun SettingScreen(navigator: DestinationsNavigator) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 查看使用日志
|
||||||
|
KsuIsValid {
|
||||||
|
SettingItem(
|
||||||
|
icon = Icons.Filled.Visibility,
|
||||||
|
title = stringResource(R.string.log_viewer_view_logs),
|
||||||
|
summary = stringResource(R.string.log_viewer_view_logs_summary),
|
||||||
|
onClick = {
|
||||||
|
navigator.navigate(LogViewerScreenDestination)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showBottomsheet) {
|
if (showBottomsheet) {
|
||||||
LogBottomSheet(
|
LogBottomSheet(
|
||||||
onDismiss = { showBottomsheet = false },
|
onDismiss = { showBottomsheet = false },
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ object CardConfig {
|
|||||||
if (isCustom) isCustomDimSet = true
|
if (isCustom) isCustomDimSet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateShadow(enabled: Boolean, elevation: Dp = 4.dp) {
|
fun updateShadow(enabled: Boolean, elevation: Dp = cardElevation) {
|
||||||
isShadowEnabled = enabled
|
isShadowEnabled = enabled
|
||||||
cardElevation = if (enabled) elevation else 0.dp
|
cardElevation = if (enabled) elevation else cardElevation
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateBackground(enabled: Boolean) {
|
fun updateBackground(enabled: Boolean) {
|
||||||
@@ -128,7 +128,7 @@ object CardConfig {
|
|||||||
isUserLightModeEnabled = prefs.getBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, false)
|
isUserLightModeEnabled = prefs.getBoolean(Keys.IS_USER_LIGHT_MODE_ENABLED, false)
|
||||||
|
|
||||||
// 应用阴影设置
|
// 应用阴影设置
|
||||||
updateShadow(isShadowEnabled, if (isShadowEnabled) 4.dp else 0.dp)
|
updateShadow(isShadowEnabled, if (isShadowEnabled) cardElevation else 0.dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("使用 updateShadow 替代", ReplaceWith("updateShadow(enabled)"))
|
@Deprecated("使用 updateShadow 替代", ReplaceWith("updateShadow(enabled)"))
|
||||||
@@ -151,16 +151,16 @@ object CardStyleProvider {
|
|||||||
fun getCardElevation() = CardDefaults.cardElevation(
|
fun getCardElevation() = CardDefaults.cardElevation(
|
||||||
defaultElevation = CardConfig.cardElevation,
|
defaultElevation = CardConfig.cardElevation,
|
||||||
pressedElevation = if (CardConfig.isShadowEnabled) {
|
pressedElevation = if (CardConfig.isShadowEnabled) {
|
||||||
(CardConfig.cardElevation.value + 4).dp
|
(CardConfig.cardElevation.value + 0).dp
|
||||||
} else 0.dp,
|
} else 0.dp,
|
||||||
focusedElevation = if (CardConfig.isShadowEnabled) {
|
focusedElevation = if (CardConfig.isShadowEnabled) {
|
||||||
(CardConfig.cardElevation.value + 6).dp
|
(CardConfig.cardElevation.value + 0).dp
|
||||||
} else 0.dp,
|
} else 0.dp,
|
||||||
hoveredElevation = if (CardConfig.isShadowEnabled) {
|
hoveredElevation = if (CardConfig.isShadowEnabled) {
|
||||||
(CardConfig.cardElevation.value + 2).dp
|
(CardConfig.cardElevation.value + 0).dp
|
||||||
} else 0.dp,
|
} else 0.dp,
|
||||||
draggedElevation = if (CardConfig.isShadowEnabled) {
|
draggedElevation = if (CardConfig.isShadowEnabled) {
|
||||||
(CardConfig.cardElevation.value + 8).dp
|
(CardConfig.cardElevation.value + 0).dp
|
||||||
} else 0.dp,
|
} else 0.dp,
|
||||||
disabledElevation = 0.dp
|
disabledElevation = 0.dp
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -687,4 +687,24 @@
|
|||||||
<string name="sus_maps_description_text">从 /proc/self/[maps|smaps|smaps_rollup|map_files|mem|pagemap] 中隐藏内存映射的真实文件路径。请注意:此功能不支持隐藏匿名内存映射,也无法隐藏由注入库本身产生的内联钩子或 PLT 钩子。</string>
|
<string name="sus_maps_description_text">从 /proc/self/[maps|smaps|smaps_rollup|map_files|mem|pagemap] 中隐藏内存映射的真实文件路径。请注意:此功能不支持隐藏匿名内存映射,也无法隐藏由注入库本身产生的内联钩子或 PLT 钩子。</string>
|
||||||
<string name="sus_maps_warning">重要提示:对于具备完善注入检测机制的应用,此功能可能无法有效绕过检测。</string>
|
<string name="sus_maps_warning">重要提示:对于具备完善注入检测机制的应用,此功能可能无法有效绕过检测。</string>
|
||||||
<string name="sus_maps_debug_info">首先通过 ps -enf 查找目标应用的 PID 和 UID,然后检查 /proc/<pid>/maps 中的相关路径,并与 /proc/1/mountinfo 中的设备号进行比对以确保一致性。只有当设备号一致时,隐藏映射才能正常工作。</string>
|
<string name="sus_maps_debug_info">首先通过 ps -enf 查找目标应用的 PID 和 UID,然后检查 /proc/<pid>/maps 中的相关路径,并与 /proc/1/mountinfo 中的设备号进行比对以确保一致性。只有当设备号一致时,隐藏映射才能正常工作。</string>
|
||||||
|
<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_select_file">选择日志文件</string>
|
||||||
|
<string name="log_viewer_current_log">当前日志</string>
|
||||||
|
<string name="log_viewer_old_log">旧日志</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">查看使用日志</string>
|
||||||
|
<string name="log_viewer_view_logs_summary">查看 KernelSU 超级用户访问日志</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -695,4 +695,25 @@ Important Note:\n
|
|||||||
<string name="sus_maps_description_text">Hide the real file paths of memory mappings from /proc/self/[maps|smaps|smaps_rollup|map_files|mem|pagemap]. Please note: This feature does not support hiding anonymous memory mappings, nor can it hide inline hooks or PLT hooks caused by the injected library itself.</string>
|
<string name="sus_maps_description_text">Hide the real file paths of memory mappings from /proc/self/[maps|smaps|smaps_rollup|map_files|mem|pagemap]. Please note: This feature does not support hiding anonymous memory mappings, nor can it hide inline hooks or PLT hooks caused by the injected library itself.</string>
|
||||||
<string name="sus_maps_warning">Important Notice: For applications with well-implemented injection detection mechanisms, this feature may not effectively bypass detection.</string>
|
<string name="sus_maps_warning">Important Notice: For applications with well-implemented injection detection mechanisms, this feature may not effectively bypass detection.</string>
|
||||||
<string name="sus_maps_debug_info">First, find the target application\'s PID and UID using ps -enf, then check the relevant paths in /proc/<pid>/maps and compare the device numbers with those in /proc/1/mountinfo to ensure consistency. Only when the device numbers match can the map hiding function work properly.</string>
|
<string name="sus_maps_debug_info">First, find the target application\'s PID and UID using ps -enf, then check the relevant paths in /proc/<pid>/maps and compare the device numbers with those in /proc/1/mountinfo to ensure consistency. Only when the device numbers match can the map hiding function work properly.</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_select_file">Select Log File</string>
|
||||||
|
<string name="log_viewer_current_log">Current Log</string>
|
||||||
|
<string name="log_viewer_old_log">Old Log</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 Usage Logs</string>
|
||||||
|
<string name="log_viewer_view_logs_summary">View KernelSU superuser access logs</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user