Step 1: Import susfs and sulog to modify
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
Card {
|
||||
|
||||
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 {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -626,17 +645,48 @@ 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(),
|
||||
content = getSELinuxStatus()
|
||||
)
|
||||
if (susfsPair.first == "Supported" && susfsPair.second.isNotEmpty()) {
|
||||
InfoText(
|
||||
title = stringResource(R.string.home_susfs_version),
|
||||
content = susfsPair.second,
|
||||
bottomPadding = 0.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getManagerVersion(context: Context): Pair<String, Long> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user