diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt index 9f6bbe7a6..5269299e5 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt @@ -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 +{ + 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) diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index c88310742..eab6055ea 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1030,4 +1030,10 @@ Это текущий сервер — VPN отключится. Вернуть можно, снова добавив профиль по ссылке, файлу или QR. Удалить + Важное + Всё + В отчёт попадут журнал подключения и версия приложения. + Отправить отчёт + Скопировать журнал + Журнал скопирован diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index d0abdb43a..1fc85ad4e 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1016,4 +1016,10 @@ This is the current server — the VPN will disconnect. You can get it back by adding the profile again from a link, file or QR. Delete + Important + All + The report contains the connection log and the app version. + Send report + Copy log + Log copied