android: log screen as in the Final 2026 mockup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user