android: log screen as in the Final 2026 mockup

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-12 10:35:42 +07:00
1 parent eb5a89623b
commit 8cd704ed08
3 files changed
+115 -53

No files matched your search

@@ -20,11 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -58,12 +54,30 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import org.strongswan.android.ui.compose.design.ShxButton
import org.strongswan.android.ui.compose.design.ShxChip
import org.strongswan.android.ui.compose.design.ShxIcons
import org.strongswan.android.ui.compose.design.ShxNote
import org.strongswan.android.ui.compose.theme.LocalShxColors
import org.strongswan.android.R
import org.strongswan.android.data.LogContentProvider
import org.strongswan.android.logic.CharonVpnService
import org.strongswan.android.logic.DiagLog
import org.strongswan.android.ui.compose.SecondaryScaffold
import org.strongswan.android.ui.compose.theme.ShxSpacing
import org.strongswan.android.ui.compose.theme.ShxTheme
import java.io.BufferedReader
import java.io.File
@@ -222,18 +236,36 @@ class LogViewModel : ViewModel()
}
}
/* F_Log: «Important» (default), «All», «Errors» */
private enum class LogLevelFilter(val labelRes: Int)
{
ALL(R.string.log_filter_all),
WARN(R.string.log_filter_warn),
IMPORTANT(R.string.log_filter_important),
ALL(R.string.log_filter_everything),
ERROR(R.string.log_filter_error),
}
private fun String.isErrorLine() = contains(" ERROR ")
private fun String.isErrorLine() = contains(" ERROR ") || contains("failed", ignoreCase = true) ||
contains("timeout", ignoreCase = true) || contains("giving up", ignoreCase = true)
private fun String.isWarnLine() = contains(" WARN ") || contains(" WARNING ")
private fun String.isWarnLine() = contains(" WARN ") || contains(" WARNING ") || contains("retransmit", ignoreCase = true)
private fun String.isGoodLine() = contains("established", ignoreCase = true) || contains("successful", ignoreCase = true) ||
contains("installed", ignoreCase = true)
/* the milestones of a connection: what «Important» keeps besides problems */
private val MILESTONES = listOf("initiating", "IKE_SA", "CHILD_SA", "authentication", "established", "deleting", "closing", "reauth", "network")
private fun String.isImportantLine() = isErrorLine() || isWarnLine() || MILESTONES.any { contains(it, ignoreCase = true) }
/* «Oct 12 10:21:49 06[IKE] message» → «10:21:49», «message» */
private val CHARON_LINE = Regex("""^\w{3}\s+\d+\s+(\d{2}:\d{2}:\d{2})\s+\d+\[\w+]\s?(.*)$""")
private fun splitLine(line: String): Pair<String, String>
{
val m = CHARON_LINE.find(line) ?: return "" to line
return m.groupValues[1] to m.groupValues[2]
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun LogScreen(
onBack: () -> Unit,
@@ -243,15 +275,16 @@ private fun LogScreen(
)
{
val context = LocalContext.current
val c = LocalShxColors.current
val allLines by viewModel.lines.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.ALL) }
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.IMPORTANT) }
val lines = remember(allLines, filter) {
when (filter)
{
LogLevelFilter.IMPORTANT -> allLines.filter { it.isImportantLine() }
LogLevelFilter.ALL -> allLines
LogLevelFilter.WARN -> allLines.filter { it.isErrorLine() || it.isWarnLine() }
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
}
}
@@ -293,66 +326,83 @@ private fun LogScreen(
followedSize = lines.size
followedFilter = filter
}
val copied = stringResource(R.string.log_copied)
val scope = rememberCoroutineScope()
SecondaryScaffold(
title = stringResource(R.string.log_title),
onBack = onBack,
actions = {
IconButton(onClick = onSend) {
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.send_log))
/* 44dp tonal circle of the mockup header */
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile)
.clickable(role = Role.Button) {
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("log", lines.joinToString("\n")))
scope.launch { snackbarHostState.showSnackbar(copied) }
},
)
{
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.log_copy), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
}
},
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
) { padding ->
Column(Modifier.fillMaxSize().padding(padding))
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize().padding(padding).padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
)
{
Row(
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
{
LogLevelFilter.entries.forEach { level ->
androidx.compose.material3.FilterChip(
selected = filter == level,
onClick = { filter = level },
label = { Text(stringResource(level.labelRes)) },
)
ShxChip(stringResource(level.labelRes), filter == level, { filter = level })
}
}
if (lines.isEmpty())
/* the dark console: grey time, coloured message */
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(Color(0xFF1C1B20)))
{
Text(
stringResource(R.string.empty_log),
modifier = Modifier.padding(ShxSpacing.xl),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
else
{
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(horizontal = ShxSpacing.md),
) {
items(lines.size) { index ->
val line = lines[index]
val color = when
{
line.isErrorLine() -> MaterialTheme.colorScheme.error
line.isWarnLine() -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurface
if (lines.isEmpty())
{
Text(
stringResource(R.string.empty_log), style = LOG_STYLE, color = Color(0xFF79767F),
modifier = Modifier.padding(14.dp),
)
}
else
{
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
modifier = Modifier.fillMaxSize(),
) {
items(lines.size) { index ->
val line = lines[index]
val (time, message) = splitLine(line)
val color = when
{
line.isErrorLine() || line.isWarnLine() -> Color(0xFFFFB869)
line.isGoodLine() -> Color(0xFF7CE3B0)
else -> Color(0xFFE5E1E9)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
if (time.isNotEmpty())
{
Text(time, style = LOG_STYLE, color = Color(0xFF79767F))
}
Text(message, style = LOG_STYLE, color = color)
}
}
Text(
line,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
),
color = color,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
ShxNote(stringResource(R.string.log_report_note))
ShxButton(stringResource(R.string.log_send_report), onSend, modifier = Modifier.fillMaxWidth())
}
}
}
private val LOG_STYLE = TextStyle(fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 17.sp)
@@ -1030,4 +1030,10 @@
<string name="server_delete_current">Это текущий сервер — VPN отключится.</string>
<string name="server_delete_restore">Вернуть можно, снова добавив профиль по ссылке, файлу или QR.</string>
<string name="server_delete_confirm">Удалить</string>
<string name="log_filter_important">Важное</string>
<string name="log_filter_everything">Всё</string>
<string name="log_report_note">В отчёт попадут журнал подключения и версия приложения.</string>
<string name="log_send_report">Отправить отчёт</string>
<string name="log_copy">Скопировать журнал</string>
<string name="log_copied">Журнал скопирован</string>
</resources>
@@ -1016,4 +1016,10 @@
<string name="server_delete_current">This is the current server — the VPN will disconnect.</string>
<string name="server_delete_restore">You can get it back by adding the profile again from a link, file or QR.</string>
<string name="server_delete_confirm">Delete</string>
<string name="log_filter_important">Important</string>
<string name="log_filter_everything">All</string>
<string name="log_report_note">The report contains the connection log and the app version.</string>
<string name="log_send_report">Send report</string>
<string name="log_copy">Copy log</string>
<string name="log_copied">Log copied</string>
</resources>