diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt index 0bd50d492..935c133bb 100644 --- a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt @@ -1,6 +1,7 @@ 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.Box 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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.setValue import androidx.compose.ui.Alignment 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.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 androidx.compose.ui.unit.sp import com.topjohnwu.magisk.core.ktx.timeDateFormat @@ -88,7 +96,7 @@ fun LogScreen(viewModel: LogViewModel) { nestedScrollConnection = scrollBehavior.nestedScrollConnection ) 1 -> MagiskLogTab( - log = uiState.magiskLog, + entries = uiState.magiskLogEntries, onSave = { viewModel.saveMagiskLog() }, onClear = { viewModel.clearMagiskLog() }, nestedScrollConnection = scrollBehavior.nestedScrollConnection @@ -202,9 +210,14 @@ private fun SuLogCard(log: SuLog) { } @Composable -private fun MagiskLogTab(log: String, onSave: () -> Unit, onClear: () -> Unit, nestedScrollConnection: NestedScrollConnection) { +private fun MagiskLogTab( + entries: List, + onSave: () -> Unit, + onClear: () -> Unit, + nestedScrollConnection: NestedScrollConnection +) { Column(modifier = Modifier.fillMaxSize()) { - if (log.isBlank()) { + if (entries.isEmpty()) { Box( modifier = Modifier .weight(1f) @@ -218,22 +231,21 @@ private fun MagiskLogTab(log: String, onSave: () -> Unit, onClear: () -> Unit, n ) } } else { - Box( + val listState = rememberLazyListState(initialFirstVisibleItemIndex = entries.size - 1) + LazyColumn( + state = listState, modifier = Modifier .weight(1f) .nestedScroll(nestedScrollConnection) - .horizontalScroll(rememberScrollState()) - .verticalScroll(rememberScrollState()) - .padding(12.dp) - .padding(bottom = 76.dp) + .padding(horizontal = 12.dp), + contentPadding = PaddingValues(bottom = 88.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Text( - text = log, - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - lineHeight = 16.sp, - color = MiuixTheme.colorScheme.onSurface - ) + item { Spacer(Modifier.height(4.dp)) } + items(entries.size, key = { it }) { index -> + MagiskLogCard(entries[index]) + } + item { Spacer(Modifier.height(4.dp)) } } } @@ -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, + ) + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogViewModel.kt index a845dba3a..3c794c261 100644 --- a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogViewModel.kt +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/LogViewModel.kt @@ -29,6 +29,7 @@ class LogViewModel( data class UiState( val loading: Boolean = true, val magiskLog: String = "", + val magiskLogEntries: List = emptyList(), val suLogs: List = emptyList(), ) @@ -42,9 +43,11 @@ class LogViewModel( withContext(Dispatchers.Default) { magiskLogRaw = repo.fetchMagiskLogs() val suLogs = repo.fetchSuLogs() + val entries = MagiskLogParser.parse(magiskLogRaw) _uiState.update { it.copy( loading = false, magiskLog = magiskLogRaw, + magiskLogEntries = entries, suLogs = suLogs, ) } } diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/MagiskLogParser.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/MagiskLogParser.kt new file mode 100644 index 000000000..3c8b3b90c --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/log/MagiskLogParser.kt @@ -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 { + if (raw.isBlank()) return emptyList() + + val lines = raw.lines() + val result = mutableListOf() + + 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 + } +}