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 7a5fe9274..ac0eeab3c 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 @@ -46,6 +46,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import org.strongswan.android.R import org.strongswan.android.data.LogContentProvider import org.strongswan.android.logic.CharonVpnService @@ -59,13 +66,15 @@ import java.io.FileReader class LogActivity : AppCompatActivity() { + private val messages = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { ShxTheme { - LogScreen(onBack = { finish() }, onSend = { sendLog() }) + LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages) } } } @@ -80,7 +89,7 @@ class LogActivity : AppCompatActivity() ).filter { it.exists() && it.length() > 0L } if (files.isEmpty()) { - Toast.makeText(this, R.string.empty_log, Toast.LENGTH_SHORT).show() + messages.tryEmit(R.string.empty_log) return } var version = "" @@ -165,17 +174,47 @@ class LogViewModel : ViewModel() } } +private enum class LogLevelFilter(val labelRes: Int) +{ + ALL(R.string.log_filter_all), + WARN(R.string.log_filter_warn), + ERROR(R.string.log_filter_error), +} + +private fun String.isErrorLine() = contains(" ERROR ") + +private fun String.isWarnLine() = contains(" WARN ") || contains(" WARNING ") + +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable -private fun LogScreen(onBack: () -> Unit, onSend: () -> Unit, viewModel: LogViewModel = viewModel()) +private fun LogScreen( + onBack: () -> Unit, + onSend: () -> Unit, + messages: kotlinx.coroutines.flow.Flow, + viewModel: LogViewModel = viewModel(), +) { val context = LocalContext.current - val lines by viewModel.lines.collectAsStateWithLifecycle() + val allLines by viewModel.lines.collectAsStateWithLifecycle() val listState = rememberLazyListState() + val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() } + var filter by rememberSaveable { mutableStateOf(LogLevelFilter.ALL) } + val lines = remember(allLines, filter) { + when (filter) + { + LogLevelFilter.ALL -> allLines + LogLevelFilter.WARN -> allLines.filter { it.isErrorLine() || it.isWarnLine() } + LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() } + } + } LaunchedEffect(Unit) { viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE)) } - LaunchedEffect(lines.size) { + LaunchedEffect(messages) { + messages.collect { snackbarHostState.showSnackbar(context.getString(it)) } + } + LaunchedEffect(lines.size, filter) { if (lines.isNotEmpty()) { listState.scrollToItem(lines.lastIndex) @@ -190,36 +229,57 @@ private fun LogScreen(onBack: () -> Unit, onSend: () -> Unit, viewModel: LogView Icon(Icons.Default.Share, contentDescription = stringResource(R.string.send_log)) } }, + snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) }, ) { padding -> - if (lines.isEmpty()) + Column(Modifier.fillMaxSize().padding(padding)) { - Text( - stringResource(R.string.empty_log), - modifier = Modifier.padding(padding).padding(ShxSpacing.xl), - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( + modifier = Modifier.padding(horizontal = ShxSpacing.lg), + horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm), ) - } - else - { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = ShxSpacing.md), - ) { - items(lines.size) { index -> - val line = lines[index] - val isError = line.contains(" ERROR ", ignoreCase = false) - Text( - line, - style = MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - ), - color = if (isError) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(vertical = 2.dp), + { + LogLevelFilter.entries.forEach { level -> + androidx.compose.material3.FilterChip( + selected = filter == level, + onClick = { filter = level }, + label = { Text(stringResource(level.labelRes)) }, ) } } + if (lines.isEmpty()) + { + 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 + } + Text( + line, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + ), + color = color, + modifier = Modifier.padding(vertical = 2.dp), + ) + } + } + } } } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SecondaryScaffold.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SecondaryScaffold.kt index 62849e89d..9ac2c07c3 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SecondaryScaffold.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SecondaryScaffold.kt @@ -33,10 +33,12 @@ fun SecondaryScaffold( title: String, onBack: () -> Unit, actions: @Composable RowScope.() -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit, ) { Scaffold( + snackbarHost = snackbarHost, topBar = { TopAppBar( title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) }, diff --git a/src/frontends/android/app/src/main/res/values-de/strings.xml b/src/frontends/android/app/src/main/res/values-de/strings.xml index 12c481daf..691270d88 100644 --- a/src/frontends/android/app/src/main/res/values-de/strings.xml +++ b/src/frontends/android/app/src/main/res/values-de/strings.xml @@ -506,4 +506,7 @@ Profil auswählen oder hinzufügen Update %1$s wird heruntergeladen + Alle + Warnungen + Fehler 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 ae5951842..a3210c137 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 @@ -503,4 +503,7 @@ Статус защиты и подключение одним касанием Выберите или добавьте профиль Загрузка обновления %1$s + Все + Предупреждения + Ошибки 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 c0134568c..28f4f577b 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -508,4 +508,7 @@ Protection status and one-tap connect Select or add a profile Downloading update %1$s + All + Warnings + Errors