Skip to content
Open
3 changes: 3 additions & 0 deletions DittoToolsAndroid/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package live.ditto.tools.data

data class LogConfiguration(
val maxAge : Int,
val maxFilesOnDisk: Int,
val maxSize: Int
)
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Original file line number Diff line number Diff line change
@@ -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>(AnnotatedString(""))
val logConfiguration = _logConfiguration

private val _logDirectoryInfo = mutableStateOf<AnnotatedString>(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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(LogDetailsScreenViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return LogDetailsScreenViewModel(ditto, filesDir = filesDir) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Original file line number Diff line number Diff line change
@@ -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<List<String>>(emptyList())
val logFileList = _logFileList

private val _reverse = MutableStateFlow(false)
val reverse: StateFlow<Boolean> = _reverse

private val _tail = MutableStateFlow(false)
val tail: StateFlow<Boolean> = _tail

//Search
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query

//Log Lines
private val _allLines = MutableStateFlow<List<Map<String, Any>>>(emptyList())
val filteredLines: StateFlow<List<Map<String, Any>>> =
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<String> = mutableListOf<String>()
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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(LogFileViewerScreenViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return LogFileViewerScreenViewModel(ditto, filesDir) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Loading