diff --git a/DittoToolsAndroid/build.gradle.kts b/DittoToolsAndroid/build.gradle.kts index 6ae9a97..50bed19 100644 --- a/DittoToolsAndroid/build.gradle.kts +++ b/DittoToolsAndroid/build.gradle.kts @@ -41,6 +41,9 @@ dependencies { implementation(libs.live.ditto.ditto) + //Moshi + implementation(libs.moshi.kotlin) + testImplementation(libs.junit.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/data/LogConfiguration.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/data/LogConfiguration.kt new file mode 100644 index 0000000..e539aef --- /dev/null +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/data/LogConfiguration.kt @@ -0,0 +1,7 @@ +package live.ditto.tools.data + +data class LogConfiguration( + val maxAge : Int, + val maxFilesOnDisk: Int, + val maxSize: Int +) diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogDetailsScreen.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogDetailsScreen.kt new file mode 100644 index 0000000..3bfe9a1 --- /dev/null +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogDetailsScreen.kt @@ -0,0 +1,101 @@ +package live.ditto.tools.logviewer + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DensitySmall +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import live.ditto.Ditto +import live.ditto.tools.R + +@Composable +fun LogDetailsScreen( + onButtonClick: () -> Unit, + ditto: Ditto, + logDetailsScreenViewModel: LogDetailsScreenViewModel = viewModel( + factory = LogDetailsScreenViewModelFactory( + ditto, + filesDir = LocalContext.current.applicationContext.filesDir + ) + ) +) { + + val logConfiguration = logDetailsScreenViewModel.logConfiguration + val logDirectoryInfo = logDetailsScreenViewModel.logDirectoryInfo + + LaunchedEffect(logConfiguration) { + logDetailsScreenViewModel.getLogConfigurationInfo() + } + + LaunchedEffect(logDirectoryInfo) { + logDetailsScreenViewModel.getLogDirInfo() + } + + Column( + modifier = Modifier + .fillMaxSize() + .fillMaxHeight() + .padding(all = 16.dp) + ) { + LogInfoCard(stringResource(R.string.log_config), logConfiguration.value) + LogInfoCard(stringResource(R.string.log_dir_info), logDirectoryInfo.value) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { onButtonClick() } + ) { + Icon(Icons.Default.DensitySmall, contentDescription = "Logs") + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.log_view)) + } + } +} + +@Composable +fun LogInfoCard(title: String, info: AnnotatedString){ + ElevatedCard( + elevation = CardDefaults.cardElevation( + defaultElevation = 6.dp + ), + modifier = Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(all = 16.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = title, + fontSize = 16.sp, + textAlign = TextAlign.Center, + style = TextStyle(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + ) + Spacer(modifier = Modifier.height(3.dp)) + Text(text = info) } + } +} \ No newline at end of file diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogDetailsScreenViewModel.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogDetailsScreenViewModel.kt new file mode 100644 index 0000000..69aee4a --- /dev/null +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogDetailsScreenViewModel.kt @@ -0,0 +1,94 @@ +package live.ditto.tools.logviewer + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import live.ditto.Ditto +import live.ditto.tools.utils.LogUtils +import live.ditto.tools.utils.Utils +import java.io.File + +class LogDetailsScreenViewModel(val ditto : Ditto, val filesDir: File) : ViewModel(){ + + private val dittoLogUtils = LogUtils( + ditto = ditto, + filesDir = filesDir + ) + + private val _logConfiguration = mutableStateOf(AnnotatedString("")) + val logConfiguration = _logConfiguration + + private val _logDirectoryInfo = mutableStateOf(AnnotatedString("")) + val logDirectoryInfo = _logDirectoryInfo + + fun getLogConfigurationInfo(){ + viewModelScope.launch(Dispatchers.IO) { + val config = dittoLogUtils.getLogConfiguration() + + _logConfiguration.value = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("Max File Age: ") + } + append("${config.maxAge} Hours") + append("\n") + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("Max File Size: ") + } + append("${config.maxSize} MB") + append("\n") + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("Max Files On Disk: ") + } + append("${config.maxFilesOnDisk}") + } + } + } + + fun getLogDirInfo(){ + + viewModelScope.launch(Dispatchers.IO) { + val fileSize = Utils.formatFileSize(dittoLogUtils.getLogFileDirSize()) + + _logDirectoryInfo.value = buildAnnotatedString { + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Log Directory Size: ") + } + append("$fileSize\n") + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Log File Count: ") + } + append("${dittoLogUtils.getLogFileCount()}\n") + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Current Log File Error Count: ") + } + append("${dittoLogUtils.logErrorCount()}") + } + } + } +} + +class LogDetailsScreenViewModelFactory( + private val ditto: Ditto, + private val filesDir: File +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(LogDetailsScreenViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return LogDetailsScreenViewModel(ditto, filesDir = filesDir) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogFileViewerScreenViewModel.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogFileViewerScreenViewModel.kt new file mode 100644 index 0000000..16b1d84 --- /dev/null +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogFileViewerScreenViewModel.kt @@ -0,0 +1,173 @@ +package live.ditto.tools.logviewer + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import live.ditto.Ditto +import live.ditto.tools.utils.LogUtils +import java.io.File +import kotlin.collections.plus +import kotlin.collections.takeLast + +class LogFileViewerScreenViewModel(val ditto : Ditto, val filesDir: File) : ViewModel(){ + + private val dittoLogUtils = LogUtils(filesDir = filesDir, ditto = ditto) + private var tailJob: Job? = null + private var _expandedInnerMenu by mutableStateOf(false) + val isExpandedInnerMenu : Boolean + get() = _expandedInnerMenu + fun setExpandedInnerMenu(value: Boolean){ + _expandedInnerMenu = value + } + private var _isMenuFilter by mutableStateOf(false) + val isMenuFilter : Boolean + get() = _isMenuFilter + fun setMenuFilter(value: Boolean){ + _isMenuFilter = value + } + + private val _logFileList = mutableStateOf>(emptyList()) + val logFileList = _logFileList + + private val _reverse = MutableStateFlow(false) + val reverse: StateFlow = _reverse + + private val _tail = MutableStateFlow(false) + val tail: StateFlow = _tail + + //Search + private val _query = MutableStateFlow("") + val query: StateFlow = _query + + //Log Lines + private val _allLines = MutableStateFlow>>(emptyList()) + val filteredLines: StateFlow>> = + combine(_allLines, _query){ lines, q -> + if (q.isBlank()){ + lines + }else if(isMenuFilter){ + lines.filter { it -> + if (q == "FATAL"){ + (it["level"] as String).contains(q, ignoreCase = true) + || (it["level"] as String).contains("PANIC", ignoreCase = true) + }else{ + (it["level"] as String).contains(q) + } + } + }else{ + val trimmedQuery = q.trim() + lines + .filter { + (it["message"] as String).contains(trimmedQuery, ignoreCase = true) + || (it["level"] as String).contains(trimmedQuery, ignoreCase = true) + || (it["target"] as String).contains(trimmedQuery, ignoreCase = true) + } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + + init { + setup() + } + + private fun getLogFileNameList() : Job { + return viewModelScope.launch { + val list : MutableList = mutableListOf() + dittoLogUtils.getLogFileList().forEach { file -> + list.add(file.name) + } + _logFileList.value = list + } + } + + private fun loadLogFile(fileName : String) : Job { + return viewModelScope.launch{ + _allLines.value = dittoLogUtils.readLogFile(fileName = fileName) + } + } + + private fun setup(){ + viewModelScope.launch(Dispatchers.IO) { + val fileNamesJob = getLogFileNameList() + fileNamesJob.join() + if (logFileList.value.isNotEmpty()) { + loadLogFile(logFileList.value[0]) + } + } + } + + fun onQueryChange(newQuery: String){ + _query.value = newQuery + } + + fun toggleReverse(){ + _reverse.value = !_reverse.value + } + + fun toggleTail(){ + _tail.value = !_tail.value + + if (_tail.value){ + startTrailing() + }else{ + stopTrailing() + } + } + + /** + * Trails log file and takes the last 5000 lines + */ + private fun startTrailing(){ + tailJob?.cancel() + tailJob = viewModelScope.launch(Dispatchers.IO) { + dittoLogUtils.tailLogFile().collect { newLine -> + _allLines.update { old -> + val updatedLine = dittoLogUtils.parseLogLine(newLine) + updatedLine?.let { (old + it).takeLast(5000) } ?: old + } + } + } + } + + /** + * Stops trailing log job + */ + private fun stopTrailing(){ + tailJob?.cancel() + tailJob = null + } + + override fun onCleared() { + stopTrailing() + super.onCleared() + } +} + +class LogFileScreenViewModelFactory( + private val ditto: Ditto, + private val filesDir: File +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(LogFileViewerScreenViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return LogFileViewerScreenViewModel(ditto, filesDir) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogViewerScreen.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogViewerScreen.kt new file mode 100644 index 0000000..f78b718 --- /dev/null +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/logviewer/LogViewerScreen.kt @@ -0,0 +1,539 @@ +package live.ditto.tools.logviewer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +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.collectAsState +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 kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import live.ditto.Ditto +import live.ditto.tools.utils.LogUtils.Companion.getBackgroundColor +import live.ditto.tools.R + +@Composable +fun LogViewerScreen( + ditto: Ditto, + logFileViewerScreenViewModel: LogFileViewerScreenViewModel = viewModel( + factory = LogFileScreenViewModelFactory( + ditto, + filesDir = LocalContext.current.applicationContext.filesDir + ) + ) +) { + + val lines by logFileViewerScreenViewModel.filteredLines.collectAsState() + val searchQuery by logFileViewerScreenViewModel.query.collectAsState() + val reverse by logFileViewerScreenViewModel.reverse.collectAsState() + val tail by logFileViewerScreenViewModel.tail.collectAsState() + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(lines, tail, reverse) { + if (tail && lines.isNotEmpty()){ + val targetIndex = if (reverse) 0 else lines.lastIndex + listState.scrollToItem(targetIndex) + } + } + + LaunchedEffect(listState.isScrollInProgress) { + if(listState.isScrollInProgress && tail){ + logFileViewerScreenViewModel.toggleTail() + } + } + + Column { + + Row( + modifier = Modifier + .fillMaxWidth() + .height(height = 48.dp) + .padding(start = 16.dp, end = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.log_details_label), style = MaterialTheme.typography.headlineMedium + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TailIndicator(tail) + + LogDropdownMenu( + setExpandedNested = { value -> logFileViewerScreenViewModel.setExpandedInnerMenu(value) }, + tailEnabled = tail, + onToggleTail = logFileViewerScreenViewModel::toggleTail, + onToggleReverse = logFileViewerScreenViewModel::toggleReverse, + ) + + NestedMenu( + getExpanded = { logFileViewerScreenViewModel.isExpandedInnerMenu}, + setExpanded = { value -> logFileViewerScreenViewModel.setExpandedInnerMenu(value) }, + setMenuFilter = { value -> logFileViewerScreenViewModel.setMenuFilter(value) }, + onSearchQueryChange = { query -> logFileViewerScreenViewModel.onQueryChange(query)} + ) + } + } + + if (lines.isEmpty() && searchQuery.isBlank()){ + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.log_no_log_found), + textAlign = TextAlign.Center + ) + } + }else{ + + //Search Box + BasicTextField( + value = searchQuery, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp).height(height = 56.dp), + onValueChange = { + logFileViewerScreenViewModel.setMenuFilter(false) + logFileViewerScreenViewModel.onQueryChange(it) + } + ){ innerTextField -> + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .border(1.dp, Color.Gray, RoundedCornerShape(4.dp)) + ) { + + //Leading Icon + Icon( + Icons.Default.Search, + contentDescription = "Search", + modifier = Modifier.padding(start = 4.dp) + ) + + // Text Field + Box(Modifier.weight(1f)) { + innerTextField() + } + + // Trailing icon + if (searchQuery.isNotEmpty()){ + IconButton(onClick = { + logFileViewerScreenViewModel.onQueryChange("") + logFileViewerScreenViewModel.setMenuFilter(false) + }) { + Icon( + Icons.Default.Close, + contentDescription = "Clear" + ) + } + } + } + + } + + Spacer(modifier = Modifier.height(8.dp)) + + LogLevelLegend() + + if (lines.isEmpty()){ + Column ( + modifier = Modifier.fillMaxSize(), // Make the Box fill the whole screen + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(imageVector = Icons.Default.Warning, + contentDescription = "Warning", + tint = colorResource(R.color.log_warn), + modifier = Modifier.size(100.dp).padding(bottom = 6.dp) + ) + Text( + text = stringResource(R.string.log_no_result), + style = MaterialTheme.typography.titleLarge + ) + } + }else{ + //Log Lines + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + reverseLayout = reverse, + modifier = Modifier.fillMaxSize() + ) { + items(lines) { logLine -> + LogCard(logLine = logLine) + } + } + if (listState.firstVisibleItemIndex > 0) { + FloatingActionButton( + onClick = { + coroutineScope.launch { + listState.animateScrollToItem(0) + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + Icon(Icons.Default.KeyboardArrowUp, contentDescription = "Scroll to top") + } + } + } + } + } + } +} + +@Composable +fun LogCard(logLine: Map) { + + var expanded by remember { mutableStateOf(false) } + + val aString = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("${logLine["level"]}") + } + append(" ${logLine["timestamp"]}\n") + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("Target: ") + } + append("${logLine["target"]}\n") + + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("Message: ") + } + append("${logLine["message"]}") + + logLine["error"]?.let { it as String + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("\nError: ") + } + append(it) + } + } + + Card( + colors = CardDefaults.cardColors( + containerColor = colorResource( getBackgroundColor(logLine["level"] as String)) + ), + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) + .fillMaxWidth() + .clickable { expanded = !expanded } + ) { + Text(text = aString, modifier = Modifier.padding(all = 16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text(if (expanded) stringResource(R.string.log_less) else stringResource(R.string.log_more)) + Icon( + if (expanded) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowRight, contentDescription = "Expand" + ) + + } + // Only show detailed content when expanded + AnimatedVisibility(visible = expanded) { + Text(getExpandedText(logLine), modifier = Modifier.padding(start = 16.dp, end = 16.dp)) + } + } +} + +@Composable +fun LogLevelLegend() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + val levels = listOf("DEBUG", "INFO", "WARN", "ERROR", "FATAL") + levels.forEach { level -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(10.dp) + .background( + color = colorResource(getBackgroundColor(level)), + shape = CircleShape + ) + ) + Text(level, style = MaterialTheme.typography.labelSmall) + } + } + } +} + +@Composable +fun LogDropdownMenu( + setExpandedNested: (Boolean) -> Unit, + tailEnabled: Boolean, + onToggleTail: () -> Unit, + onToggleReverse: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Box{ + IconButton(onClick = { expanded = !expanded }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.log_tail)) }, + onClick = { + onToggleTail() + expanded = false + }, + trailingIcon = { + if(tailEnabled){ + Icon(imageVector = Icons.Default.Check, contentDescription = "Tail Logs" ) + } + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.log_reverse)) }, + onClick = { + onToggleReverse() + expanded = false + }, + trailingIcon = { + Icon(Icons.Default.SwapVert, contentDescription = "Reverse Logs" ) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.log_filter)) }, + onClick = { + expanded = false + setExpandedNested(true) + }, + trailingIcon = { + Icon(Icons.Default.FilterAlt, contentDescription = "Filter" ) + } + ) + } + } +} + +private fun getExpandedText(logLine: Map): AnnotatedString{ + + val threadId = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("ThreadId: ") + } + append("${logLine["threadId"]}\n") + } + + val threadName = logLine["threadName"]?.let { + buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append("ThreadName: ") + } + append("$it\n") + } + } + + val other = buildAnnotatedString { + logLine.filter { map -> map.key != "message" + && map.key != "level" + && map.key != "error" + && map.key != "timestamp" + && map.key != "target" + && map.key != "threadId" + && map.key != "threadName" + && map.key != "span" + && map.key != "spans" + }.forEach { (key, value) -> + + if (value is Map<*,*>){ + value.forEach { (key, value) -> + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append(key as String) + } + append(": $value\n") + } + }else{ + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append(key) + } + append(": $value\n") + } + } + } + + //NOTE: Seems that what is inside of span is also contained in spans?? + //Need to confirm. + val span = buildAnnotatedString { + val map = logLine["span"] + if (map != null && map is Map<*,*>){ + map.forEach { (key, value) -> + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append(key as String) + } + append(": $value\n") + } + } + } + + val spans = buildAnnotatedString { + val spansList = logLine["spans"] + + spansList?.let { + if (it is List<*>){ + it.forEach { span -> + (span as Map<*, *>).forEach { (key, value) -> + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)){ + append(key as String) + } + append(": $value\n") + } + } + } + } + } + + return threadName?.let { it + threadId + if (spans.isEmpty()) span else spans + other } + ?: run { threadId + if (spans.isEmpty()) span else spans + other} +} + +@Composable +private fun TailIndicator(isTailing: Boolean){ + if (!isTailing) return + + val alpha by rememberInfiniteTransition().animateFloat( + initialValue = 0.4F, + targetValue = 1F, + animationSpec = infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse + ) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 8.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = colorResource(R.color.log_tail).copy(alpha = alpha), + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.log_tail_live), style = MaterialTheme.typography.labelSmall, color = Color(0xFF4CAF50)) + } +} + +@Composable +private fun NestedMenu( + getExpanded: () -> Boolean, + setExpanded: (Boolean) -> Unit, + setMenuFilter: (Boolean) -> Unit, + onSearchQueryChange: (String) -> Unit, +){ + + val logLevels = listOf("FATAL","ERROR", "WARN", "INFO", "DEBUG") + + DropdownMenu( + expanded = getExpanded(), + onDismissRequest = { setExpanded(false) } + ) { + logLevels.forEach { logLevel -> + DropdownMenuItem( + text = { Text(logLevel) }, + leadingIcon = { + Box( + modifier = Modifier + .size(10.dp) + .background( + color = colorResource(getBackgroundColor(logLevel)), + shape = CircleShape + ) + ) + }, + onClick = { + setMenuFilter(true) + setExpanded(false) + onSearchQueryChange(logLevel) + } + ) + } + } +} \ No newline at end of file diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/DittoToolsViewer.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/DittoToolsViewer.kt index 0d9b193..1741bc1 100644 --- a/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/DittoToolsViewer.kt +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/DittoToolsViewer.kt @@ -36,6 +36,8 @@ import live.ditto.tools.diskusage.DittoDiskUsage import live.ditto.tools.exportlogs.ExportLogs import live.ditto.tools.exportlogs.ExportLogsToPortal import live.ditto.tools.health.ui.composables.HealthScreen +import live.ditto.tools.logviewer.LogDetailsScreen +import live.ditto.tools.logviewer.LogViewerScreen import live.ditto.tools.peerslist.PeersListViewer import live.ditto.tools.presencedegradationreporter.PresenceDegradationReporterScreen import live.ditto.tools.presenceviewer.DittoPresenceViewer @@ -133,6 +135,7 @@ private fun DittoToolsViewerScaffold( Screens.HealthScreen.route -> stringResource(R.string.health_viewer_tool_label) Screens.HeartbeatScreen.route -> stringResource(R.string.heartbeat_tool_label) Screens.PresenceDegradationReporterScreen.route -> stringResource(R.string.presence_degradation_reporter_tool_label) + Screens.LogDetailsScreen.route -> stringResource(R.string.log_details_label) else -> stringResource(R.string.tools_menu_title) } ) @@ -249,6 +252,15 @@ private fun ToolsViewerNavHost( composable(Screens.PresenceDegradationReporterScreen.route) { PresenceDegradationReporterScreen(ditto = ditto) } + composable(Screens.LogDetailsScreen.route) { + LogDetailsScreen( + onButtonClick = { navController.navigate(route = Screens.LogViewerScreen.route) }, + ditto = ditto + ) + } + composable(Screens.LogViewerScreen.route) { + LogViewerScreen(ditto = ditto) + } } } diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/navigation/Screens.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/navigation/Screens.kt index 077fbe1..fa60fbf 100644 --- a/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/navigation/Screens.kt +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/navigation/Screens.kt @@ -35,4 +35,10 @@ sealed class Screens { object PresenceDegradationReporterScreen: Screen { override val route = "presenceDegradationReporter" } + object LogDetailsScreen: Screen { + override val route = "logDetails" + } + object LogViewerScreen: Screen { + override val route = "logViewer" + } } \ No newline at end of file diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/viewmodel/ToolsViewerViewModel.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/viewmodel/ToolsViewerViewModel.kt index 5083df4..95db2db 100644 --- a/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/viewmodel/ToolsViewerViewModel.kt +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/toolsviewer/viewmodel/ToolsViewerViewModel.kt @@ -3,6 +3,7 @@ package live.ditto.tools.toolsviewer.viewmodel import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.DensitySmall import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.HealthAndSafety import androidx.compose.material.icons.filled.Hub @@ -68,6 +69,11 @@ class ToolsViewerViewModel: ViewModel() { route = Screens.DataBrowserScreen.route, icon = Icons.Default.Search ), + ToolMenuItem( + label = R.string.log_details_label, + route = Screens.LogDetailsScreen.route, + icon = Icons.Default.DensitySmall + ), ToolMenuItem( label = R.string.export_logs_to_portal_tool_label, route = Screens.ExportLogsToPortalScreen.route, diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/utils/LogUtils.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/utils/LogUtils.kt new file mode 100644 index 0000000..2d53dca --- /dev/null +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/utils/LogUtils.kt @@ -0,0 +1,227 @@ +package live.ditto.tools.utils + +import android.util.Log +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import live.ditto.Ditto +import live.ditto.tools.R +import live.ditto.tools.data.LogConfiguration +import java.io.File +import java.io.RandomAccessFile + +class LogUtils(filesDir: File, val ditto: Ditto) { + private val dittoFileDir = filesDir + private val dittoLogDir = File("${dittoFileDir.path}/ditto/ditto_logs") + private val logConfigItems = listOf(MAX_AGE, MAX_SIZE, MAX_FILES_ON_DISK) + + /** + * Reads log configuration from store + * @return LogConfiguration + * */ + suspend fun getLogConfiguration() : LogConfiguration { + + var maxAge: Int = -1 + var maxFilesOnDisk: Int = -1 + var maxSize: Int = -1 + + withContext(Dispatchers.IO) { + logConfigItems.forEach { configItem -> + val resultItem = ditto.store.execute("SHOW $configItem").items[0] + when(configItem){ + MAX_AGE -> maxAge = resultItem.value[MAX_AGE] as Int + MAX_SIZE -> maxSize = resultItem.value[MAX_SIZE] as Int + MAX_FILES_ON_DISK -> maxFilesOnDisk = resultItem.value[MAX_FILES_ON_DISK] as Int + } + } + } + + return LogConfiguration(maxAge = maxAge, maxFilesOnDisk = maxFilesOnDisk, maxSize = maxSize) + } + + /** + * Log File directory size in bytes + * */ + fun getLogFileDirSize() : Long{ + var size: Long = 0 + + try { + if(dittoLogDir.exists() && dittoLogDir.isDirectory){ + dittoLogDir.walkTopDown() + .filter { it.isFile } + .forEach{ size += it.length() } + } + }catch (e: Exception){ + Log.e(TAG, "Error getting log directory size", e) + } + + return size + } + + /** + * Returns number of log files currently in directory + * */ + fun getLogFileCount() : Int{ + var count = 0 + try { + if(dittoLogDir.exists() && dittoLogDir.isDirectory){ + dittoLogDir.walkTopDown() + .filter { it.isFile } + .forEach{ _ -> count++} + } + }catch (e: Exception){ + Log.e(TAG, "Error getting log directory count", e) + } + return count + } + + suspend fun getLogFileList() : List { + var result : List = emptyList() + withContext(Dispatchers.IO){ + try{ + if(dittoLogDir.exists() && dittoLogDir.isDirectory){ + result = dittoLogDir.listFiles()?.toList() ?: emptyList() + }else return@withContext + }catch (e: Exception ){ + Log.e(TAG, "Error getting log files", e) + } + } + return result + } + + /** + * Parses log line + * @param line line that will be parsed + * @return List containing log lines parsed. + * */ + fun parseLogLine(line: String) : Map?{ + var logLine : Map? = null + try { + logLine = moshiAdapter.lenient().fromJson(line) + }catch (e: Exception){ + Log.e(TAG, "Error parsing log line [$line]", e) + } + return logLine + } + + /** + * Reads MAX_LINES number of lines. + * @param fileName Name of file used to read. + * @return List containing log lines parsed. + * */ + suspend fun readLogFile(fileName: String) : ArrayList> { + val lines = ArrayList>(minOf(MAX_LINES, 1024)) + withContext(Dispatchers.IO){ + + try { + val bufferedReader = if (dittoLogDir.exists() && dittoLogDir.isDirectory){ + dittoLogDir.listFiles() + ?.filter { file -> file.name == fileName } + ?.get(0)?.bufferedReader() + } else null + + bufferedReader?.useLines { sequence -> + sequence.take(MAX_LINES).forEach { line -> + val output = parseLogLine(line) + output?.let { lines.add(it) } + } + } + }catch (e: Exception){ + Log.e(TAG, "Error reading log file $e") + } + } + return lines + } + + /** + * Checks number of errors contained in log file. + * Based solely on log level ERROR + * */ + fun logErrorCount() : Int { + var count = 0 + + try{ + if(dittoLogDir.exists() && dittoLogDir.isDirectory){ + dittoLogDir.listFiles() + ?.filter { file -> file.name.endsWith(".log") } + ?.get(0) + ?.bufferedReader() + ?.useLines { sequence -> + sequence.forEach { line -> if (line.contains(ERROR_REGEX)) count++ } + } + } + }catch (e: Exception ){ + Log.e(TAG, "Error getting log files: $e") + } + + return count + } + + fun getCurrentLogFile() : File?{ + var result : File? = null + try{ + if(dittoLogDir.exists() && dittoLogDir.isDirectory){ + result = dittoLogDir.listFiles() + ?.firstOrNull { file -> file.name.endsWith(".log") } + } + }catch (e: Exception ){ + Log.e(TAG, "Error getting log files: $e") + } + return result + } + + fun tailLogFile(): Flow = flow { + val file = getCurrentLogFile() ?: return@flow + + RandomAccessFile(file, "r").use { raf -> + var filePointer = raf.length() + raf.seek(filePointer) + while (true) { + val newLength = file.length() + if (newLength > filePointer) { + raf.seek(filePointer) + + while (true) { + val line = raf.readLine() ?: break + emit(line) + } + + filePointer = raf.filePointer + } + delay(1000) + } + } + }.flowOn(Dispatchers.IO) + + companion object{ + private const val TAG = "DittoLogUtils" + private const val MAX_SIZE = "rotating_log_file_max_size_mb" + private const val MAX_AGE = "rotating_log_file_max_age_h" + private const val MAX_FILES_ON_DISK = "rotating_log_file_max_files_on_disk" + private const val MAX_LINES = 5000 //Max log lines to read to protect against a big file + + // Regex pattern that captures "level":"ERROR" and standalone "error" word + // but ignores metrics like doc_id_filter_error_rate + private val ERROR_REGEX = Regex(""""level"\s*:\s*"ERROR"|(?>(paramType) + fun getBackgroundColor(level: String) : Int{ + return when(level){ + "DEBUG" -> R.color.log_debug + "INFO" -> R.color.log_info + "WARN" -> R.color.log_warn + "ERROR" -> R.color.log_error + "FATAL", "PANIC" -> R.color.log_fatal + else -> R.color.log_unknown + } + } + } +} \ No newline at end of file diff --git a/DittoToolsAndroid/src/main/java/live/ditto/tools/utils/Utils.kt b/DittoToolsAndroid/src/main/java/live/ditto/tools/utils/Utils.kt new file mode 100644 index 0000000..6434633 --- /dev/null +++ b/DittoToolsAndroid/src/main/java/live/ditto/tools/utils/Utils.kt @@ -0,0 +1,15 @@ +package live.ditto.tools.utils + +class Utils { + companion object{ + + fun formatFileSize(bytes: Long) : String = + when { + bytes >= 1 shl 30 -> "%.1f GB".format(bytes.toDouble() / (1 shl 30)) + bytes >= 1 shl 20 -> "%.1f MB".format(bytes.toDouble() / (1 shl 20)) + bytes >= 1 shl 10 -> "%.0f kB".format(bytes.toDouble() / (1 shl 10)) + else -> "$bytes B" + } + } + +} \ No newline at end of file diff --git a/DittoToolsAndroid/src/main/res/values/colors.xml b/DittoToolsAndroid/src/main/res/values/colors.xml new file mode 100644 index 0000000..7345d08 --- /dev/null +++ b/DittoToolsAndroid/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + + #64B5F6 + #81C784 + #FFB74D + #E57373 + #BA68C8 + #B0BEC5 + #4CAF50 + diff --git a/DittoToolsAndroid/src/main/res/values/strings.xml b/DittoToolsAndroid/src/main/res/values/strings.xml index be8f0d6..b1a9173 100644 --- a/DittoToolsAndroid/src/main/res/values/strings.xml +++ b/DittoToolsAndroid/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ Disk Usage Health Viewer Heartbeat + Log Viewer Presence Degradation Reporter @@ -102,4 +103,20 @@ Export failed Log export failed: %1$s + + Log Details + Tail Logs + Live (1s Delay) + View Current Log + Log Details + Log Configuration + Log Directory Info + Reverse Log + Filter + Search Log + No log found. + No Result + Less + More + \ No newline at end of file diff --git a/Img/log_details.png b/Img/log_details.png new file mode 100644 index 0000000..2900ac4 Binary files /dev/null and b/Img/log_details.png differ diff --git a/Img/log_tailing.png b/Img/log_tailing.png new file mode 100644 index 0000000..c154626 Binary files /dev/null and b/Img/log_tailing.png differ diff --git a/Img/log_viewer.png b/Img/log_viewer.png new file mode 100644 index 0000000..15513fc Binary files /dev/null and b/Img/log_viewer.png differ diff --git a/README.md b/README.md index 99cf56c..44b4dc9 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,78 @@ ditto.presenceDegradationReporterFlow().collect { state: PresenceDegradationRepo Health +### 10. Log File Viewer + +The Log File Viewer provides a comprehensive interface for viewing and analyzing Ditto log files directly within your application. It includes two main screens: + +**Log Details Screen** - Displays log configuration settings and directory information: +- Max file age, size, and files on disk settings +- Total log directory size +- Log file count +- Current log file error count + +**Log File Screen** - View and analyze log file contents with: +- Real-time log tailing (continuously updates as new logs are written) +- Search and filter capabilities by log level (DEBUG, INFO, WARN, ERROR) +- Expandable log entries for detailed inspection +- Reverse chronological order option + +#### UI Composables + +**Log Details Screen:** +```kotlin +LogDetailsScreen( + ditto = ditto, + onButtonClick = { /* Navigate to log file screen */ } +) +``` + +**Log File Screen:** +```kotlin +LogFileScreen( + ditto = ditto +) +``` + +#### Features + +- **Real-time Tailing**: Automatically updates with new log entries as they're written +- **Search**: Full-text search across all log entries +- **Filter by Level**: Show only specific log levels (DEBUG, INFO, WARN, ERROR) +- **Expandable Entries**: Tap to expand and see full log details +- **Reverse Order**: Toggle chronological vs reverse chronological order +- **Color-coded**: Log levels are color-coded for easy identification + - DEBUG: Blue + - INFO: Green + - WARN: Orange + - ERROR: Red + +#### Integration + +The Log File Viewer is available in the Data/Debugging section when using `DittoToolsViewer`. It can be accessed directly through the tools menu or embedded as standalone composables: + +**As Standalone Screens:** +```kotlin +// In your navigation graph +composable("logDetails") { + LogDetailsScreen( + onButtonClick = { navController.navigate("logFile") }, + ditto = ditto + ) +} + +composable("logFile") { + LogFileScreen(ditto = ditto) +} + +//Or if only interested in LogFileScreen can use as standalone +LogFileScreen(ditto = ditto) +``` +
+ Log Details + Log Viewer + Log Tails +
## Shrinking the app size then not using all tools diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 299abf1..03224b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ androidx-appcompat = "1.6.1" androidx-compose = "2023.06.01" androidx-navigation = "2.5.3" androidx-test-ext = "1.1.5" +moshiKotlin = "1.15.2" webkit = "1.7.0" datastorePreferences = "1.0.0" ditto = "4.11.6" @@ -46,6 +47,7 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } +moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshiKotlin" } [plugins] com-android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }