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