diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index f2af5627..7f7ffcaf 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -570,6 +570,8 @@ Import a previously created backup Crash reporter Error and crash reports + Logs + View and export application logs Export log file Search Configure the search diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index 015b598f..c6b80445 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -41,6 +41,7 @@ import de.mm20.launcher2.ui.settings.favorites.FavoritesSettingsScreen import de.mm20.launcher2.ui.settings.filesearch.FileSearchSettingsScreen import de.mm20.launcher2.ui.settings.hiddenitems.HiddenItemsSettingsScreen import de.mm20.launcher2.ui.settings.license.LicenseScreen +import de.mm20.launcher2.ui.settings.log.LogScreen import de.mm20.launcher2.ui.settings.main.MainSettingsScreen import de.mm20.launcher2.ui.settings.musicwidget.MusicWidgetSettingsScreen import de.mm20.launcher2.ui.settings.search.SearchSettingsScreen @@ -166,6 +167,9 @@ class SettingsActivity : BaseActivity() { composable("settings/debug/crashreporter") { CrashReporterScreen() } + composable("settings/debug/logs") { + LogScreen() + } composable( "settings/debug/crashreporter/report?fileName={fileName}", arguments = listOf(navArgument("fileName") { diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt index 0bee3dbd..05c8163c 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/debug/DebugSettingsScreen.kt @@ -37,25 +37,10 @@ fun DebugSettingsScreen() { }) Preference( - title = stringResource(R.string.preference_export_log), + title = stringResource(R.string.preference_logs), + summary = stringResource(R.string.preference_logs_summary), onClick = { - scope.launch { - val path = DebugInformationDumper().dump(context) - context.tryStartActivity( - Intent.createChooser( - Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra( - Intent.EXTRA_STREAM, FileProvider.getUriForFile( - context, - context.applicationContext.packageName + ".fileprovider", - File(path) - ) - ) - }, null - ) - ) - } + navController?.navigate("settings/debug/logs") }) } PreferenceCategory(stringResource(R.string.preference_category_debug_tools)) { diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/log/LogScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/log/LogScreen.kt new file mode 100644 index 00000000..b74120c4 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/log/LogScreen.kt @@ -0,0 +1,173 @@ +package de.mm20.launcher2.ui.settings.log + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material.icons.rounded.Warning +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 +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import de.mm20.launcher2.debug.DebugInformationDumper +import de.mm20.launcher2.ktx.tryStartActivity +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.preferences.PreferenceScreen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.util.regex.Pattern + +@Composable +fun LogScreen() { + var lines by remember { mutableStateOf(emptyList()) } + LaunchedEffect(null) { + val process = Runtime.getRuntime().exec("/system/bin/logcat -v time") + + val pattern = Pattern.compile( + "^(\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3})\\s+" + /* timestamp [1] */ + "(\\w)/(.+?)\\(\\s*(\\d+)\\): (.*)$") /* level, tag, pid, msg [2-5] */ + + launch(Dispatchers.IO) { + val inputStream = process.inputStream.bufferedReader() + while (isActive) { + val line = try { + val line = inputStream.readLine() + val matcher = pattern.matcher(line) + if (matcher.matches()) { + FormattedLogcatLine( + message = matcher.group(5) ?: "", + tag = matcher.group(3) ?: "", + level = matcher.group(2) ?: "", + timestamp = matcher.group(1) ?: "", + ) + } else { + RawLogcatLine(line) + } + } catch (e: IOException) { + break + } + lines = (lines + line) + } + } + try { + awaitCancellation() + } finally { + process.destroy() + } + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + PreferenceScreen( + title = stringResource(id = R.string.preference_logs), + topBarActions = { + IconButton(onClick = { + scope.launch { + val path = DebugInformationDumper().dump(context) + context.tryStartActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra( + Intent.EXTRA_STREAM, FileProvider.getUriForFile( + context, + context.applicationContext.packageName + ".fileprovider", + File(path) + ) + ) + }, null + ) + ) + } + }) { + Icon(Icons.Rounded.Share, contentDescription = null) + } + } + ) { + items(lines) { + if (it is RawLogcatLine) { + Text(modifier = Modifier.padding(16.dp), text = it.line ?: "", style = MaterialTheme.typography.bodySmall) + } else if (it is FormattedLogcatLine) { + val contentColor = when(it.level) { + "E" -> MaterialTheme.colorScheme.onErrorContainer + "W" -> MaterialTheme.colorScheme.onPrimaryContainer + "D" -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurface + } + val bgColor = when(it.level) { + "E" -> MaterialTheme.colorScheme.errorContainer + "W" -> MaterialTheme.colorScheme.primaryContainer + "D" -> MaterialTheme.colorScheme.surfaceVariant + else -> MaterialTheme.colorScheme.surface + } + Column(modifier = Modifier + .fillMaxWidth() + .background(bgColor) + .padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + when(it.level) { + "E" -> Icons.Rounded.Error + "W" -> Icons.Rounded.Warning + "D" -> Icons.Rounded.BugReport + else -> Icons.Rounded.Info + }, + null, + tint = contentColor + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = it.timestamp + " • " + it.tag, + style = MaterialTheme.typography.labelSmall, + color = contentColor + ) + } + Text( + modifier = Modifier.padding(top = 8.dp), + text = it.message, + style = MaterialTheme.typography.bodySmall, + color = contentColor + ) + } + } + } + } +} + +sealed interface LogcatLine + +data class FormattedLogcatLine( + val level: String, + val timestamp: String, + val tag: String, + val message: String, +) : LogcatLine + +data class RawLogcatLine(val line: String): LogcatLine \ No newline at end of file