Rewrite Magisk log tab with itemized parsed entries

Parse logcat-format lines into structured MagiskLogEntry objects with
timestamp, level, tag, PID/TID, and message. Display each entry as a
card with a colored log level badge, tag, timestamp, and expandable
message text instead of dumping raw text.

Made-with: Cursor
This commit is contained in:
LoveSy
2026-03-03 14:45:53 +08:00
committed by topjohnwu
parent 6f4e4743a1
commit 8946938987
3 changed files with 172 additions and 18 deletions
@@ -1,6 +1,7 @@
package com.topjohnwu.magisk.ui.log package com.topjohnwu.magisk.ui.log
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -11,23 +12,30 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily 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.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.topjohnwu.magisk.core.ktx.timeDateFormat import com.topjohnwu.magisk.core.ktx.timeDateFormat
@@ -88,7 +96,7 @@ fun LogScreen(viewModel: LogViewModel) {
nestedScrollConnection = scrollBehavior.nestedScrollConnection nestedScrollConnection = scrollBehavior.nestedScrollConnection
) )
1 -> MagiskLogTab( 1 -> MagiskLogTab(
log = uiState.magiskLog, entries = uiState.magiskLogEntries,
onSave = { viewModel.saveMagiskLog() }, onSave = { viewModel.saveMagiskLog() },
onClear = { viewModel.clearMagiskLog() }, onClear = { viewModel.clearMagiskLog() },
nestedScrollConnection = scrollBehavior.nestedScrollConnection nestedScrollConnection = scrollBehavior.nestedScrollConnection
@@ -202,9 +210,14 @@ private fun SuLogCard(log: SuLog) {
} }
@Composable @Composable
private fun MagiskLogTab(log: String, onSave: () -> Unit, onClear: () -> Unit, nestedScrollConnection: NestedScrollConnection) { private fun MagiskLogTab(
entries: List<MagiskLogEntry>,
onSave: () -> Unit,
onClear: () -> Unit,
nestedScrollConnection: NestedScrollConnection
) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (log.isBlank()) { if (entries.isEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -218,22 +231,21 @@ private fun MagiskLogTab(log: String, onSave: () -> Unit, onClear: () -> Unit, n
) )
} }
} else { } else {
Box( val listState = rememberLazyListState(initialFirstVisibleItemIndex = entries.size - 1)
LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.nestedScroll(nestedScrollConnection) .nestedScroll(nestedScrollConnection)
.horizontalScroll(rememberScrollState()) .padding(horizontal = 12.dp),
.verticalScroll(rememberScrollState()) contentPadding = PaddingValues(bottom = 88.dp),
.padding(12.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
.padding(bottom = 76.dp)
) { ) {
Text( item { Spacer(Modifier.height(4.dp)) }
text = log, items(entries.size, key = { it }) { index ->
fontFamily = FontFamily.Monospace, MagiskLogCard(entries[index])
fontSize = 12.sp, }
lineHeight = 16.sp, item { Spacer(Modifier.height(4.dp)) }
color = MiuixTheme.colorScheme.onSurface
)
} }
} }
@@ -254,3 +266,86 @@ private fun MagiskLogTab(log: String, onSave: () -> Unit, onClear: () -> Unit, n
} }
} }
} }
@Composable
private fun MagiskLogCard(entry: MagiskLogEntry) {
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
) {
Column(modifier = Modifier.padding(12.dp)) {
if (entry.isParsed) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.weight(1f)
) {
LogLevelBadge(entry.level)
Text(
text = entry.tag,
style = MiuixTheme.textStyles.body1,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
Text(
text = entry.timestamp,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
maxLines = 1,
)
}
Spacer(Modifier.height(4.dp))
}
Text(
text = entry.message,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 16.sp,
color = MiuixTheme.colorScheme.onSurface,
maxLines = if (expanded) Int.MAX_VALUE else 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun LogLevelBadge(level: Char) {
val (bg, fg) = when (level) {
'V' -> Color(0xFF9E9E9E) to Color.White
'D' -> Color(0xFF2196F3) to Color.White
'I' -> Color(0xFF4CAF50) to Color.White
'W' -> Color(0xFFFFC107) to Color.Black
'E' -> Color(0xFFF44336) to Color.White
'F' -> Color(0xFF9C27B0) to Color.White
else -> Color(0xFF757575) to Color.White
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(bg)
.padding(horizontal = 5.dp, vertical = 1.dp),
contentAlignment = Alignment.Center
) {
Text(
text = level.toString(),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = fg,
)
}
}
@@ -29,6 +29,7 @@ class LogViewModel(
data class UiState( data class UiState(
val loading: Boolean = true, val loading: Boolean = true,
val magiskLog: String = "", val magiskLog: String = "",
val magiskLogEntries: List<MagiskLogEntry> = emptyList(),
val suLogs: List<SuLog> = emptyList(), val suLogs: List<SuLog> = emptyList(),
) )
@@ -42,9 +43,11 @@ class LogViewModel(
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
magiskLogRaw = repo.fetchMagiskLogs() magiskLogRaw = repo.fetchMagiskLogs()
val suLogs = repo.fetchSuLogs() val suLogs = repo.fetchSuLogs()
val entries = MagiskLogParser.parse(magiskLogRaw)
_uiState.update { it.copy( _uiState.update { it.copy(
loading = false, loading = false,
magiskLog = magiskLogRaw, magiskLog = magiskLogRaw,
magiskLogEntries = entries,
suLogs = suLogs, suLogs = suLogs,
) } ) }
} }
@@ -0,0 +1,56 @@
package com.topjohnwu.magisk.ui.log
data class MagiskLogEntry(
val timestamp: String = "",
val pid: Int = 0,
val tid: Int = 0,
val level: Char = 'I',
val tag: String = "",
val message: String = "",
val isParsed: Boolean = false,
)
object MagiskLogParser {
// Logcat format: "MM-DD HH:MM:SS.mmm PID TID LEVEL TAG : message"
private val logcatRegex = Regex(
"""(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+(.+?)\s*:\s+(.*)"""
)
fun parse(raw: String): List<MagiskLogEntry> {
if (raw.isBlank()) return emptyList()
val lines = raw.lines()
val result = mutableListOf<MagiskLogEntry>()
for (line in lines) {
if (line.isBlank()) continue
val match = logcatRegex.find(line)
if (match != null) {
result.add(
MagiskLogEntry(
timestamp = match.groupValues[1],
pid = match.groupValues[2].toIntOrNull() ?: 0,
tid = match.groupValues[3].toIntOrNull() ?: 0,
level = match.groupValues[4].firstOrNull() ?: 'I',
tag = match.groupValues[5].trim(),
message = match.groupValues[6],
isParsed = true,
)
)
} else if (result.isNotEmpty() && result.last().isParsed) {
// Continuation line — append to previous entry
val prev = result.last()
result[result.lastIndex] = prev.copy(
message = prev.message + "\n" + line.trimEnd()
)
} else {
result.add(
MagiskLogEntry(message = line.trimEnd())
)
}
}
return result
}
}