├── .idea ├── .name ├── .gitignore ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── compiler.xml ├── kotlinc.xml ├── vcs.xml ├── inspectionProfiles │ ├── profiles_settings.xml │ └── ktlint.xml ├── LogcatViewer.iml ├── kotlinScripting.xml ├── misc.xml └── jarRepositories.xml ├── gradle.properties ├── .editorconfig ├── LogJerry.icns ├── src ├── test │ ├── resources │ │ └── junit-platform.properties │ └── kotlin │ │ └── com │ │ └── jerryjeon │ │ └── logjerry │ │ └── parse │ │ ├── ParserTest.kt │ │ ├── StudioLogcatAboveDolphinParserTest.kt │ │ └── StudioLogcatBelowChipmunkParserTest.kt └── main │ ├── resources │ └── LogJerry.png │ └── kotlin │ └── com │ └── jerryjeon │ └── logjerry │ ├── ui │ ├── focus │ │ ├── LogFocus.kt │ │ ├── MarkFocus.kt │ │ ├── DetectionFocus.kt │ │ └── KeyboardFocus.kt │ ├── popup │ │ ├── TextFilterPopup.kt │ │ ├── PriorityFilterPopup.kt │ │ ├── BasePopup.kt │ │ ├── TagFilterPopup.kt │ │ └── PackageFilterPopup.kt │ ├── HeaderView.kt │ ├── AppliedTextFilter.kt │ ├── ColumnVisibilityView.kt │ ├── PriorityFilterView.kt │ ├── InvalidSentences.kt │ ├── DetectionView.kt │ ├── ShortcutDialog.kt │ ├── MarkDetectionView.kt │ ├── TextFilterView.kt │ ├── MarkDialog.kt │ └── ParseCompletedView.kt │ ├── filter │ ├── FilterSortOption.kt │ ├── SortOrder.kt │ ├── LogFilter.kt │ ├── HiddenFilter.kt │ ├── PriorityFilter.kt │ ├── TagFilter.kt │ ├── PackageFilter.kt │ ├── TextFilter.kt │ ├── SortOptionDialog.kt │ └── FilterManager.kt │ ├── tab │ ├── Tabs.kt │ ├── Tab.kt │ └── TabManager.kt │ ├── parse │ ├── LogParser.kt │ ├── ParseResult.kt │ ├── ParserFactory.kt │ ├── CustomParser.kt │ ├── ParseStatus.kt │ ├── FirebaseTestLabLogcatFormatParser.kt │ ├── StudioLogcatBelowChipmunkParser.kt │ ├── StudioLogcatAboveDolphinParser.kt │ └── AdbLogcatDefaultFormatParser.kt │ ├── logview │ ├── LogSelection.kt │ ├── MarkInfo.kt │ ├── RefinedLog.kt │ ├── RefineResult.kt │ └── LogAnnotation.kt │ ├── detector │ ├── DetectorKey.kt │ ├── KeywordDetectionRequest.kt │ ├── DetectionStatus.kt │ ├── Detector.kt │ ├── BracketFinder.kt │ ├── KeywordDetector.kt │ ├── MarkDetector.kt │ ├── JsonDetector.kt │ ├── ExceptionDetector.kt │ ├── DetectorManager.kt │ ├── DataClassDetector.kt │ └── KeywordDetectionView.kt │ ├── mark │ └── LogMark.kt │ ├── preferences │ ├── WindowSize.kt │ ├── SortOrderPreferences.kt │ ├── Preferences.kt │ └── PreferencesViewModel.kt │ ├── log │ ├── LogContent.kt │ ├── LogContentView.kt │ ├── SampleData.kt │ ├── Log.kt │ └── ParseCompleted.kt │ ├── table │ ├── ColumnInfo.kt │ ├── ColumnType.kt │ └── Header.kt │ ├── util │ ├── Clipboard.kt │ └── Keyboard.kt │ ├── source │ ├── Source.kt │ └── SourceManager.kt │ ├── detection │ └── DetectionFinished.kt │ └── serialization │ ├── ColorAsLongSerializer.kt │ └── TextUnitAsFloatSerializer.kt ├── gradle ├── wrapper │ └── gradle-wrapper.properties └── libs.versions.toml ├── settings.gradle.kts ├── proguard-rules.pro ├── README.md ├── .gitignore ├── gradlew.bat └── gradlew /.idea/.name: -------------------------------------------------------------------------------- 1 | LogJerry -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | disabled_rules = no-wildcard-imports -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /LogJerry.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerry-jeon/LogJerry/HEAD/LogJerry.icns -------------------------------------------------------------------------------- /src/test/resources/junit-platform.properties: -------------------------------------------------------------------------------- 1 | junit.jupiter.testinstance.lifecycle.default = per_class 2 | -------------------------------------------------------------------------------- /src/main/resources/LogJerry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerry-jeon/LogJerry/HEAD/src/main/resources/LogJerry.png -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/focus/LogFocus.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.focus 2 | 3 | interface LogFocus { 4 | val index: Int 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/FilterSortOption.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | enum class FilterSortOption { 4 | Name, Frequency, 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/SortOrder.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | enum class SortOrder { 4 | Ascending, 5 | Descending 6 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/tab/Tabs.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.tab 2 | 3 | data class Tabs( 4 | val tabList: List, 5 | val active: Tab 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/focus/MarkFocus.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.focus 2 | 3 | data class MarkFocus( 4 | override val index: Int 5 | ) : LogFocus 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/LogParser.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | interface LogParser { 4 | fun parse(rawLines: List): ParseResult 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/focus/DetectionFocus.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.focus 2 | 3 | data class DetectionFocus( 4 | override val index: Int 5 | ) : LogFocus -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/focus/KeyboardFocus.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.focus 2 | 3 | data class KeyboardFocus( 4 | override val index: Int 5 | ) : LogFocus 6 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/logview/LogSelection.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.logview 2 | 3 | data class LogSelection( 4 | val refinedLog: RefinedLog, 5 | val index: Int 6 | ) -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/LogFilter.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | 5 | interface LogFilter { 6 | fun filter(log: Log): Boolean 7 | } 8 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | gradlePluginPortal() 5 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 6 | 7 | } 8 | } 9 | rootProject.name = "LogJerry" 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/DetectorKey.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | // When we support custom detection, then key should be String 4 | enum class DetectorKey { 5 | Keyword, Exception, Json, Mark, DataClass; 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/ParseResult.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | 5 | data class ParseResult( 6 | val logs: List, 7 | val invalidSentences: List> 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/KeywordDetectionRequest.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | sealed class KeywordDetectionRequest { 4 | class TurnedOn(val keyword: String) : KeywordDetectionRequest() 5 | object TurnedOff : KeywordDetectionRequest() 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/ParserFactory.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | interface ParserFactory { 4 | 5 | /** 6 | * If the parser can't handle the sample then return null 7 | */ 8 | fun create(sample: String): LogParser? 9 | 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/mark/LogMark.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.mark 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import com.jerryjeon.logjerry.log.Log 5 | 6 | data class LogMark( 7 | val log: Log, 8 | val note: String, 9 | val color: Color 10 | ) -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/preferences/WindowSize.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.preferences 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | // null to maximize 6 | @Serializable 7 | data class WindowSize( 8 | val width: Int, 9 | val height: Int, 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/log/LogContent.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.log 2 | 3 | sealed class LogContent(val text: String) { 4 | class Text(text: String) : LogContent(text) 5 | class Json(text: String) : LogContent(text) 6 | 7 | class DataClass(text: String) : LogContent(text) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/table/ColumnInfo.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.table 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ColumnInfo( 7 | val columnType: ColumnType, 8 | val width: Int?, // null means new weight 1f 9 | val visible: Boolean 10 | ) 11 | -------------------------------------------------------------------------------- /.idea/LogcatViewer.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/HiddenFilter.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import com.jerryjeon.logjerry.log.Priority 5 | 6 | data class HiddenFilter(val hiddenLogIndices: Set) : LogFilter { 7 | override fun filter(log: Log): Boolean { 8 | return log.index !in hiddenLogIndices 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/util/Clipboard.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.util 2 | 3 | import java.awt.Toolkit 4 | import java.awt.datatransfer.StringSelection 5 | 6 | fun copyToClipboard(text: String) { 7 | val selection = StringSelection(text) 8 | Toolkit.getDefaultToolkit() 9 | .systemClipboard 10 | .setContents(selection, selection) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/DetectionStatus.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | data class DetectionStatus( 4 | val key: DetectorKey, 5 | val currentIndex: Int, 6 | val selected: Detection?, 7 | val allDetections: List, 8 | ) { 9 | val totalCount = allDetections.size 10 | val currentIndexInView = currentIndex + 1 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/PriorityFilter.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import com.jerryjeon.logjerry.log.Priority 5 | 6 | data class PriorityFilter(val priority: Priority) : LogFilter { 7 | override fun filter(log: Log): Boolean { 8 | return priority.level <= log.priority.level 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/ktlint.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/logview/MarkInfo.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.logview 2 | 3 | sealed class MarkInfo { 4 | class Marked( 5 | val markedLog: RefinedLog, 6 | ) : MarkInfo() 7 | 8 | class StatBetweenMarks( 9 | val logCount: Int, 10 | val duration: String?, // ex) 1h 2m 3s, and null if it's not able to calculate 11 | ) : MarkInfo() 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/CustomParser.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | class CustomParser : LogParser { 4 | override fun parse(rawLines: List): ParseResult { 5 | TODO("Not yet implemented") 6 | } 7 | 8 | companion object : ParserFactory { 9 | override fun create(sample: String): LogParser? { 10 | return null 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.idea/kotlinScripting.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 2147483647 6 | true 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/tab/Tab.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.tab 2 | 3 | import com.jerryjeon.logjerry.preferences.Preferences 4 | import com.jerryjeon.logjerry.source.SourceManager 5 | 6 | class Tab( 7 | val name: String, 8 | val sourceManager: SourceManager, 9 | ) { 10 | companion object { 11 | fun gettingStarted(preferences: Preferences) = Tab("Getting Started", SourceManager(preferences)) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/ParseStatus.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import com.jerryjeon.logjerry.log.ParseCompleted 4 | 5 | sealed class ParseStatus { 6 | object NotStarted : ParseStatus() 7 | data class Proceeding( 8 | val percent: Int 9 | ) : ParseStatus() 10 | class Completed( 11 | val parseResult: ParseResult, 12 | val parseCompleted: ParseCompleted, 13 | ) : ParseStatus() 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/Detector.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | import androidx.compose.ui.text.SpanStyle 4 | 5 | interface Detector { 6 | val key: DetectorKey 7 | val shownAsBlock: Boolean 8 | fun detect(logStr: String, logIndex: Int): List 9 | } 10 | 11 | interface Detection { 12 | val id: String 13 | val key: DetectorKey 14 | val range: IntRange 15 | val logIndex: Int 16 | val style: SpanStyle 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/log/LogContentView.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.log 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.AnnotatedString 5 | 6 | sealed class LogContentView { 7 | 8 | class Simple(val str: AnnotatedString) : LogContentView() 9 | 10 | class Block( 11 | val cation: String, 12 | val str: AnnotatedString, 13 | val background: Color?, 14 | val lineCount: Int 15 | ) : LogContentView() 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/source/Source.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.source 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | sealed class Source { 7 | 8 | class ZipFile(val file: java.io.File) : Source() 9 | 10 | class File(val file: java.io.File) : Source() 11 | 12 | class Text(val text: String) : Source() 13 | 14 | class LogsFlow(val logs: StateFlow>) : Source() 15 | 16 | object None : Source() 17 | } 18 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/TagFilter.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | 5 | data class TagFilter( 6 | val tag: String?, 7 | val frequency: Int, 8 | val include: Boolean 9 | ) 10 | 11 | data class TagFilters( 12 | val filters: List 13 | ) : LogFilter { 14 | override fun filter(log: Log): Boolean { 15 | return filters.any { 16 | it.include && it.tag == log.tag 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detection/DetectionFinished.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detection 2 | 3 | import com.jerryjeon.logjerry.detector.Detection 4 | import com.jerryjeon.logjerry.detector.Detector 5 | import com.jerryjeon.logjerry.detector.DetectorKey 6 | import com.jerryjeon.logjerry.log.Log 7 | 8 | data class DetectionFinished( 9 | val detectors: List>, 10 | val detectionsByLog: Map>>, 11 | val allDetections: Map>, 12 | ) 13 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/PackageFilter.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | 5 | data class PackageFilter( 6 | val packageName: String?, 7 | val frequency: Int, 8 | val include: Boolean 9 | ) 10 | 11 | data class PackageFilters( 12 | val filters: List 13 | ) : LogFilter { 14 | override fun filter(log: Log): Boolean { 15 | return filters.any { 16 | it.include && it.packageName == log.packageName 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/log/SampleData.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.log 2 | 3 | 4 | object SampleData { 5 | val rawLog = "2021-12-19 23:05:36.664 165-165/? I/hwservicemanager: Since android.hardware.media.omx@1.0::IOmxStore/default is not registered, trying to start it as a lazy HAL." 6 | val log = Log( 7 | 1, 8 | "2021-12-19", 9 | "23:05:36.664", 10 | 165, 11 | 165, 12 | null, 13 | "I", 14 | "hwservicemanager", 15 | "Since android.hardware.media.omx@1.0::IOmxStore/default is not registered, trying to start it as a lazy HAL." 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/popup/TextFilterPopup.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.popup 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.geometry.Offset 5 | import com.jerryjeon.logjerry.filter.TextFilter 6 | import com.jerryjeon.logjerry.ui.TextFilterView 7 | 8 | @Composable 9 | fun TextFilterPopup( 10 | showTextFilterPopup: Boolean, 11 | textFilterAnchor: Offset, 12 | dismiss: () -> Unit, 13 | addTextFilter: (TextFilter) -> Unit 14 | ) { 15 | BasePopup(showTextFilterPopup, textFilterAnchor, dismiss) { 16 | TextFilterView(addFilter = addTextFilter, dismiss = dismiss) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/popup/PriorityFilterPopup.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.popup 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.geometry.Offset 5 | import com.jerryjeon.logjerry.filter.PriorityFilter 6 | import com.jerryjeon.logjerry.ui.PriorityFilterView 7 | 8 | @Composable 9 | fun PriorityFilterPopup( 10 | showPopup: Boolean, 11 | anchor: Offset, 12 | priorityFilter: PriorityFilter, 13 | dismiss: () -> Unit, 14 | setPriorityFilter: (PriorityFilter) -> Unit 15 | ) { 16 | BasePopup(showPopup, anchor, dismiss) { 17 | PriorityFilterView(priorityFilter, setPriorityFilter) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -keepattributes Annotation, InnerClasses 2 | -dontnote kotlinx.serialization.AnnotationsKt 3 | -dontnote kotlinx.serialization.SerializationKt 4 | 5 | # Keep Serializers 6 | 7 | -keep,includedescriptorclasses class com.jerryjeon.logjerry.**$$serializer { *; } 8 | -keepclassmembers class com.jerryjeon.logjerry.** { 9 | *** Companion; 10 | } 11 | -keepclasseswithmembers class com.jerryjeon.logjerry.** { 12 | kotlinx.serialization.KSerializer serializer(...); 13 | } 14 | 15 | # When kotlinx.serialization.json.JsonObjectSerializer occurs 16 | 17 | -keepclassmembers class kotlinx.serialization.json.** { 18 | *** Companion; 19 | } 20 | -keepclasseswithmembers class kotlinx.serialization.json.** { 21 | kotlinx.serialization.KSerializer serializer(...); 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/serialization/ColorAsLongSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.serialization 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import kotlinx.serialization.KSerializer 5 | import kotlinx.serialization.descriptors.PrimitiveKind 6 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 7 | import kotlinx.serialization.descriptors.SerialDescriptor 8 | import kotlinx.serialization.encoding.Decoder 9 | import kotlinx.serialization.encoding.Encoder 10 | 11 | // Be careful: it assumes TextUnitType is always sp 12 | object ColorAsLongSerializer : KSerializer { 13 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextUnit", PrimitiveKind.LONG) 14 | override fun serialize(encoder: Encoder, value: Color) = encoder.encodeLong(value.value.toLong()) 15 | override fun deserialize(decoder: Decoder): Color = Color(decoder.decodeLong().toULong()) 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/table/ColumnType.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class) 2 | 3 | package com.jerryjeon.logjerry.table 4 | 5 | import androidx.compose.ui.ExperimentalComposeUiApi 6 | import androidx.compose.ui.input.key.Key 7 | import androidx.compose.ui.input.key.KeyShortcut 8 | 9 | enum class ColumnType(val text: String, val shortcut: KeyShortcut, val showDivider: Boolean) { 10 | Number("#", KeyShortcut(Key.One, meta = true), true), 11 | Date("Date", KeyShortcut(Key.Two, meta = true), true), 12 | Time("Time", KeyShortcut(Key.Three, meta = true), true), 13 | Pid("pid", KeyShortcut(Key.Four, meta = true), true), 14 | Tid("tid", KeyShortcut(Key.Five, meta = true), true), 15 | PackageName("PackageName", KeyShortcut(Key.Six, meta = true), true), 16 | Priority("Lev", KeyShortcut(Key.Seven, meta = true), true), 17 | Tag("Tag", KeyShortcut(Key.Eight, meta = true), true), 18 | Log("Log", KeyShortcut(Key.Nine, meta = true), false), 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/serialization/TextUnitAsFloatSerializer.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalUnitApi::class) 2 | 3 | package com.jerryjeon.logjerry.serialization 4 | 5 | import androidx.compose.ui.unit.ExperimentalUnitApi 6 | import androidx.compose.ui.unit.TextUnit 7 | import androidx.compose.ui.unit.TextUnitType 8 | import kotlinx.serialization.KSerializer 9 | import kotlinx.serialization.descriptors.PrimitiveKind 10 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 11 | import kotlinx.serialization.descriptors.SerialDescriptor 12 | import kotlinx.serialization.encoding.Decoder 13 | import kotlinx.serialization.encoding.Encoder 14 | 15 | // Be careful: it assumes TextUnitType is always sp 16 | object TextUnitAsFloatSerializer : KSerializer { 17 | override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextUnit", PrimitiveKind.FLOAT) 18 | override fun serialize(encoder: Encoder, value: TextUnit) = encoder.encodeFloat(value.value) 19 | override fun deserialize(decoder: Decoder): TextUnit = TextUnit(decoder.decodeFloat(), TextUnitType.Sp) 20 | } 21 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/logview/RefinedLog.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.logview 2 | 3 | import com.jerryjeon.logjerry.detector.Detection 4 | import com.jerryjeon.logjerry.detector.DetectorKey 5 | import com.jerryjeon.logjerry.detector.MarkDetection 6 | import com.jerryjeon.logjerry.log.Log 7 | import com.jerryjeon.logjerry.log.LogContentView 8 | import java.time.Duration 9 | 10 | class RefinedLog( 11 | val log: Log, 12 | val detections: Map>, 13 | val logContentViews: List, 14 | val timeGap: Duration? 15 | ) { 16 | val mark = detections[DetectorKey.Mark]?.firstOrNull() as? MarkDetection 17 | val marked = mark != null 18 | 19 | fun durationBetween(other: RefinedLog): Duration? { 20 | return this.log.durationBetween(other.log) 21 | } 22 | } 23 | 24 | fun Duration.toHumanReadable(): String { 25 | val hours: Long = toHours() 26 | val minutes: Long = toMinutes() % 60 27 | val seconds: Long = seconds % 60 28 | 29 | return when { 30 | hours > 0 -> { 31 | "${hours}h ${minutes}m ${seconds}s" 32 | } 33 | minutes > 0 -> { 34 | "${minutes}m ${seconds}s" 35 | } 36 | else -> { 37 | "${toMillis()}ms" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 11 | 12 | 13 | 15 | 16 | 17 | 28 | 29 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/TextFilter.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import com.jerryjeon.logjerry.table.ColumnType 5 | 6 | data class TextFilter( 7 | val columnType: ColumnType, 8 | val textFilterType: TextFilterType, 9 | val text: String 10 | ) : LogFilter { 11 | override fun filter(log: Log): Boolean { 12 | return when (textFilterType) { 13 | TextFilterType.Include -> { 14 | when (columnType) { 15 | ColumnType.PackageName -> text in (log.packageName ?: "") 16 | ColumnType.Tag -> if (log.tag == null) true else text in log.tag 17 | ColumnType.Log -> text in log.log 18 | else -> throw NotImplementedError("Not supported filter : $columnType") 19 | } 20 | } 21 | TextFilterType.Exclude -> { 22 | when (columnType) { 23 | ColumnType.PackageName -> text !in (log.packageName ?: "") 24 | ColumnType.Tag -> if (log.tag == null) true else text !in log.tag 25 | ColumnType.Log -> text !in log.log 26 | else -> throw NotImplementedError("Not supported filter : $columnType") 27 | } 28 | } 29 | } 30 | } 31 | } 32 | 33 | enum class TextFilterType { 34 | Include, 35 | Exclude 36 | } 37 | -------------------------------------------------------------------------------- /src/test/kotlin/com/jerryjeon/logjerry/parse/ParserTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import io.kotest.matchers.collections.shouldBeEmpty 5 | import io.kotest.matchers.collections.shouldBeSingleton 6 | import org.junit.jupiter.api.Test 7 | 8 | internal class ParserTest { 9 | 10 | @Test 11 | fun `Given raw login, parse well`() { 12 | val parseResult = StudioLogcatBelowChipmunkParser( 13 | includeDateTime = true, 14 | includePidTid = true, 15 | includePackageName = true, 16 | includeTag = true 17 | ).parse( 18 | listOf("2021-12-19 23:05:36.664 165-165/? I/hwservicemanager: Since android.hardware.media.omx@1.0::IOmxStore/default is not registered, trying to start it as a lazy HAL.") 19 | ) 20 | 21 | parseResult.logs.shouldBeSingleton { 22 | Log( 23 | number = 1, 24 | date = "2021-12-19", 25 | time = "23:05:36.664", 26 | pid = 165, 27 | tid = 165, 28 | packageName = null, 29 | priorityText = "I", 30 | tag = "hwservicemanager", 31 | log = "Since android.hardware.media.omx@1.0::IOmxStore/default is not registered, trying to start it as a lazy HAL.", 32 | ) 33 | } 34 | parseResult.invalidSentences.shouldBeEmpty() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/BracketFinder.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | fun String.findBracketRanges(start: Char, end: Char): List { 4 | // Find bracket pairs, { } and check this is json or not 5 | val bracketRanges = mutableListOf() 6 | 7 | var startIndex = -1 8 | var braceCount = 0 9 | var isInString = false 10 | var isEscaped = false 11 | 12 | var currentIndex = 0 13 | while (currentIndex < this.length) { 14 | val currentChar = this[currentIndex] 15 | if (!isEscaped) { 16 | if (currentChar == start && !isInString) { 17 | if (startIndex == -1) { 18 | startIndex = currentIndex 19 | } 20 | braceCount++ 21 | } else if (currentChar == end && !isInString) { 22 | braceCount-- 23 | if (braceCount == 0 && startIndex != -1) { 24 | val endIndex = currentIndex 25 | bracketRanges.add(IntRange(startIndex, endIndex)) 26 | startIndex = -1 27 | } 28 | } else if (currentChar == '"') { 29 | if (currentIndex > 0) { 30 | isInString = !isInString 31 | } 32 | } 33 | } 34 | isEscaped = currentChar == '\\' && !isEscaped 35 | currentIndex++ 36 | } 37 | 38 | return bracketRanges 39 | } 40 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | coroutine = "1.6.4" 3 | kotlin = "1.8.20" 4 | okio = "3.2.0" 5 | kotlinx-serialization-json = "1.4.0" 6 | compose = "1.4.0" 7 | ktlint = "10.2.0" 8 | compose-material-icons = "1.2.0-beta01" 9 | jupiter = "5.9.3" 10 | kotest = "5.4.1" 11 | 12 | [libraries] 13 | coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutine" } 14 | coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "coroutine" } 15 | okio-okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" } 16 | kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } 17 | compose-material-icons = { group = "org.jetbrains.compose.material", name = "material-icons-extended-desktop", version.ref = "compose-material-icons" } 18 | jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "jupiter" } 19 | kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" } 20 | 21 | [plugins] 22 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 23 | kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 24 | compose = { id = "org.jetbrains.compose", version.ref = "compose" } 25 | ktlint = { id = "org.jlleitschuh.gradle.ktlint-idea", version.ref = "ktlint" } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/util/Keyboard.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class) 2 | 3 | package com.jerryjeon.logjerry.util 4 | 5 | import androidx.compose.ui.ExperimentalComposeUiApi 6 | import androidx.compose.ui.input.key.* 7 | 8 | val isMac = System.getProperty("os.name").contains("mac", ignoreCase = true) 9 | 10 | val KeyEvent.isCtrlOrMetaPressed: Boolean 11 | get() = if (isMac) { 12 | this.isMetaPressed 13 | } else { 14 | this.isCtrlPressed 15 | } 16 | 17 | object KeyShortcuts { 18 | val newTab = if(isMac) { 19 | KeyShortcut(Key.T, meta = true) 20 | } else { 21 | KeyShortcut(Key.T, ctrl = true) 22 | } 23 | val openFile = if(isMac) { 24 | KeyShortcut(Key.O, meta = true) 25 | } else { 26 | KeyShortcut(Key.O, ctrl = true) 27 | } 28 | val previousTab = if(isMac) { 29 | KeyShortcut(Key.LeftBracket, meta = true, shift = true) 30 | } else { 31 | KeyShortcut(Key.LeftBracket, ctrl = true, shift = true) 32 | } 33 | val nextTab = if(isMac) { 34 | KeyShortcut(Key.RightBracket, meta = true, shift = true) 35 | } else { 36 | KeyShortcut(Key.RightBracket, ctrl = true, shift = true) 37 | } 38 | val closeTab = if(isMac) { 39 | KeyShortcut(Key.W, meta = true) 40 | } else { 41 | KeyShortcut(Key.W, ctrl = true) 42 | } 43 | val preferences = if(isMac) { 44 | KeyShortcut(Key.Comma, meta = true) 45 | } else { 46 | KeyShortcut(Key.Comma, ctrl = true) 47 | } 48 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/KeywordDetector.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.SpanStyle 5 | import java.util.UUID 6 | 7 | class KeywordDetector(private val keyword: String) : Detector { 8 | override val key: DetectorKey = DetectorKey.Keyword 9 | override val shownAsBlock: Boolean = false 10 | override fun detect(logStr: String, logIndex: Int): List { 11 | if (keyword.isBlank()) return emptyList() 12 | val orKeywords = keyword.split("|") 13 | .filter { it.isNotBlank() } 14 | val indexRanges = mutableListOf() 15 | orKeywords.forEach { 16 | var startIndex = 0 17 | while (startIndex != -1) { 18 | startIndex = logStr.indexOf(it, startIndex, ignoreCase = true) 19 | if (startIndex != -1) { 20 | indexRanges.add(startIndex..startIndex + it.length) 21 | startIndex += it.length 22 | } 23 | } 24 | } 25 | 26 | return indexRanges.map { KeywordDetection(it, logIndex) } 27 | } 28 | } 29 | 30 | class KeywordDetection(override val range: IntRange, override val logIndex: Int) : Detection { 31 | override val id: String = UUID.randomUUID().toString() 32 | override val key: DetectorKey = DetectorKey.Keyword 33 | override val style: SpanStyle 34 | get() = detectedStyle 35 | private val detectedStyle: SpanStyle = SpanStyle(background = Color.Yellow) 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/preferences/SortOrderPreferences.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalSerializationApi::class) 2 | 3 | package com.jerryjeon.logjerry.preferences 4 | 5 | import com.jerryjeon.logjerry.filter.FilterSortOption 6 | import com.jerryjeon.logjerry.filter.SortOrder 7 | import kotlinx.serialization.ExperimentalSerializationApi 8 | import kotlinx.serialization.Serializable 9 | import kotlinx.serialization.json.decodeFromStream 10 | import okio.use 11 | import java.io.File 12 | import java.io.FileInputStream 13 | 14 | @Serializable 15 | data class SortOrderPreferences( 16 | val tagFilterSortOption: FilterSortOption = FilterSortOption.Frequency, 17 | val tagFilterSortOrder: SortOrder = SortOrder.Descending, 18 | val packageFilterSortOption: FilterSortOption = FilterSortOption.Frequency, 19 | val packageFilterSortOrder: SortOrder = SortOrder.Descending, 20 | ) { 21 | 22 | companion object { 23 | val default = SortOrderPreferences() 24 | val file = File(System.getProperty("java.io.tmpdir"), "LogJerrySortOrderPreferences.json") 25 | 26 | fun load(): SortOrderPreferences { 27 | return if (file.exists()) { 28 | val use = file.inputStream().use { PreferencesViewModel.json.decodeFromStream(it) } 29 | .also { println("Loaded SortOrderPreferences: $it") } 30 | use 31 | } else { 32 | default 33 | } 34 | } 35 | fun save(preferences: SortOrderPreferences) = file.writeText(PreferencesViewModel.json.encodeToString(serializer(), preferences)) 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/MarkDetector.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.SpanStyle 5 | import com.jerryjeon.logjerry.mark.LogMark 6 | import java.util.UUID 7 | 8 | class MarkDetector( 9 | val logMarks: Map, 10 | ) : Detector { 11 | override val key = DetectorKey.Mark 12 | override val shownAsBlock: Boolean = false 13 | 14 | fun setMark(logMark: LogMark): MarkDetector { 15 | return MarkDetector(logMarks + (logMark.log.index to logMark)) 16 | } 17 | 18 | fun deleteMark(logIndex: Int): MarkDetector { 19 | return MarkDetector(logMarks = logMarks - logIndex) 20 | } 21 | 22 | override fun detect(logStr: String, logIndex: Int): List { 23 | val logMark = logMarks[logIndex] 24 | return if (logMark != null) { 25 | listOf( 26 | MarkDetection( 27 | UUID.randomUUID().toString(), 28 | logStr.indices, 29 | logIndex, 30 | logMark.note, 31 | logMark.color 32 | ) 33 | ) 34 | } else { 35 | emptyList() 36 | } 37 | } 38 | } 39 | 40 | class MarkDetection( 41 | override val id: String, 42 | override val range: IntRange, // TODO find cleaner way.. It doesn't need to exist 43 | override val logIndex: Int, 44 | val note: String, 45 | val color: Color, 46 | ) : Detection { 47 | override val key = DetectorKey.Mark 48 | 49 | override val style: SpanStyle = detectedStyle 50 | 51 | companion object { 52 | // TODO find cleaner way.. It doesn't need to exist 53 | val detectedStyle = SpanStyle() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/JsonDetector.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.SpanStyle 5 | import kotlinx.serialization.json.Json 6 | import kotlinx.serialization.json.JsonObject 7 | import kotlinx.serialization.json.jsonObject 8 | import java.util.UUID 9 | 10 | class JsonDetector : Detector { 11 | override val key: DetectorKey = DetectorKey.Json 12 | override val shownAsBlock: Boolean = true 13 | 14 | override fun detect(logStr: String, logIndex: Int): List { 15 | val bracketRanges = logStr.findBracketRanges('{', '}') 16 | 17 | val jsonList = bracketRanges.mapNotNull { range -> 18 | val text = logStr.substring(range) 19 | try { 20 | range to Json.parseToJsonElement(text).jsonObject 21 | } catch (_: Exception) { 22 | null 23 | } 24 | } 25 | .filter { (_, json) -> json.isNotEmpty() } // Filter empty json 26 | 27 | return jsonList.map { (range, json) -> 28 | JsonDetection(range, logIndex, json) 29 | } 30 | } 31 | } 32 | 33 | class JsonDetection( 34 | override val range: IntRange, 35 | override val logIndex: Int, 36 | val json: JsonObject, 37 | override val id: String = UUID.randomUUID().toString() 38 | ) : Detection { 39 | override val key: DetectorKey = DetectorKey.Json 40 | override val style: SpanStyle 41 | get() = detectedStyle 42 | 43 | companion object { 44 | val detectedStyle = SpanStyle(background = Color(0x40D3D3D3)) 45 | } 46 | 47 | fun move(index: Int): JsonDetection { 48 | return JsonDetection(IntRange(range.first + index, range.last + index), logIndex, json, id) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LogJerry 2 | 3 | LogJerry is a desktop investigation tool for android logs. 4 | 5 | Instead of relying on extensive editors like VS Code, LogJerry simplifies log analysis by offering easy-to-navigate columns and log filtering. 6 | his tool is particularly handy for quickly locating information like JSON in your logs. 7 | Born out of a personal need for a better log investigation tool, we welcome all feedback as we continue to develop and improve LogJerry. 8 | 9 | ## Main features 10 | 11 | - Auto-detect exceptions, JSON objects 12 | - Prettify JSON 13 | - Filters 14 | - Find keywords in logs 15 | - Make a note for each log 16 | 17 | ![logjerry_temp_720](https://user-images.githubusercontent.com/5154440/192139287-c049b3f1-9a6e-49f9-a15b-6817ef51a2ee.gif) 18 | 19 | ### Download 20 | 21 | Only Mac ARM64 package is provided in the Release tab. 22 | You can download a .dmg file in the [releases](https://github.com/jkj8790/LogJerry/releases). 23 | 24 | ### Build 25 | 26 | The other platforms except Mac ARM64, you can build an application that runs on your platform by yourself. 27 | 1. Clone this repository 28 | 2. Execute `createReleaseDistributable` gradle task. Run command `./gradlew createReleaseDistributable` on the project top directory 29 | 3. Check `build/compose/binaries/main/app` folder 30 | 31 | # License 32 | ``` 33 | Copyright 2022 KyoungJoo Jeon 34 | 35 | Licensed under the Apache License, Version 2.0 (the "License"); 36 | you may not use this file except in compliance with the License. 37 | You may obtain a copy of the License at 38 | 39 | http://www.apache.org/licenses/LICENSE-2.0 40 | 41 | Unless required by applicable law or agreed to in writing, software 42 | distributed under the License is distributed on an "AS IS" BASIS, 43 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 44 | See the License for the specific language governing permissions and 45 | limitations under the License. 46 | ``` 47 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/popup/BasePopup.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.popup 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.shape.RoundedCornerShape 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.geometry.Offset 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.unit.* 14 | import androidx.compose.ui.window.Popup 15 | import androidx.compose.ui.window.PopupPositionProvider 16 | 17 | @Composable 18 | fun BasePopup( 19 | showPopup: Boolean, 20 | anchor: Offset, 21 | dismiss: () -> Unit, 22 | content: @Composable () -> Unit, 23 | ) { 24 | if (showPopup) { 25 | Popup( 26 | onDismissRequest = dismiss, 27 | focusable = true, 28 | popupPositionProvider = object : PopupPositionProvider { 29 | override fun calculatePosition( 30 | anchorBounds: IntRect, 31 | windowSize: IntSize, 32 | layoutDirection: LayoutDirection, 33 | popupContentSize: IntSize 34 | ): IntOffset { 35 | val additionalMargin = 10 36 | return IntOffset( 37 | anchor.x.toInt(), 38 | (anchor.y + anchorBounds.height + additionalMargin).toInt() 39 | ) 40 | } 41 | } 42 | ) { 43 | Box( 44 | Modifier.border(1.dp, Color.LightGray, RoundedCornerShape(4.dp)) 45 | .background(MaterialTheme.colors.background) 46 | .padding(16.dp) 47 | ) { 48 | content() 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/HeaderView.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class) 2 | 3 | package com.jerryjeon.logjerry.ui 4 | 5 | import androidx.compose.desktop.ui.tooling.preview.Preview 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.material.Divider 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.ExperimentalComposeUiApi 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import com.jerryjeon.logjerry.MyTheme 15 | import com.jerryjeon.logjerry.preferences.Preferences 16 | import com.jerryjeon.logjerry.table.ColumnInfo 17 | import com.jerryjeon.logjerry.table.Header 18 | 19 | // TODO Find cleaner way 20 | fun RowScope.applyWidth(width: Int?, modifier: Modifier = Modifier): Modifier { 21 | return if (width == null) modifier.weight(1f) else modifier.width(width.dp) 22 | } 23 | 24 | @Composable 25 | fun RowScope.HeaderView(columnInfo: ColumnInfo, modifier: Modifier = Modifier) { 26 | Row(applyWidth(columnInfo.width, modifier).padding(4.dp), verticalAlignment = Alignment.CenterVertically) { 27 | Text( 28 | text = columnInfo.columnType.text, 29 | modifier = Modifier.align(Alignment.CenterVertically) 30 | ) 31 | } 32 | } 33 | 34 | @Composable 35 | fun HeaderRow(header: Header, divider: @Composable RowScope.() -> Unit) { 36 | Row(Modifier.height(IntrinsicSize.Min)) { 37 | Spacer(Modifier.width(8.dp)) 38 | header.asColumnList.forEach { 39 | if (it.visible) { 40 | HeaderView(it) 41 | if (it.columnType.showDivider) { 42 | divider() 43 | } 44 | } 45 | } 46 | Spacer(Modifier.width(8.dp)) 47 | } 48 | } 49 | 50 | @Preview 51 | @Composable 52 | fun HeaderPreview() { 53 | MyTheme(preferences = Preferences.default) { 54 | HeaderRow(Header()) { Divider() } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/AppliedTextFilter.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui 2 | 3 | import androidx.compose.foundation.border 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.shape.RoundedCornerShape 6 | import androidx.compose.material.* 7 | import androidx.compose.material.icons.Icons 8 | import androidx.compose.material.icons.filled.Close 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.unit.dp 13 | import com.jerryjeon.logjerry.filter.TextFilter 14 | import com.jerryjeon.logjerry.filter.TextFilterType 15 | 16 | @Composable 17 | fun AppliedTextFilter(textFilter: TextFilter, removeFilter: (TextFilter) -> Unit) { 18 | val borderColor = when (textFilter.textFilterType) { 19 | TextFilterType.Include -> MaterialTheme.colors.secondary 20 | else -> MaterialTheme.colors.error 21 | } 22 | Row( 23 | modifier = Modifier.height(IntrinsicSize.Min) 24 | .border(1.dp, borderColor, RoundedCornerShape(8.dp)) 25 | .padding(horizontal = 8.dp), 26 | verticalAlignment = Alignment.CenterVertically 27 | ) { 28 | Spacer(Modifier.width(8.dp)) 29 | Text( 30 | textFilter.columnType.text, 31 | modifier = Modifier.padding(vertical = 8.dp), 32 | style = MaterialTheme.typography.caption 33 | ) 34 | Spacer(Modifier.width(8.dp)) 35 | Divider(Modifier.width(1.dp).fillMaxHeight()) 36 | Spacer(Modifier.width(8.dp)) 37 | Text(textFilter.text, modifier = Modifier, style = MaterialTheme.typography.caption) 38 | Spacer(Modifier.width(8.dp)) 39 | IconButton( 40 | onClick = { removeFilter(textFilter) }, 41 | modifier = Modifier.size(18.dp) 42 | ) { 43 | Icon( 44 | Icons.Default.Close, 45 | contentDescription = "Remove a filter", 46 | modifier = Modifier.size(12.dp) 47 | ) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/log/Log.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.log 2 | 3 | import java.time.Duration 4 | import java.time.LocalDate 5 | import java.time.LocalDateTime 6 | import java.time.LocalTime 7 | import java.time.format.DateTimeFormatter 8 | 9 | data class Log( 10 | val number: Int, 11 | val date: String?, 12 | val time: String?, 13 | val pid: Long?, 14 | val tid: Long?, 15 | val packageName: String?, 16 | val priorityText: String, 17 | val tag: String?, 18 | val log: String 19 | ) { 20 | val index = number - 1 21 | val priority = Priority.find(priorityText) 22 | 23 | val localDateTime: LocalDateTime? 24 | get() = try { 25 | if (date != null && time != null) { 26 | LocalDateTime.parse("$date $time", formatter) 27 | } else if (time != null) { 28 | // Assume date is today. 29 | LocalTime.parse(time, timeFormatter).atDate(LocalDate.now()) 30 | } else { 31 | null 32 | } 33 | } catch (e: Exception) { 34 | null 35 | } 36 | 37 | fun durationBetween(other: Log): Duration? { 38 | val thisLocalDateTime = localDateTime ?: return null 39 | val otherLocalDateTime = other.localDateTime ?: return null 40 | return Duration.between(thisLocalDateTime, otherLocalDateTime) 41 | } 42 | 43 | companion object { 44 | val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") 45 | val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS") 46 | } 47 | } 48 | 49 | enum class Priority(val text: String, val fullText: String, val level: Int) { 50 | Verbose("V", "Verb", 1), 51 | Debug("D", "Debug", 2), 52 | Info("I", "Info", 3), 53 | Warning("W", "Warn", 4), 54 | Error("E", "Error", 5); 55 | 56 | companion object { 57 | fun find(text: String): Priority { 58 | return values().find { it.text == text } 59 | ?: throw IllegalArgumentException("Not defined priority: $text") 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/ColumnVisibilityView.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.width 6 | import androidx.compose.material.Checkbox 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.MutableState 11 | import androidx.compose.runtime.getValue 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.text.style.TextAlign 16 | import androidx.compose.ui.unit.dp 17 | import androidx.compose.ui.window.MenuScope 18 | import com.jerryjeon.logjerry.table.ColumnInfo 19 | import com.jerryjeon.logjerry.table.Header 20 | 21 | @Composable 22 | fun ColumnVisibility(headerState: MutableState
) { 23 | val asColumnList = headerState.value.asColumnList 24 | 25 | Column { 26 | Text("Column visibility", style = MaterialTheme.typography.h4) 27 | asColumnList.chunked(3).forEach { chunked -> 28 | Row { 29 | chunked.forEach { columnInfo -> 30 | ColumnCheckBox(columnInfo, headerState) 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | @Composable 38 | private fun ColumnCheckBox(columnInfo: ColumnInfo, headerState: MutableState
) { 39 | var header by headerState 40 | Row(modifier = Modifier.width(110.dp)) { 41 | Text(columnInfo.columnType.name, modifier = Modifier.weight(1f).align(Alignment.CenterVertically), textAlign = TextAlign.Center) 42 | Checkbox(columnInfo.visible, onCheckedChange = { 43 | header = header.copyOf(columnInfo.columnType, columnInfo.copy(visible = it)) 44 | }) 45 | } 46 | } 47 | 48 | @Composable 49 | fun MenuScope.columnCheckboxItem(columnInfo: ColumnInfo, setColumnInfoVisibility: (ColumnInfo, Boolean) -> Unit) { 50 | CheckboxItem( 51 | text = columnInfo.columnType.name, 52 | checked = columnInfo.visible, 53 | shortcut = columnInfo.columnType.shortcut 54 | ) { 55 | setColumnInfoVisibility(columnInfo, it) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/PriorityFilterView.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.LocalTextStyle 5 | import androidx.compose.material.Slider 6 | import androidx.compose.material.Text 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.text.style.TextAlign 10 | import androidx.compose.ui.unit.dp 11 | import androidx.compose.ui.unit.sp 12 | import com.jerryjeon.logjerry.filter.PriorityFilter 13 | import com.jerryjeon.logjerry.log.Priority 14 | 15 | @Composable 16 | fun PriorityFilterView( 17 | priorityFilter: PriorityFilter, 18 | changePriorityFilter: (PriorityFilter) -> Unit 19 | ) { 20 | var value by remember(priorityFilter) { mutableStateOf(priorityFilter.priority.ordinal.toFloat()) } 21 | Column(modifier = Modifier.width(IntrinsicSize.Min)) { 22 | Text("Log level") 23 | Spacer(Modifier.height(8.dp)) 24 | val priorities = Priority.values() 25 | 26 | Slider( 27 | modifier = Modifier.width(300.dp).height(50.dp), 28 | value = value, 29 | onValueChange = { value = it }, 30 | steps = priorities.size - 2, 31 | valueRange = 0f..(priorities.size - 1).toFloat(), 32 | onValueChangeFinished = { 33 | val priority = priorities[value.toInt()] 34 | changePriorityFilter(PriorityFilter(priority)) 35 | }, 36 | ) 37 | 38 | Row( 39 | modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), 40 | horizontalArrangement = Arrangement.SpaceBetween 41 | ) { 42 | val middle = priorities.size / 2 43 | priorities.forEachIndexed { index, priority -> 44 | Text( 45 | priority.fullText, 46 | modifier = Modifier.width(40.dp), 47 | style = LocalTextStyle.current.copy(fontSize = 11.sp), 48 | textAlign = when { 49 | index < middle -> TextAlign.Start 50 | index == middle -> TextAlign.Center 51 | else -> TextAlign.End 52 | } 53 | ) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/table/Header.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.table 2 | 3 | import kotlinx.serialization.Serializable 4 | import java.io.File 5 | 6 | @Serializable 7 | data class Header( 8 | val number: ColumnInfo = ColumnInfo(ColumnType.Number, 50, true), 9 | val date: ColumnInfo = ColumnInfo(ColumnType.Date, 100, false), 10 | val time: ColumnInfo = ColumnInfo(ColumnType.Time, 100, true), 11 | val pid: ColumnInfo = ColumnInfo(ColumnType.Pid, 50, false), 12 | val tid: ColumnInfo = ColumnInfo(ColumnType.Tid, 50, false), 13 | val packageName: ColumnInfo = ColumnInfo(ColumnType.PackageName, 130, true), 14 | val priority: ColumnInfo = ColumnInfo(ColumnType.Priority, 40, true), 15 | val tag: ColumnInfo = ColumnInfo(ColumnType.Tag, 200, true), 16 | val log: ColumnInfo = ColumnInfo(ColumnType.Log, null, true), 17 | ) { 18 | 19 | val asColumnList: List = listOf(number, date, time, pid, tid, packageName, priority, tag, log) 20 | 21 | operator fun get(columnType: ColumnType): ColumnInfo { 22 | return when (columnType) { 23 | ColumnType.Number -> number 24 | ColumnType.Date -> date 25 | ColumnType.Time -> time 26 | ColumnType.Pid -> pid 27 | ColumnType.Tid -> tid 28 | ColumnType.PackageName -> packageName 29 | ColumnType.Priority -> priority 30 | ColumnType.Tag -> tag 31 | ColumnType.Log -> log 32 | } 33 | } 34 | 35 | fun copyOf(columnType: ColumnType, columnInfo: ColumnInfo): Header { 36 | return when (columnType) { 37 | ColumnType.Number -> copy(number = columnInfo) 38 | ColumnType.Date -> copy(date = columnInfo) 39 | ColumnType.Time -> copy(time = columnInfo) 40 | ColumnType.Pid -> copy(pid = columnInfo) 41 | ColumnType.Tid -> copy(tid = columnInfo) 42 | ColumnType.PackageName -> copy(packageName = columnInfo) 43 | ColumnType.Priority -> copy(priority = columnInfo) 44 | ColumnType.Tag -> copy(tag = columnInfo) 45 | ColumnType.Log -> copy(log = columnInfo) 46 | } 47 | } 48 | 49 | companion object { 50 | val file = File(System.getProperty("java.io.tmpdir"), "LogJerryPreferencesHeader.json") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/ExceptionDetector.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | import androidx.compose.ui.text.SpanStyle 4 | import java.util.UUID 5 | 6 | class ExceptionDetector : Detector { 7 | override val key = DetectorKey.Exception 8 | override val shownAsBlock: Boolean = false 9 | override fun detect(logStr: String, logIndex: Int): List { 10 | val lines = logStr.split("\n") 11 | val stackStartLine = lines.indexOfFirst { isStackTrace(it) } 12 | .takeIf { it != -1 } ?: return emptyList() 13 | 14 | val exceptionLines = lines.subList(0, (stackStartLine - 1).coerceAtLeast(0)) 15 | val words = exceptionLines.joinToString(separator = " ").split(',', '.', ' ', '\n', '$', ':', ';') 16 | val exception = words.firstOrNull { it.contains("exception", ignoreCase = true) || it.contains("error", ignoreCase = true) } 17 | 18 | val result = ExceptionDetection(logStr.indices, logIndex, exception ?: "") 19 | return listOf(result) 20 | } 21 | 22 | /** 23 | * Check pattern is 24 | * `at methodCall (FileName:Line)` 25 | */ 26 | private fun isStackTrace(str: String): Boolean { 27 | val trimmed = str.trim() 28 | if (!trimmed.startsWith("at ")) return false 29 | if (trimmed.lastOrNull() != ')') return false 30 | 31 | val locationStart = trimmed.lastIndexOf("(") 32 | .takeIf { it != -1 } ?: return false 33 | 34 | val location = trimmed.substring(locationStart + 1, trimmed.length - 1) // skip the parentheses 35 | 36 | val split = location.split(":") 37 | if (split.size != 2) return false 38 | val fileName = split[0] 39 | if (!fileName.endsWith("java") && !fileName.endsWith("kt")) return false 40 | val lineNumber = split[1] 41 | if (lineNumber.toIntOrNull() == null) return false 42 | 43 | return true 44 | } 45 | } 46 | 47 | class ExceptionDetection( 48 | override val range: IntRange, 49 | override val logIndex: Int, 50 | val exception: String, 51 | ) : Detection { 52 | 53 | override val id: String = UUID.randomUUID().toString() 54 | 55 | companion object { 56 | val detectedStyle = SpanStyle() 57 | } 58 | 59 | override val key: DetectorKey 60 | get() = DetectorKey.Exception 61 | override val style: SpanStyle 62 | get() = detectedStyle 63 | } 64 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/FirebaseTestLabLogcatFormatParser.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import java.util.concurrent.atomic.AtomicInteger 5 | 6 | class FirebaseTestLabLogcatFormatParser : LogParser { 7 | 8 | private val number = AtomicInteger(1) 9 | 10 | override fun parse(rawLines: List): ParseResult { 11 | val logs = mutableListOf() 12 | val invalidSentences = mutableListOf>() 13 | var lastLog: Log? = null 14 | rawLines.forEachIndexed { index, s -> 15 | lastLog = try { 16 | val log = parseSingleLineLog(s) 17 | 18 | // Custom continuation 19 | if (log.log.startsWith("Cont(")) { 20 | lastLog?.let { 21 | it.copy(log = "${it.log}${log.log.substringAfter(") ")}") 22 | } ?: log 23 | } else { 24 | lastLog?.let { logs.add(it) } 25 | log 26 | } 27 | } catch (e: Exception) { 28 | val continuedLog = if (lastLog == null) { 29 | invalidSentences.add(index to s) 30 | return@forEachIndexed 31 | } else { 32 | lastLog!! 33 | } 34 | continuedLog.copy(log = "${continuedLog.log}\n$s") 35 | } 36 | } 37 | lastLog?.let { logs.add(it) } 38 | return ParseResult(logs, invalidSentences) 39 | } 40 | 41 | fun parseSingleLineLog(raw: String): Log { 42 | val timeAndMessage = raw.split(": ", limit = 2) 43 | 44 | val date = timeAndMessage[0].split(" ")[0] 45 | val time = timeAndMessage[0].split(" ")[1] 46 | 47 | val metadataParts = timeAndMessage[1].substringBefore(":") 48 | val priorityText = metadataParts.substringBefore("/") 49 | 50 | val tagWithPid = metadataParts.substringAfter("/") 51 | val tag = tagWithPid.substringBefore("(") 52 | val pid = tagWithPid.substringAfter("(").substringBefore(")").toLong() 53 | 54 | val originalLog = timeAndMessage[1].substringAfter(": ") 55 | 56 | return Log(number.getAndIncrement(), date, time, pid, null, null, priorityText, tag, originalLog) 57 | } 58 | 59 | companion object : ParserFactory { 60 | private val logRegex = """\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}: [EWIDV]/[a-zA-Z0-9_\-]+\( *\d+ *\): .+""".toRegex() 61 | 62 | override fun create(sample: String): LogParser? { 63 | val matches = logRegex.matches(sample) 64 | return if (matches) { 65 | FirebaseTestLabLogcatFormatParser() 66 | } else { 67 | null 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/InvalidSentences.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class) 2 | 3 | package com.jerryjeon.logjerry.ui 4 | 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.Divider 7 | import androidx.compose.material.MaterialTheme 8 | import androidx.compose.material.Surface 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.* 11 | import androidx.compose.ui.ExperimentalComposeUiApi 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.input.key.Key 14 | import androidx.compose.ui.input.key.KeyEventType 15 | import androidx.compose.ui.input.key.key 16 | import androidx.compose.ui.input.key.type 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.window.Dialog 19 | import androidx.compose.ui.window.DialogState 20 | import com.jerryjeon.logjerry.util.isCtrlOrMetaPressed 21 | 22 | @Composable 23 | fun InvalidSentencesDialog(invalidSentences: List>) { 24 | var showInvalidSentence by remember { mutableStateOf(invalidSentences.isNotEmpty()) } 25 | if (showInvalidSentence) { 26 | Dialog( 27 | onCloseRequest = { showInvalidSentence = false }, 28 | title = "Invalid sentences", 29 | state = DialogState(width = 800.dp, height = 600.dp), 30 | onPreviewKeyEvent = { keyEvent -> 31 | if (keyEvent.isCtrlOrMetaPressed && keyEvent.key == Key.W && keyEvent.type == KeyEventType.KeyDown) { 32 | showInvalidSentence = false 33 | } 34 | false 35 | } 36 | ) { 37 | Surface(color = MaterialTheme.colors.surface, contentColor = MaterialTheme.colors.onSurface) { 38 | InvalidSentences(invalidSentences) 39 | } 40 | } 41 | } 42 | } 43 | 44 | @Composable 45 | private fun InvalidSentences(invalidSentences: List>) { 46 | Column(Modifier.fillMaxSize().padding(16.dp)) { 47 | Text( 48 | "There were ${invalidSentences.size} invalid sentences", 49 | style = MaterialTheme.typography.body1 50 | ) 51 | Text( 52 | "Supported format : \"date time pid-tid/packageName priority/tag: log\"", 53 | style = MaterialTheme.typography.body1 54 | ) 55 | Spacer(Modifier.height(16.dp)) 56 | Divider() 57 | Spacer(Modifier.height(16.dp)) 58 | invalidSentences.forEach { (index, s) -> 59 | Row(Modifier.height(IntrinsicSize.Min)) { 60 | Text("Line ${index + 1}") 61 | Spacer(Modifier.width(4.dp)) 62 | Divider(Modifier.width(1.dp).fillMaxHeight()) 63 | Spacer(Modifier.width(4.dp)) 64 | Text(s, color = MaterialTheme.colors.primary) 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # Generated files 13 | .idea/**/contentModel.xml 14 | 15 | # Sensitive or high-churn files 16 | .idea/**/dataSources/ 17 | .idea/**/dataSources.ids 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | .idea/**/dbnavigator.xml 23 | 24 | # Gradle 25 | .idea/**/gradle.xml 26 | .idea/**/libraries 27 | 28 | # Gradle and Maven with auto-import 29 | # When using Gradle or Maven with auto-import, you should exclude module files, 30 | # since they will be recreated, and may cause churn. Uncomment if using 31 | # auto-import. 32 | # .idea/artifacts 33 | # .idea/compiler.xml 34 | # .idea/jarRepositories.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### Gradle template 75 | .gradle 76 | **/build/ 77 | !src/**/build/ 78 | 79 | # Ignore Gradle GUI config 80 | gradle-app.setting 81 | 82 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 83 | !gradle-wrapper.jar 84 | 85 | # Cache of project 86 | .gradletasknamecache 87 | 88 | # # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 89 | # gradle/wrapper/gradle-wrapper.properties 90 | 91 | ### Kotlin template 92 | # Compiled class file 93 | *.class 94 | 95 | # Log file 96 | *.log 97 | 98 | # BlueJ files 99 | *.ctxt 100 | 101 | # Mobile Tools for Java (J2ME) 102 | .mtj.tmp/ 103 | 104 | # Package Files # 105 | *.jar 106 | *.war 107 | *.nar 108 | *.ear 109 | *.zip 110 | *.tar.gz 111 | *.rar 112 | 113 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 114 | hs_err_pid* 115 | 116 | signing.properties 117 | /local.properties 118 | release_script.txt 119 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/DetectionView.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui 2 | 3 | import androidx.compose.foundation.border 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.* 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.KeyboardArrowDown 8 | import androidx.compose.material.icons.filled.KeyboardArrowUp 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.runtime.CompositionLocalProvider 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import com.jerryjeon.logjerry.detector.DetectionStatus 16 | 17 | @Composable 18 | fun DetectionView( 19 | modifier: Modifier = Modifier, 20 | detectionStatus: DetectionStatus, 21 | title: String, 22 | moveToPreviousOccurrence: (DetectionStatus) -> Unit, 23 | moveToNextOccurrence: (DetectionStatus) -> Unit, 24 | ) { 25 | CompositionLocalProvider( 26 | LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 12.sp), 27 | ) { 28 | Row( 29 | modifier 30 | .wrapContentWidth() 31 | .border(ButtonDefaults.outlinedBorder, MaterialTheme.shapes.small) 32 | .padding(start = 12.dp), 33 | horizontalArrangement = Arrangement.SpaceBetween, 34 | verticalAlignment = Alignment.CenterVertically 35 | ) { 36 | Text(title) 37 | Spacer(modifier = Modifier.width(8.dp)) 38 | DetectionSelectionExist(detectionStatus, moveToPreviousOccurrence, moveToNextOccurrence) 39 | } 40 | } 41 | } 42 | 43 | @Composable 44 | fun DetectionSelectionExist( 45 | selection: DetectionStatus, 46 | moveToPreviousOccurrence: (DetectionStatus) -> Unit, 47 | moveToNextOccurrence: (DetectionStatus) -> Unit 48 | ) { 49 | Row(verticalAlignment = Alignment.CenterVertically) { 50 | Row(modifier = Modifier) { 51 | if (selection.selected == null) { 52 | Text( 53 | " ${selection.allDetections.size}", 54 | modifier = Modifier.align(Alignment.CenterVertically) 55 | ) 56 | } else { 57 | Text( 58 | "${selection.currentIndexInView} / ${selection.totalCount}", 59 | modifier = Modifier.align(Alignment.CenterVertically) 60 | ) 61 | } 62 | } 63 | IconButton(onClick = { moveToPreviousOccurrence(selection) }) { 64 | Icon(Icons.Default.KeyboardArrowUp, "Previous Occurrence") 65 | } 66 | IconButton(onClick = { moveToNextOccurrence(selection) }) { 67 | Icon(Icons.Default.KeyboardArrowDown, "Next Occurrence") 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/ShortcutDialog.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class) 2 | 3 | package com.jerryjeon.logjerry.ui 4 | 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.MaterialTheme 9 | import androidx.compose.material.Surface 10 | import androidx.compose.material.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.MutableState 13 | import androidx.compose.ui.ExperimentalComposeUiApi 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.input.key.Key 16 | import androidx.compose.ui.input.key.KeyEventType 17 | import androidx.compose.ui.input.key.key 18 | import androidx.compose.ui.input.key.type 19 | import androidx.compose.ui.unit.dp 20 | import androidx.compose.ui.window.Window 21 | import androidx.compose.ui.window.WindowState 22 | import com.jerryjeon.logjerry.util.isCtrlOrMetaPressed 23 | 24 | @Composable 25 | fun ShortcutDialog( 26 | isOpen: MutableState, 27 | ) { 28 | if (isOpen.value) { 29 | Window( 30 | state = WindowState(width = 1200.dp, height = 1200.dp), 31 | onCloseRequest = { isOpen.value = false }, 32 | onPreviewKeyEvent = { keyEvent -> 33 | if (keyEvent.isCtrlOrMetaPressed && keyEvent.key == Key.W && keyEvent.type == KeyEventType.KeyDown) { 34 | isOpen.value = false 35 | } 36 | false 37 | } 38 | ) { 39 | Surface(color = MaterialTheme.colors.surface, contentColor = MaterialTheme.colors.onSurface) { 40 | Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { 41 | Text( 42 | """ 43 | ⌘ + O - Open File 44 | ⌘ + N - New Tab 45 | ⌘ + V - (When new tab has opened) Paste from clipboard 46 | 47 | ⌘ + M - Mark a selected log 48 | ← - Hide a selected log 49 | ⌘ + [ - Move to previous mark 50 | ⌘ + ] - Move to next mark 51 | ⌘ + F - Find.. 52 | ⌘ + C - Copy the content of the selected log 53 | 54 | Navigation 55 | ↑ - Move to previous log 56 | ↓ - Move to next log 57 | PgUp - Move to previous page 58 | PgDown - Move to next page 59 | Home - Move to the top 60 | End - Move to the bottom 61 | """.trimIndent() 62 | ) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/DetectorManager.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | import com.jerryjeon.logjerry.mark.LogMark 4 | import com.jerryjeon.logjerry.preferences.Preferences 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.FlowPreview 8 | import kotlinx.coroutines.flow.* 9 | 10 | @OptIn(FlowPreview::class) 11 | class DetectorManager(preferences: Preferences) { 12 | private val detectionScope = CoroutineScope(Dispatchers.Default) 13 | 14 | private val defaultDetectors = 15 | listOf(JsonDetector(), DataClassDetector()) + (if (preferences.showExceptionDetection) listOf(ExceptionDetector()) else emptyList()) 16 | private val keywordDetectorEnabledStateFlow: MutableStateFlow = MutableStateFlow(false) 17 | private val detectingKeywordFlow = MutableStateFlow("") 18 | val keywordDetectionRequestFlow = 19 | combine(keywordDetectorEnabledStateFlow, detectingKeywordFlow) { enabled, keyword -> 20 | if (enabled) { 21 | KeywordDetectionRequest.TurnedOn(keyword) 22 | } else { 23 | KeywordDetectionRequest.TurnedOff 24 | } 25 | } 26 | .stateIn(detectionScope, SharingStarted.Lazily, KeywordDetectionRequest.TurnedOff) 27 | 28 | private val toggleMarkLogRequestFlow = MutableStateFlow(null) 29 | private val markDetectorFlow = toggleMarkLogRequestFlow.scan(MarkDetector(emptyMap())) { detector, markRequest -> 30 | when (markRequest) { 31 | is MarkRequest.Mark -> detector.setMark(markRequest.logMark) 32 | is MarkRequest.Delete -> detector.deleteMark(markRequest.logIndex) 33 | null -> detector 34 | } 35 | } 36 | val markedRowsFlow = markDetectorFlow.map { it.logMarks } 37 | .map { it.values.map { it.log }.sortedBy { log -> log.index } } // TODO find cleaner way 38 | .stateIn(detectionScope, SharingStarted.Lazily, emptyList()) 39 | 40 | val detectorsFlow = combine( 41 | keywordDetectionRequestFlow, markDetectorFlow 42 | ) { keywordDetectionRequest, markDetector -> 43 | when (keywordDetectionRequest) { 44 | is KeywordDetectionRequest.TurnedOn -> defaultDetectors + listOf(KeywordDetector(keywordDetectionRequest.keyword)) + markDetector 45 | KeywordDetectionRequest.TurnedOff -> defaultDetectors + markDetector 46 | } 47 | } 48 | 49 | fun findKeyword(keyword: String) { 50 | detectingKeywordFlow.value = keyword 51 | } 52 | 53 | fun setKeywordDetectionEnabled(enabled: Boolean) { 54 | keywordDetectorEnabledStateFlow.value = enabled 55 | } 56 | 57 | fun setMark(logMark: LogMark) { 58 | toggleMarkLogRequestFlow.value = MarkRequest.Mark(logMark) 59 | } 60 | fun deleteMark(logIndex: Int) { 61 | toggleMarkLogRequestFlow.value = MarkRequest.Delete(logIndex) 62 | } 63 | 64 | sealed class MarkRequest { 65 | data class Mark(val logMark: LogMark) : MarkRequest() 66 | data class Delete(val logIndex: Int) : MarkRequest() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/MarkDetectionView.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalFoundationApi::class) 2 | 3 | package com.jerryjeon.logjerry.ui 4 | 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.onClick 8 | import androidx.compose.material.* 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.KeyboardArrowDown 11 | import androidx.compose.material.icons.filled.KeyboardArrowUp 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.CompositionLocalProvider 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.text.style.TextAlign 17 | import androidx.compose.ui.unit.dp 18 | import androidx.compose.ui.unit.sp 19 | import com.jerryjeon.logjerry.detector.DetectionStatus 20 | 21 | @Composable 22 | fun MarkDetectionView( 23 | modifier: Modifier, 24 | detectionStatus: DetectionStatus?, 25 | moveToPreviousOccurrence: (DetectionStatus) -> Unit, 26 | moveToNextOccurrence: (DetectionStatus) -> Unit, 27 | openMarkedRowsTab: () -> Unit 28 | ) { 29 | CompositionLocalProvider( 30 | LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 12.sp), 31 | ) { 32 | Box(modifier = modifier) { 33 | Column(Modifier.height(IntrinsicSize.Min).padding(start = 8.dp, top = 8.dp, bottom = 8.dp, end = 0.dp)) { 34 | Row { 35 | Text("Marked rows") 36 | Spacer(Modifier.width(16.dp)) 37 | } 38 | if (detectionStatus == null) { 39 | Spacer(Modifier.height(16.dp)) 40 | Text("No results", textAlign = TextAlign.Center) 41 | } else { 42 | MarkDetectionSelectionExist(detectionStatus, moveToPreviousOccurrence, moveToNextOccurrence) 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | @Composable 50 | fun MarkDetectionSelectionExist( 51 | selection: DetectionStatus, 52 | moveToPreviousOccurrence: (DetectionStatus) -> Unit, 53 | moveToNextOccurrence: (DetectionStatus) -> Unit 54 | ) { 55 | Column { 56 | Row { 57 | Row(modifier = Modifier.weight(1f).fillMaxHeight()) { 58 | if (selection.selected == null) { 59 | Text( 60 | "${selection.allDetections.size} results", 61 | modifier = Modifier.align(Alignment.CenterVertically) 62 | ) 63 | } else { 64 | Text( 65 | "${selection.currentIndexInView} / ${selection.totalCount}", 66 | modifier = Modifier.align(Alignment.CenterVertically) 67 | ) 68 | } 69 | } 70 | IconButton(onClick = { moveToPreviousOccurrence(selection) }) { 71 | Icon(Icons.Default.KeyboardArrowUp, "Previous Occurrence") 72 | } 73 | IconButton(onClick = { moveToNextOccurrence(selection) }) { 74 | Icon(Icons.Default.KeyboardArrowDown, "Next Occurrence") 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/source/SourceManager.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.source 2 | 3 | import com.jerryjeon.logjerry.log.ParseCompleted 4 | import com.jerryjeon.logjerry.parse.* 5 | import com.jerryjeon.logjerry.preferences.Preferences 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.* 9 | import okio.FileSystem 10 | import okio.Path.Companion.toOkioPath 11 | import okio.Path.Companion.toPath 12 | import okio.openZip 13 | 14 | class SourceManager( 15 | private val preferences: Preferences, 16 | initialSource: Source = Source.None 17 | ) { 18 | private val sourceScope = CoroutineScope(Dispatchers.Default) 19 | private val studioLogcatBelowChipmunkParser = StudioLogcatBelowChipmunkParser( 20 | includeDateTime = true, 21 | includePidTid = true, 22 | includePackageName = true, 23 | includeTag = true 24 | ) 25 | 26 | val sourceFlow: MutableStateFlow = MutableStateFlow(initialSource) 27 | val parseStatusFlow: StateFlow = sourceFlow.map { 28 | if (it is Source.LogsFlow) { 29 | return@map ParseStatus.Completed( 30 | ParseResult(it.logs.value, emptyList()), 31 | ParseCompleted(it.logs, preferences) 32 | ) 33 | } 34 | 35 | @Suppress("KotlinConstantConditions") val lines = when (it) { 36 | is Source.ZipFile -> { 37 | val zipFileSystem = FileSystem.SYSTEM.openZip(it.file.toOkioPath()) 38 | val files = zipFileSystem.listOrNull("/".toPath()) ?: return@map ParseStatus.NotStarted 39 | zipFileSystem.read(files.first()) { readUtf8() }.split("\n") 40 | } 41 | 42 | is Source.File -> it.file.readLines() 43 | is Source.Text -> it.text.split("\n") 44 | is Source.LogsFlow -> throw IllegalStateException("Shouldn't reach here") 45 | Source.None -> return@map ParseStatus.NotStarted 46 | } 47 | val parser = chooseParser(lines) 48 | val parseResult = parser.parse(lines) 49 | ParseStatus.Completed(parseResult, ParseCompleted(MutableStateFlow(parseResult.logs), preferences)) 50 | }.stateIn(sourceScope, SharingStarted.Lazily, ParseStatus.NotStarted) 51 | 52 | private fun chooseParser(lines: List): LogParser { 53 | return lines.firstNotNullOfOrNull { 54 | CustomParser.create(it) 55 | ?: FirebaseTestLabLogcatFormatParser.create(it) 56 | ?: StudioLogcatBelowChipmunkParser.create(it) 57 | ?: StudioLogcatAboveDolphinParser.create(it) 58 | ?: AdbLogcatDefaultFormatParser.create(it) 59 | } ?: studioLogcatBelowChipmunkParser // TODO would be better if show failure message that the parser doesn't exist that can parse the content 60 | } 61 | 62 | fun changeSource(source: Source) { 63 | this.sourceFlow.value = source 64 | } 65 | 66 | fun turnOnKeywordDetection() { 67 | when (val value = parseStatusFlow.value) { 68 | is ParseStatus.Completed -> { 69 | value.parseCompleted.detectorManager.setKeywordDetectionEnabled(true) 70 | } 71 | ParseStatus.NotStarted -> {} 72 | is ParseStatus.Proceeding -> {} 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/popup/TagFilterPopup.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.popup 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material.* 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Sort 12 | import androidx.compose.runtime.* 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.geometry.Offset 16 | import androidx.compose.ui.unit.dp 17 | import com.jerryjeon.logjerry.filter.* 18 | 19 | @Composable 20 | fun TagFilterPopup( 21 | showTagFilterPopup: Boolean, 22 | tagFilterAnchor: Offset, 23 | tagFilters: TagFilters, 24 | tagFilterSortOption: Pair, 25 | dismiss: () -> Unit, 26 | toggleTagFilter: (TagFilter) -> Unit, 27 | includeAll: () -> Unit, 28 | excludeAll: () -> Unit, 29 | setTagFilterSortOption: (FilterSortOption, SortOrder) -> Unit, 30 | ) { 31 | var showSortOptionDialog by remember { mutableStateOf(false) } 32 | if (showSortOptionDialog) { 33 | SortOptionDialog(tagFilterSortOption, setTagFilterSortOption, closeDialog = { showSortOptionDialog = false }) 34 | } 35 | 36 | val scrollState = rememberScrollState() 37 | BasePopup(showTagFilterPopup, tagFilterAnchor, dismiss) { 38 | Column(modifier = Modifier.verticalScroll(scrollState)) { 39 | Row(verticalAlignment = Alignment.CenterVertically) { 40 | OutlinedButton(onClick = includeAll) { 41 | Text( 42 | text = "Check All", 43 | modifier = Modifier, 44 | style = MaterialTheme.typography.body2 45 | ) 46 | } 47 | Spacer(Modifier.width(4.dp)) 48 | OutlinedButton(onClick = excludeAll,) { 49 | Text( 50 | text = "Uncheck All", 51 | modifier = Modifier, 52 | style = MaterialTheme.typography.body2 53 | ) 54 | } 55 | Spacer(Modifier.width(4.dp)) 56 | IconButton(onClick = { 57 | showSortOptionDialog = true 58 | }) { 59 | Icon( 60 | imageVector = Icons.Default.Sort, 61 | contentDescription = "Sort", 62 | ) 63 | } 64 | } 65 | tagFilters.filters.forEachIndexed { index, tagFilter -> 66 | Row(verticalAlignment = Alignment.CenterVertically) { 67 | Checkbox( 68 | checked = tagFilter.include, 69 | onCheckedChange = { 70 | toggleTagFilter(tagFilter) 71 | }, 72 | ) 73 | Text( 74 | text = "${tagFilter.tag ?: "?"} (${tagFilter.frequency})", 75 | modifier = Modifier, 76 | style = MaterialTheme.typography.body2 77 | ) 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/popup/PackageFilterPopup.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.ui.popup 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.foundation.rememberScrollState 8 | import androidx.compose.foundation.verticalScroll 9 | import androidx.compose.material.* 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Sort 12 | import androidx.compose.runtime.* 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.geometry.Offset 16 | import androidx.compose.ui.unit.dp 17 | import com.jerryjeon.logjerry.filter.* 18 | 19 | @Composable 20 | fun PackageFilterPopup( 21 | showPackageFilterPopup: Boolean, 22 | packageFilterAnchor: Offset, 23 | packageFilters: PackageFilters, 24 | packageFilterSortOption: Pair, 25 | dismiss: () -> Unit, 26 | togglePackageFilter: (PackageFilter) -> Unit, 27 | includeAll: () -> Unit, 28 | excludeAll: () -> Unit, 29 | setPackageFilterSortOption: (FilterSortOption, SortOrder) -> Unit, 30 | ) { 31 | var showSortOptionDialog by remember { mutableStateOf(false) } 32 | if (showSortOptionDialog) { 33 | SortOptionDialog(packageFilterSortOption, setPackageFilterSortOption, closeDialog = { showSortOptionDialog = false }) 34 | } 35 | 36 | BasePopup(showPackageFilterPopup, packageFilterAnchor, dismiss) { 37 | Column(modifier = Modifier.verticalScroll(rememberScrollState())) { 38 | Row(verticalAlignment = Alignment.CenterVertically) { 39 | OutlinedButton(onClick = includeAll) { 40 | Text( 41 | text = "Check All", 42 | modifier = Modifier, 43 | style = MaterialTheme.typography.body2 44 | ) 45 | } 46 | Spacer(Modifier.width(4.dp)) 47 | OutlinedButton(onClick = excludeAll,) { 48 | Text( 49 | text = "Uncheck All", 50 | modifier = Modifier, 51 | style = MaterialTheme.typography.body2 52 | ) 53 | } 54 | Spacer(Modifier.width(4.dp)) 55 | IconButton(onClick = { 56 | showSortOptionDialog = true 57 | }) { 58 | Icon( 59 | imageVector = Icons.Default.Sort, 60 | contentDescription = "Sort", 61 | ) 62 | } 63 | } 64 | packageFilters.filters.forEachIndexed { index, packageFilter -> 65 | Row(verticalAlignment = Alignment.CenterVertically) { 66 | Checkbox( 67 | checked = packageFilter.include, 68 | onCheckedChange = { 69 | togglePackageFilter(packageFilter) 70 | }, 71 | ) 72 | Text( 73 | text = "${packageFilter.packageName ?: "?"} (${packageFilter.frequency})", 74 | modifier = Modifier, 75 | style = MaterialTheme.typography.body2 76 | ) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/preferences/Preferences.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalUnitApi::class) 2 | 3 | package com.jerryjeon.logjerry.preferences 4 | 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.graphics.Color 8 | import androidx.compose.ui.unit.ExperimentalUnitApi 9 | import androidx.compose.ui.unit.TextUnit 10 | import androidx.compose.ui.unit.sp 11 | import com.jerryjeon.logjerry.log.Priority 12 | import com.jerryjeon.logjerry.serialization.ColorAsLongSerializer 13 | import com.jerryjeon.logjerry.serialization.TextUnitAsFloatSerializer 14 | import com.jerryjeon.logjerry.table.Header 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.serialization.ExperimentalSerializationApi 17 | import kotlinx.serialization.Serializable 18 | import kotlinx.serialization.Transient 19 | import kotlinx.serialization.json.decodeFromStream 20 | import java.io.File 21 | 22 | @OptIn(ExperimentalSerializationApi::class) 23 | @Serializable 24 | data class Preferences( 25 | @Serializable(with = TextUnitAsFloatSerializer::class) val fontSize: TextUnit = 14.sp, 26 | val colorTheme: ColorTheme = ColorTheme.System, 27 | val lightColorByPriority: Map = mutableMapOf( 28 | Priority.Verbose to Color(0xFFBBBBBB), 29 | Priority.Debug to Color(0xFFAAB895), 30 | Priority.Info to Color(0xFF3EDE66), 31 | Priority.Warning to Color(0xFFFF6B68), 32 | Priority.Error to Color(0xFFFF6B68), 33 | ), 34 | val lightBackgroundColor: @Serializable(with = ColorAsLongSerializer::class) Color = Color.White, 35 | val darkColorByPriority: Map = mutableMapOf( 36 | Priority.Verbose to Color(0xFFBBBBBB), 37 | Priority.Debug to Color(0xFFAAB895), 38 | Priority.Info to Color(0xFF3EDE66), 39 | Priority.Warning to Color(0xFFFF6B68), 40 | Priority.Error to Color(0xFFFF6B68), 41 | ), 42 | val darkBackgroundColor: @Serializable(with = ColorAsLongSerializer::class) Color = Color(0XFF121212), 43 | val showExceptionDetection: Boolean = true, 44 | val showInvalidSentences: Boolean = true, 45 | val jsonPreviewSize: Int = 20, 46 | val windowSizeWhenOpened: WindowSize? = null, // (width, height), null to maximize 47 | ) { 48 | 49 | // TODO should be optimized 50 | @Composable 51 | fun colorByPriority(): Map { 52 | return when (colorTheme) { 53 | ColorTheme.Light -> lightColorByPriority 54 | ColorTheme.Dark -> darkColorByPriority 55 | ColorTheme.System -> { 56 | if (isSystemInDarkTheme()) { 57 | darkColorByPriority 58 | } else { 59 | lightColorByPriority 60 | } 61 | } 62 | } 63 | } 64 | 65 | companion object { 66 | val default = Preferences() 67 | val file = File(System.getProperty("java.io.tmpdir"), "LogJerryPreferences.json") 68 | } 69 | 70 | // These are not need to be here, but I don't want to create another class for this 71 | @Transient 72 | val headerFlow = MutableStateFlow( 73 | try { 74 | Header.file.inputStream().use { PreferencesViewModel.json.decodeFromStream(it) } 75 | } catch (e: Exception) { 76 | Header() 77 | } 78 | ) 79 | } 80 | 81 | enum class ColorTheme { 82 | Light, Dark, System 83 | } 84 | -------------------------------------------------------------------------------- /src/test/kotlin/com/jerryjeon/logjerry/parse/StudioLogcatAboveDolphinParserTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import io.kotest.matchers.collections.shouldBeEmpty 4 | import io.kotest.matchers.nulls.shouldNotBeNull 5 | import io.kotest.matchers.shouldBe 6 | import org.junit.jupiter.api.Assertions.* 7 | import org.junit.jupiter.api.Nested 8 | import org.junit.jupiter.params.ParameterizedTest 9 | import org.junit.jupiter.params.provider.Arguments 10 | import org.junit.jupiter.params.provider.MethodSource 11 | import java.util.stream.Stream 12 | 13 | internal class StudioLogcatAboveDolphinParserTest { 14 | 15 | @Nested 16 | inner class FactoryTest { 17 | 18 | @ParameterizedTest 19 | @MethodSource("logAndIncludeSettings") 20 | fun `Factory can be created for all include settings`(input: String, expected: StudioLogcatAboveDolphinParser) { 21 | val parser = StudioLogcatAboveDolphinParser.create(input) 22 | parser.shouldNotBeNull() shouldBe expected 23 | 24 | parser.parse(listOf(input)).invalidSentences.shouldBeEmpty() 25 | } 26 | 27 | // TODO add tests for various formats 28 | private fun logAndIncludeSettings(): Stream { 29 | return Stream.of( 30 | Arguments.of( 31 | "2022-10-30 20:23:38.484 19086-19156 Gralloc4 com.example.myapplication I mapper 4.x is not supported", 32 | StudioLogcatAboveDolphinParser( 33 | includeDate = true, 34 | includeTime = true, 35 | includePid = true, 36 | includeTid = true, 37 | includeTag = true, 38 | includePackageName = true 39 | ) 40 | ), 41 | Arguments.of( 42 | "2022-09-26 23:45:28.054 321-16228 resolv pid-321 D res_nmkquery: (QUERY, IN, A)", 43 | StudioLogcatAboveDolphinParser( 44 | includeDate = true, 45 | includeTime = true, 46 | includePid = true, 47 | includeTid = true, 48 | includeTag = true, 49 | includePackageName = true 50 | ) 51 | ), 52 | Arguments.of( 53 | "2022-09-26 23:46:30.454 543-599 VerityUtils system_process E Failed to measure fs-verity, errno 1: /data/app/~~byIkOUX0heIwtiImACXFMg==/com.sendbird.android.test-e2Yic0CCjvMq4lmW7z0IAA==/base.apk", 54 | StudioLogcatAboveDolphinParser( 55 | includeDate = true, 56 | includeTime = true, 57 | includePid = true, 58 | includeTid = true, 59 | includeTag = true, 60 | includePackageName = true 61 | ) 62 | ), 63 | 64 | // Compact view 65 | Arguments.of( 66 | "20:58:04.567 W Failed to initialize 101010-2 format, error = EGL_SUCCESS", 67 | StudioLogcatAboveDolphinParser( 68 | includeDate = false, 69 | includeTime = true, 70 | includePid = false, 71 | includeTid = false, 72 | includeTag = false, 73 | includePackageName = false 74 | ) 75 | ), 76 | 77 | Arguments.of( 78 | "20:58:04.567 Tag W Failed to initialize 101010-2 format, error = EGL_SUCCESS", 79 | StudioLogcatAboveDolphinParser( 80 | includeDate = false, 81 | includeTime = true, 82 | includePid = false, 83 | includeTid = false, 84 | includeTag = true, 85 | includePackageName = false 86 | ) 87 | ), 88 | ) 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/tab/TabManager.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.tab 2 | 3 | import com.jerryjeon.logjerry.preferences.Preferences 4 | import com.jerryjeon.logjerry.source.Source 5 | import com.jerryjeon.logjerry.source.SourceManager 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import java.io.File 8 | 9 | class TabManager( 10 | private val preferences: Preferences, 11 | initialTab: Tab = Tab(name = "Getting Started", sourceManager = SourceManager(preferences)) 12 | ) { 13 | 14 | val tabs = MutableStateFlow(Tabs(listOf(initialTab), initialTab)) 15 | 16 | fun findShortcutPressed() { 17 | tabs.value.active.sourceManager.turnOnKeywordDetection() 18 | } 19 | 20 | fun onNewFileSelected(file: File) { 21 | val newActiveTab = Tab(file.name, SourceManager(preferences)) 22 | if (file.extension.equals("zip", true)) { 23 | newActiveTab.sourceManager.changeSource(Source.ZipFile(file)) 24 | } else { 25 | newActiveTab.sourceManager.changeSource(Source.File(file)) 26 | } 27 | 28 | val (tabList, active) = tabs.value 29 | 30 | val newTabList = when (active.sourceManager.sourceFlow.value) { 31 | Source.None -> { 32 | // Remove getting started view 33 | (tabList - active) + newActiveTab 34 | } 35 | else -> { 36 | tabList + newActiveTab 37 | } 38 | } 39 | tabs.value = tabs.value.copy( 40 | tabList = newTabList, 41 | active = newActiveTab 42 | ) 43 | } 44 | 45 | fun activate(tab: Tab) { 46 | tabs.value = tabs.value.copy(active = tab) 47 | } 48 | 49 | fun newTab(name: String = "New tab", source: Source? = null) { 50 | val newActiveTab = if (source == null) { 51 | Tab.gettingStarted(preferences) 52 | } else { 53 | Tab(name, SourceManager(preferences, source)) 54 | } 55 | val (tabList, _) = tabs.value 56 | tabs.value = tabs.value.copy( 57 | tabList = tabList + newActiveTab, 58 | active = newActiveTab 59 | ) 60 | } 61 | 62 | fun moveToPreviousTab() { 63 | val (tabList, activated) = tabs.value 64 | val index = tabList.indexOf(activated) 65 | val newActiveTab = if (index <= 0) { 66 | tabList[tabList.size - 1] 67 | } else { 68 | tabList[index - 1] 69 | } 70 | tabs.value = tabs.value.copy(active = newActiveTab) 71 | } 72 | 73 | fun moveToNextTab() { 74 | val (tabList, activated) = tabs.value 75 | val index = tabList.indexOf(activated) 76 | val newActiveTab = if (index >= tabList.size - 1) { 77 | tabList[0] 78 | } else { 79 | tabList[index + 1] 80 | } 81 | tabs.value = tabs.value.copy(active = newActiveTab) 82 | } 83 | fun closeActiveTab() { 84 | close(tabs.value.active) 85 | } 86 | 87 | fun close(tab: Tab) { 88 | val tabList = tabs.value.tabList 89 | val closingTabIndex = tabList.indexOf(tab) 90 | 91 | when (tab) { 92 | tabs.value.active -> { 93 | when { 94 | tabList.size <= 1 -> { 95 | val newActiveTab = Tab.gettingStarted(preferences) 96 | tabs.value = Tabs( 97 | tabList = listOf(newActiveTab), 98 | active = newActiveTab 99 | ) 100 | } 101 | else -> { 102 | val nexIndex = if (closingTabIndex <= 0) tabList.size - 1 else closingTabIndex - 1 103 | val newActiveTab = tabList[nexIndex] 104 | tabs.value = tabs.value.copy( 105 | tabList = (tabList - tab), 106 | active = newActiveTab 107 | ) 108 | } 109 | } 110 | } 111 | else -> { 112 | tabs.value = tabs.value.copy(tabList = (tabList - tab)) 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/logview/RefineResult.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.logview 2 | 3 | import com.jerryjeon.logjerry.detector.Detection 4 | import com.jerryjeon.logjerry.detector.DetectionStatus 5 | import com.jerryjeon.logjerry.detector.DetectorKey 6 | import com.jerryjeon.logjerry.ui.focus.DetectionFocus 7 | import com.jerryjeon.logjerry.ui.focus.LogFocus 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.update 10 | 11 | data class RefineResult( 12 | val refinedLogs: List, 13 | val allDetections: Map> 14 | ) { 15 | val currentFocus = MutableStateFlow(null) 16 | 17 | val statusByKey = MutableStateFlow( 18 | allDetections 19 | .mapValues { (key, values) -> 20 | if (values.isEmpty()) { 21 | null 22 | } else { 23 | DetectionStatus(key, 0, null, values) 24 | } 25 | } 26 | ) 27 | 28 | val markInfos: List 29 | 30 | init { 31 | val markedLogs = refinedLogs.filter { it.marked } 32 | markInfos = if (markedLogs.isEmpty()) { 33 | emptyList() 34 | } else { 35 | val markInfos = mutableListOf() 36 | markedLogs 37 | .scan(refinedLogs.first()) { prevRefinedLog, refinedLog -> 38 | val duration = prevRefinedLog.durationBetween(refinedLog) 39 | markInfos.add( 40 | MarkInfo.StatBetweenMarks( 41 | logCount = refinedLogs.indexOf(refinedLog) - refinedLogs.indexOf(prevRefinedLog), 42 | duration = duration?.toHumanReadable() 43 | ) 44 | ) 45 | markInfos.add(MarkInfo.Marked(refinedLog)) 46 | refinedLog 47 | } 48 | 49 | val lastLog = refinedLogs.last() 50 | val lastMarkedLogs = markedLogs.last() 51 | val duration = lastMarkedLogs.durationBetween(lastLog) 52 | 53 | markInfos.add( 54 | MarkInfo.StatBetweenMarks( 55 | logCount = refinedLogs.indexOf(lastLog) - refinedLogs.indexOf(lastMarkedLogs), 56 | duration = duration?.toHumanReadable() 57 | ) 58 | ) 59 | markInfos 60 | } 61 | } 62 | 63 | fun selectPreviousDetection(status: DetectionStatus) { 64 | val previousIndex = if (status.currentIndex <= 0) { 65 | status.allDetections.size - 1 66 | } else { 67 | status.currentIndex - 1 68 | } 69 | 70 | val newStatus = status.copy(currentIndex = previousIndex, selected = status.allDetections[previousIndex]) 71 | updateDetectionStatus(newStatus) 72 | } 73 | 74 | fun selectNextDetection(selection: DetectionStatus) { 75 | val nextIndex = if (selection.currentIndex >= selection.allDetections.size - 1) { 76 | 0 77 | } else { 78 | selection.currentIndex + 1 79 | } 80 | val newSelection = selection.copy(currentIndex = nextIndex, selected = selection.allDetections[nextIndex]) 81 | updateDetectionStatus(newSelection) 82 | } 83 | 84 | private fun updateDetectionStatus(newStatus: DetectionStatus) { 85 | statusByKey.update { it + (newStatus.key to newStatus) } 86 | 87 | val indexInRefinedLogs = refinedLogs.indexOfFirst { refinedLog -> 88 | refinedLog.log.index == newStatus.selected?.logIndex 89 | } 90 | if (indexInRefinedLogs != -1) { 91 | currentFocus.value = DetectionFocus(indexInRefinedLogs) 92 | } 93 | } 94 | 95 | fun selectPreviousDetection(key: DetectorKey) { 96 | statusByKey.value[key]?.let { selectPreviousDetection(it) } 97 | } 98 | 99 | fun selectNextDetection(key: DetectorKey) { 100 | statusByKey.value[key]?.let { selectNextDetection(it) } 101 | } 102 | 103 | fun selectDetection(detection: Detection) { 104 | statusByKey.update { 105 | val status = it[detection.key] ?: return@update it 106 | val index = status.allDetections.indexOf(detection) 107 | val newStatus = status.copy(currentIndex = index, selected = detection) 108 | it + (newStatus.key to newStatus) 109 | } 110 | 111 | val indexInRefinedLogs = refinedLogs.indexOfFirst { refinedLog -> 112 | refinedLog.log.index == statusByKey.value[detection.key]?.selected?.logIndex 113 | } 114 | currentFocus.value = DetectionFocus(indexInRefinedLogs) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/SortOptionDialog.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | // Dialog that changes the sort order 3 | 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.* 6 | import androidx.compose.runtime.* 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import androidx.compose.ui.window.Dialog 11 | import androidx.compose.ui.window.DialogState 12 | 13 | @Composable 14 | fun SortOptionDialog( 15 | tagFilterSortOption: Pair, 16 | setTagFilterSortOption: (FilterSortOption, SortOrder) -> Unit, 17 | closeDialog: () -> Unit, 18 | ) { 19 | Dialog( 20 | title = "Sort by", 21 | state = DialogState(width = 300.dp, height = 300.dp), 22 | onCloseRequest = closeDialog, 23 | ) { 24 | var sortOption by remember { mutableStateOf(tagFilterSortOption.first) } 25 | var sortOrder by remember { mutableStateOf(tagFilterSortOption.second) } 26 | 27 | Surface(color = MaterialTheme.colors.surface, contentColor = MaterialTheme.colors.onSurface) { 28 | Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { 29 | Text(text = "Sort by", modifier = Modifier, style = MaterialTheme.typography.body2) 30 | Row { 31 | Row(verticalAlignment = Alignment.CenterVertically) { 32 | RadioButton( 33 | selected = sortOption == FilterSortOption.Frequency, 34 | onClick = { sortOption = FilterSortOption.Frequency } 35 | ) 36 | Text( 37 | text = "Frequency", 38 | modifier = Modifier, 39 | style = MaterialTheme.typography.body2 40 | ) 41 | } 42 | Row(verticalAlignment = Alignment.CenterVertically) { 43 | RadioButton( 44 | selected = sortOption == FilterSortOption.Name, 45 | onClick = { sortOption = FilterSortOption.Name } 46 | ) 47 | Text( 48 | text = "Name", 49 | modifier = Modifier, 50 | style = MaterialTheme.typography.body2 51 | ) 52 | } 53 | } 54 | 55 | Text(text = "Sort order", modifier = Modifier, style = MaterialTheme.typography.body2) 56 | Row { 57 | Row(verticalAlignment = Alignment.CenterVertically) { 58 | RadioButton( 59 | selected = sortOrder == SortOrder.Ascending, 60 | onClick = { sortOrder = SortOrder.Ascending } 61 | ) 62 | Text( 63 | text = "Ascending", 64 | modifier = Modifier, 65 | style = MaterialTheme.typography.body2 66 | ) 67 | } 68 | Row(verticalAlignment = Alignment.CenterVertically) { 69 | RadioButton( 70 | selected = sortOrder == SortOrder.Descending, 71 | onClick = { sortOrder = SortOrder.Descending } 72 | ) 73 | Text( 74 | text = "Descending", 75 | modifier = Modifier, 76 | style = MaterialTheme.typography.body2 77 | ) 78 | } 79 | } 80 | Spacer(Modifier.height(8.dp)) 81 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { 82 | Button(onClick = { 83 | setTagFilterSortOption(sortOption, sortOrder) 84 | closeDialog() 85 | }) { 86 | Text( 87 | text = "Ok", 88 | modifier = Modifier, 89 | style = MaterialTheme.typography.body2 90 | ) 91 | } 92 | Spacer(Modifier.width(8.dp)) 93 | Button(onClick = closeDialog) { 94 | Text( 95 | text = "Cancel", 96 | modifier = Modifier, 97 | style = MaterialTheme.typography.body2 98 | ) 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/DataClassDetector.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.detector 2 | 3 | import androidx.compose.ui.graphics.Color 4 | import androidx.compose.ui.text.SpanStyle 5 | import java.util.UUID 6 | 7 | class DataClassDetector : Detector { 8 | override val key: DetectorKey = DetectorKey.DataClass 9 | override val shownAsBlock: Boolean = true 10 | 11 | override fun detect(logStr: String, logIndex: Int): List { 12 | val bracketRanges = extractDataClassRanges(logStr) 13 | 14 | val dataClasses = bracketRanges.mapNotNull { range -> 15 | val text = logStr.substring(range) 16 | try { 17 | range to (parseToStringRepresentation(text) ?: throw IllegalArgumentException("Invalid data class")) 18 | } catch (_: Exception) { 19 | null 20 | } 21 | } 22 | 23 | return dataClasses.map { (range, json) -> 24 | DataClassDetection(range, logIndex, json) 25 | } 26 | } 27 | } 28 | 29 | fun extractDataClassRanges(input: String): List { 30 | val results = mutableListOf() 31 | 32 | var startIndex = -1 33 | var openParensCount = 0 34 | var hasEquals = false // To check if we found an argument with "=" 35 | 36 | input.forEachIndexed { index, char -> 37 | when (char) { 38 | '(' -> { 39 | if (startIndex == -1) { 40 | var potentialStart = index - 1 41 | while (potentialStart >= 0 && !input[potentialStart].isLetter()) { 42 | potentialStart-- 43 | } 44 | while (potentialStart >= 0 && input[potentialStart].isLetter()) { 45 | potentialStart-- 46 | } 47 | if (potentialStart >= 0 && input[potentialStart + 1].isUpperCase()) { 48 | startIndex = potentialStart + 1 // Adjust to start of the class name 49 | } 50 | } 51 | openParensCount++ 52 | } 53 | '=' -> if (openParensCount > 0) hasEquals = true 54 | ')' -> { 55 | openParensCount-- 56 | if (openParensCount == 0 && startIndex != -1 && hasEquals) { 57 | results.add(IntRange(startIndex, index)) 58 | startIndex = -1 59 | hasEquals = false 60 | } 61 | } 62 | } 63 | } 64 | 65 | return results 66 | } 67 | 68 | fun parseToStringRepresentation(input: String): Map? { 69 | val resultMap = mutableMapOf() 70 | 71 | fun parseProperties(propertiesString: String): Map { 72 | val propertyMap = mutableMapOf() 73 | 74 | val propertyRegex = """(\w+)=((?:\w+)|(?:\w+\([\w\W]+?\)))""".toRegex() 75 | val properties = propertyRegex.findAll(propertiesString) 76 | 77 | for (propertyMatch in properties) { 78 | val propertyName = propertyMatch.groupValues[1] 79 | val propertyValue = propertyMatch.groupValues[2] 80 | 81 | if (propertyValue.matches("""\d+""".toRegex())) { 82 | propertyMap[propertyName] = propertyValue.toInt() 83 | } else if (propertyValue.contains("(")) { 84 | // Recursive call for nested data class 85 | val parsedValue = parseToStringRepresentation(propertyValue) 86 | if (parsedValue != null) { 87 | propertyMap[propertyName] = parsedValue 88 | } 89 | } else { 90 | propertyMap[propertyName] = propertyValue 91 | } 92 | } 93 | 94 | return propertyMap 95 | } 96 | 97 | val regex = """(\w+)\(([\w\W]*?)\)""".toRegex() 98 | val matchResult = regex.find(input) ?: return null 99 | 100 | val className = matchResult.groupValues[1] 101 | val propertiesString = matchResult.groupValues[2] 102 | 103 | resultMap.putAll(parseProperties(propertiesString)) 104 | // If the data class representation has no arguments, return null 105 | if (resultMap.isEmpty()) { 106 | return null 107 | } 108 | 109 | resultMap["class"] = className 110 | 111 | return resultMap 112 | } 113 | 114 | class DataClassDetection( 115 | override val range: IntRange, 116 | override val logIndex: Int, 117 | val map: Map, 118 | override val id: String = UUID.randomUUID().toString() 119 | ) : Detection { 120 | override val key: DetectorKey = DetectorKey.DataClass 121 | override val style: SpanStyle 122 | get() = detectedStyle 123 | 124 | companion object { 125 | val detectedStyle = SpanStyle(background = Color(0x40D3D3D3)) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/log/ParseCompleted.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.log 2 | 3 | import com.jerryjeon.logjerry.detection.DetectionFinished 4 | import com.jerryjeon.logjerry.detector.Detection 5 | import com.jerryjeon.logjerry.detector.DetectorKey 6 | import com.jerryjeon.logjerry.detector.DetectorManager 7 | import com.jerryjeon.logjerry.filter.FilterManager 8 | import com.jerryjeon.logjerry.logview.LogAnnotation 9 | import com.jerryjeon.logjerry.logview.RefineResult 10 | import com.jerryjeon.logjerry.logview.RefinedLog 11 | import com.jerryjeon.logjerry.preferences.Preferences 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.flow.SharingStarted 15 | import kotlinx.coroutines.flow.StateFlow 16 | import kotlinx.coroutines.flow.combine 17 | import kotlinx.coroutines.flow.map 18 | import kotlinx.coroutines.flow.mapLatest 19 | import kotlinx.coroutines.flow.stateIn 20 | import kotlinx.coroutines.yield 21 | 22 | /** 23 | * The class that is created after parsing is completed. 24 | */ 25 | class ParseCompleted( 26 | val originalLogsFlow: StateFlow>, 27 | preferences: Preferences, 28 | ) { 29 | private val refineScope = CoroutineScope(Dispatchers.Default) 30 | val filterManager = FilterManager(originalLogsFlow) 31 | val detectorManager = DetectorManager(preferences) 32 | 33 | private val filteredLogsFlow = 34 | combine( 35 | originalLogsFlow, 36 | filterManager.filtersFlow, 37 | ::Pair 38 | ).mapLatest { (originalLogs, filters) -> 39 | if (filters.isEmpty()) { 40 | originalLogs 41 | } else { 42 | originalLogs 43 | .filter { log -> 44 | filters.all { 45 | yield() 46 | it.filter(log) 47 | } 48 | } 49 | } 50 | } 51 | 52 | val detectionFinishedFlow = 53 | combine( 54 | filteredLogsFlow, 55 | detectorManager.detectorsFlow, 56 | ::Pair 57 | ).mapLatest { (filteredLogs, detectors) -> 58 | val allDetectionResults = mutableMapOf>() 59 | val detectionFinishedLogs = filteredLogs.associateWith { log -> 60 | yield() 61 | val detections = detectors.associate { it.key to it.detect(log.log, log.index) } 62 | detections.forEach { (key, value) -> 63 | yield() 64 | allDetectionResults[key] = (allDetectionResults[key] ?: emptyList()) + value 65 | } 66 | detections 67 | } 68 | 69 | DetectionFinished(detectors, detectionFinishedLogs, allDetectionResults) 70 | }.stateIn( 71 | refineScope, 72 | SharingStarted.Lazily, 73 | DetectionFinished(emptyList(), emptyMap(), emptyMap()) 74 | ) 75 | 76 | val refineResultFlow = combine( 77 | filteredLogsFlow, 78 | detectionFinishedFlow, 79 | ::Pair, 80 | ).mapLatest { (filteredLogs, detectionFinished) -> 81 | val allDetections = mutableMapOf>() 82 | var lastRefinedLog: RefinedLog? = null 83 | val refinedLogs = filteredLogs.map { log -> 84 | yield() 85 | val detections = detectionFinished.detectionsByLog[log] ?: emptyMap() 86 | detections.forEach { (key, newValue) -> 87 | yield() 88 | val list = allDetections.getOrPut(key) { emptyList() } 89 | allDetections[key] = list + newValue 90 | } 91 | 92 | // Why should it be separated : make possible to change data of detectionResult 93 | // TODO don't want to repeat all annotate if just one log has changed. How can I achieve it 94 | val logContents = 95 | LogAnnotation.separateAnnotationStrings(log, detections.values.flatten()) 96 | val timeGap = lastRefinedLog?.log?.durationBetween(log)?.takeIf { it.toSeconds() >= 3 } 97 | RefinedLog( 98 | log, 99 | detections, 100 | LogAnnotation.annotate(log, logContents, detectionFinished.detectors), 101 | timeGap 102 | ) 103 | .also { 104 | lastRefinedLog = it 105 | } 106 | } 107 | RefineResult(refinedLogs, allDetections) 108 | }.stateIn(refineScope, SharingStarted.Lazily, RefineResult(emptyList(), emptyMap())) 109 | 110 | val dateSet = originalLogsFlow.map { logs -> 111 | logs.map { it.date }.toSet() 112 | } 113 | .stateIn(refineScope, SharingStarted.Lazily, emptySet()) 114 | 115 | val singleDate = dateSet.map { it.singleOrNull() } 116 | .stateIn(refineScope, SharingStarted.Lazily, null) 117 | 118 | val optimizedHeader = combine( 119 | preferences.headerFlow, 120 | filterManager.tagFiltersFlow, 121 | filterManager.packageFiltersFlow, 122 | dateSet, 123 | ) { header, tagFilters, packageFilters, dateSet -> 124 | header.copy( 125 | tag = header.tag.copy(visible = tagFilters.filters.filter { it.include }.size != 1), 126 | packageName = header.packageName.copy(visible = packageFilters.filters.filter { it.include }.size != 1), 127 | date = header.date.copy(visible = dateSet.size > 1) 128 | ) 129 | } 130 | .stateIn(refineScope, SharingStarted.Lazily, preferences.headerFlow.value) 131 | } 132 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/detector/KeywordDetectionView.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class) 2 | 3 | package com.jerryjeon.logjerry.detector 4 | 5 | import androidx.compose.desktop.ui.tooling.preview.Preview 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material.* 10 | import androidx.compose.material.icons.Icons 11 | import androidx.compose.material.icons.filled.Close 12 | import androidx.compose.material.icons.filled.KeyboardArrowDown 13 | import androidx.compose.material.icons.filled.KeyboardArrowUp 14 | import androidx.compose.material.icons.filled.Search 15 | import androidx.compose.runtime.* 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.ExperimentalComposeUiApi 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.focus.FocusRequester 20 | import androidx.compose.ui.focus.focusRequester 21 | import androidx.compose.ui.input.key.* 22 | import androidx.compose.ui.text.TextRange 23 | import androidx.compose.ui.text.input.TextFieldValue 24 | import androidx.compose.ui.unit.dp 25 | import androidx.compose.ui.unit.sp 26 | 27 | @Composable 28 | fun KeywordDetectionView( 29 | modifier: Modifier = Modifier, 30 | keywordDetectionRequest: KeywordDetectionRequest, 31 | detectionStatus: DetectionStatus?, 32 | find: (String) -> Unit, 33 | setFindEnabled: (Boolean) -> Unit, 34 | moveToPreviousOccurrence: (DetectionStatus) -> Unit, 35 | moveToNextOccurrence: (DetectionStatus) -> Unit, 36 | ) { 37 | CompositionLocalProvider( 38 | LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 12.sp), 39 | ) { 40 | when (keywordDetectionRequest) { 41 | is KeywordDetectionRequest.TurnedOn -> { 42 | KeywordDetectionRequestViewTurnedOn( 43 | modifier, 44 | keywordDetectionRequest, 45 | find, 46 | setFindEnabled, 47 | detectionStatus, 48 | moveToPreviousOccurrence, 49 | moveToNextOccurrence 50 | ) 51 | } 52 | KeywordDetectionRequest.TurnedOff -> {} 53 | } 54 | } 55 | } 56 | 57 | @Composable 58 | private fun KeywordDetectionRequestViewTurnedOn( 59 | modifier: Modifier, 60 | keywordDetectionRequest: KeywordDetectionRequest.TurnedOn, 61 | find: (String) -> Unit, 62 | setFindEnabled: (Boolean) -> Unit, 63 | detectionStatus: DetectionStatus?, 64 | moveToPreviousOccurrence: (DetectionStatus) -> Unit, 65 | moveToNextOccurrence: (DetectionStatus) -> Unit 66 | ) { 67 | val focusRequester = remember { FocusRequester() } 68 | val textFieldValue by derivedStateOf { 69 | TextFieldValue(text = keywordDetectionRequest.keyword, selection = TextRange(keywordDetectionRequest.keyword.length)) 70 | } 71 | 72 | Box(modifier.padding(8.dp)) { 73 | OutlinedTextField( 74 | modifier = Modifier.focusRequester(focusRequester).onPreviewKeyEvent { 75 | when { 76 | it.key == Key.Enter && it.type == KeyEventType.KeyDown -> { 77 | detectionStatus?.let { selection -> 78 | if (it.isShiftPressed) { 79 | moveToPreviousOccurrence(selection) 80 | } else { 81 | moveToNextOccurrence(selection) 82 | } 83 | } 84 | true 85 | } 86 | it.key == Key.Escape && it.type == KeyEventType.KeyDown -> { 87 | setFindEnabled(false) 88 | true 89 | } 90 | else -> false 91 | } 92 | }, 93 | value = textFieldValue, 94 | onValueChange = { find(it.text) }, 95 | leadingIcon = { Icon(Icons.Default.Search, "Search") }, 96 | trailingIcon = { 97 | Row { 98 | detectionStatus?.let { 99 | if (it.selected == null) { 100 | Text( 101 | "${it.allDetections.size} results", 102 | modifier = Modifier.align(Alignment.CenterVertically) 103 | ) 104 | } else { 105 | Text( 106 | "${it.currentIndexInView} / ${detectionStatus.totalCount}", 107 | modifier = Modifier.align(Alignment.CenterVertically) 108 | ) 109 | } 110 | IconButton(onClick = { moveToPreviousOccurrence(it) }) { 111 | Icon(Icons.Default.KeyboardArrowUp, "Previous Occurrence") 112 | } 113 | IconButton(onClick = { moveToNextOccurrence(it) }) { 114 | Icon(Icons.Default.KeyboardArrowDown, "Next Occurrence") 115 | } 116 | } 117 | IconButton(onClick = { setFindEnabled(false) }) { 118 | Icon(Icons.Default.Close, "Close find") 119 | } 120 | } 121 | }, 122 | singleLine = true 123 | ) 124 | } 125 | 126 | LaunchedEffect(keywordDetectionRequest::class) { 127 | focusRequester.requestFocus() 128 | } 129 | } 130 | 131 | @Preview 132 | @Composable 133 | private fun KeywordDetectionViewPreview() { 134 | } 135 | -------------------------------------------------------------------------------- /src/test/kotlin/com/jerryjeon/logjerry/parse/StudioLogcatBelowChipmunkParserTest.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import io.kotest.matchers.collections.shouldBeEmpty 4 | import io.kotest.matchers.nulls.shouldNotBeNull 5 | import io.kotest.matchers.shouldBe 6 | import org.junit.jupiter.api.Assertions.* 7 | import org.junit.jupiter.api.Nested 8 | import org.junit.jupiter.params.ParameterizedTest 9 | import org.junit.jupiter.params.provider.Arguments 10 | import org.junit.jupiter.params.provider.MethodSource 11 | import java.util.stream.Stream 12 | 13 | internal class StudioLogcatBelowChipmunkParserTest { 14 | 15 | @Nested 16 | inner class FactoryTest { 17 | 18 | @ParameterizedTest 19 | @MethodSource("logAndStudioLogcatBelowChipmunkParser") 20 | fun `Factory can be created for all include settings`(input: String, expected: StudioLogcatBelowChipmunkParser) { 21 | val parser = StudioLogcatBelowChipmunkParser.create(input) 22 | parser.shouldNotBeNull() shouldBe expected 23 | 24 | parser.parse(listOf(input)).invalidSentences.shouldBeEmpty() 25 | } 26 | 27 | private fun logAndStudioLogcatBelowChipmunkParser(): Stream { 28 | return Stream.of( 29 | Arguments.of( 30 | "I: Tried to unregister apexservice, but there is about to be a client.", 31 | StudioLogcatBelowChipmunkParser(includeDateTime = false, includePidTid = false, includePackageName = false, includeTag = false) 32 | ), 33 | Arguments.of( 34 | "I/servicemanager: Tried to unregister apexservice, but there is about to be a client.", 35 | StudioLogcatBelowChipmunkParser(includeDateTime = false, includePidTid = false, includePackageName = false, includeTag = true) 36 | ), 37 | Arguments.of( 38 | "? I: Tried to unregister apexservice, but there is about to be a client.", 39 | StudioLogcatBelowChipmunkParser(includeDateTime = false, includePidTid = false, includePackageName = true, includeTag = false) 40 | ), 41 | Arguments.of( 42 | "? I/servicemanager: Tried to unregister apexservice, but there is about to be a client.", 43 | StudioLogcatBelowChipmunkParser(includeDateTime = false, includePidTid = false, includePackageName = true, includeTag = true) 44 | ), 45 | Arguments.of( 46 | "178-178 I: Tried to unregister apexservice, but there is about to be a client.", 47 | StudioLogcatBelowChipmunkParser(includeDateTime = false, includePidTid = true, includePackageName = false, includeTag = false) 48 | ), 49 | Arguments.of( 50 | "178-178 I/servicemanager: Tried to unregister apexservice, but there is about to be a client.", 51 | StudioLogcatBelowChipmunkParser(includeDateTime = false, includePidTid = true, includePackageName = false, includeTag = true) 52 | ), 53 | Arguments.of( 54 | "178-178/? I: Tried to unregister apexservice, but there is about to be a client.", 55 | StudioLogcatBelowChipmunkParser(includeDateTime = false, includePidTid = true, includePackageName = true, includeTag = false) 56 | ), 57 | Arguments.of( 58 | "178-178/? I/servicemanager: Tried to unregister apexservice, but there is about to be a client.", 59 | StudioLogcatBelowChipmunkParser(includeDateTime = false, includePidTid = true, includePackageName = true, includeTag = true) 60 | ), 61 | Arguments.of( 62 | "2022-10-24 08:50:35.786 I: Tried to unregister apexservice, but there is about to be a client.", 63 | StudioLogcatBelowChipmunkParser(includeDateTime = true, includePidTid = false, includePackageName = false, includeTag = false) 64 | ), 65 | Arguments.of( 66 | "2022-10-24 08:50:35.786 I/servicemanager: Tried to unregister apexservice, but there is about to be a client.", 67 | StudioLogcatBelowChipmunkParser(includeDateTime = true, includePidTid = false, includePackageName = false, includeTag = true) 68 | ), 69 | Arguments.of( 70 | "2022-10-24 09:31:55.786 ? I: Tried to unregister apexservice, but there is about to be a client.", 71 | StudioLogcatBelowChipmunkParser(includeDateTime = true, includePidTid = false, includePackageName = true, includeTag = false) 72 | ), 73 | Arguments.of( 74 | "2022-10-24 09:31:55.786 ? I/servicemanager: Tried to unregister apexservice, but there is about to be a client.", 75 | StudioLogcatBelowChipmunkParser(includeDateTime = true, includePidTid = false, includePackageName = true, includeTag = true) 76 | ), 77 | Arguments.of( 78 | "2022-10-24 09:31:55.786 178-178 I: Tried to unregister apexservice, but there is about to be a client.", 79 | StudioLogcatBelowChipmunkParser(includeDateTime = true, includePidTid = true, includePackageName = false, includeTag = false) 80 | ), 81 | Arguments.of( 82 | "2022-10-24 09:31:55.786 178-178 I/servicemanager: Tried to unregister apexservice, but there is about to be a client.", 83 | StudioLogcatBelowChipmunkParser(includeDateTime = true, includePidTid = true, includePackageName = false, includeTag = true) 84 | ), 85 | Arguments.of( 86 | "2022-10-24 09:31:55.786 178-178/? I: Tried to unregister apexservice, but there is about to be a client.", 87 | StudioLogcatBelowChipmunkParser(includeDateTime = true, includePidTid = true, includePackageName = true, includeTag = false) 88 | ), 89 | Arguments.of( 90 | "2022-10-24 09:31:55.786 178-178/? I/servicemanager: Tried to unregister apexservice, but there is about to be a client.", 91 | StudioLogcatBelowChipmunkParser(includeDateTime = true, includePidTid = true, includePackageName = true, includeTag = true) 92 | ), 93 | ) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/TextFilterView.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class) 2 | 3 | package com.jerryjeon.logjerry.ui 4 | 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.selection.selectable 8 | import androidx.compose.material.* 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.ArrowDropDown 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.ExperimentalComposeUiApi 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.graphics.Color 16 | import androidx.compose.ui.input.key.* 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.ui.unit.sp 20 | import com.jerryjeon.logjerry.filter.TextFilter 21 | import com.jerryjeon.logjerry.filter.TextFilterType 22 | import com.jerryjeon.logjerry.table.ColumnType 23 | 24 | @Composable 25 | fun TextFilterView( 26 | addFilter: (TextFilter) -> Unit, 27 | dismiss: () -> Unit 28 | ) { 29 | Column { 30 | CompositionLocalProvider( 31 | LocalTextStyle provides LocalTextStyle.current.copy(fontSize = 12.sp) 32 | ) { 33 | AddTextFilterView(addFilter, dismiss) 34 | } 35 | } 36 | } 37 | 38 | @Composable 39 | private fun AddTextFilterView( 40 | addFilter: (TextFilter) -> Unit, 41 | dismiss: () -> Unit, 42 | ) { 43 | var text by remember { mutableStateOf("") } 44 | val columnTypeState = remember { mutableStateOf(ColumnType.Log) } 45 | val radioOptions = TextFilterType.values().map { it.name } 46 | val selectedOption = remember { mutableStateOf(radioOptions[0]) } 47 | Column { 48 | Row( 49 | modifier = Modifier.onPreviewKeyEvent { 50 | when { 51 | it.key == Key.Escape && it.type == KeyEventType.KeyDown -> { 52 | text = "" 53 | dismiss() 54 | true 55 | } 56 | it.key == Key.Enter && it.type == KeyEventType.KeyDown -> { 57 | if (text.isNotBlank()) { 58 | addFilter(TextFilter(columnTypeState.value, TextFilterType.valueOf(selectedOption.value), text)) 59 | text = "" 60 | } 61 | true 62 | } 63 | 64 | else -> false 65 | } 66 | } 67 | ) { 68 | Column { 69 | radioOptions.forEach { text -> 70 | Row( 71 | Modifier 72 | .padding(all = 8.dp) 73 | .selectable( 74 | selected = (text == selectedOption.value), 75 | onClick = { selectedOption.value = text } 76 | ), 77 | verticalAlignment = Alignment.CenterVertically 78 | ) { 79 | RadioButton( 80 | selected = (text == selectedOption.value), 81 | onClick = null 82 | ) 83 | Text( 84 | text = text, 85 | modifier = Modifier.padding(start = 8.dp), 86 | color = if (text == "Include") MaterialTheme.colors.secondary else MaterialTheme.colors.error 87 | ) 88 | } 89 | } 90 | } 91 | Spacer(Modifier.width(8.dp)) 92 | Column { 93 | TextField( 94 | modifier = Modifier.height(60.dp), 95 | value = text, 96 | onValueChange = { 97 | text = it 98 | }, 99 | singleLine = true, 100 | leadingIcon = { 101 | SelectColumnTypeView(columnTypeState) 102 | }, 103 | colors = TextFieldDefaults.textFieldColors( 104 | backgroundColor = Color.Transparent 105 | ) 106 | ) 107 | } 108 | } 109 | 110 | Row(modifier = Modifier.align(Alignment.End)) { 111 | TextButton( 112 | onClick = { 113 | dismiss() 114 | text = "" 115 | } 116 | ) { 117 | Text("Cancel") 118 | } 119 | TextButton( 120 | onClick = { 121 | addFilter(TextFilter(columnTypeState.value, TextFilterType.valueOf(selectedOption.value), text)) 122 | dismiss() 123 | text = "" 124 | } 125 | ) { 126 | Text("Ok") 127 | } 128 | } 129 | } 130 | } 131 | 132 | @Composable 133 | private fun SelectColumnTypeView( 134 | columnTypeState: MutableState 135 | ) { 136 | val allowedColumnTypes = listOf(ColumnType.Log, ColumnType.Tag, ColumnType.PackageName) 137 | var expanded by remember { mutableStateOf(false) } 138 | var columnType by columnTypeState 139 | Box(Modifier) { 140 | Row( 141 | modifier = Modifier.fillMaxHeight() 142 | .clickable(onClick = { expanded = true }) 143 | .padding(start = 8.dp, top = 8.dp, bottom = 8.dp) 144 | ) { 145 | Text( 146 | columnType.name, 147 | modifier = Modifier.wrapContentWidth().align(Alignment.CenterVertically), 148 | textAlign = TextAlign.Center 149 | ) 150 | Spacer(Modifier.width(4.dp)) 151 | Icon(Icons.Default.ArrowDropDown, "Column types", modifier = Modifier.align(Alignment.CenterVertically)) 152 | } 153 | DropdownMenu(expanded, onDismissRequest = { expanded = false }) { 154 | allowedColumnTypes.forEach { 155 | DropdownMenuItem(onClick = { 156 | columnType = it 157 | expanded = false 158 | }) { 159 | Text(text = it.name) 160 | } 161 | } 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MSYS* | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/logview/LogAnnotation.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.logview 2 | 3 | import androidx.compose.ui.text.AnnotatedString 4 | import androidx.compose.ui.text.SpanStyle 5 | import com.jerryjeon.logjerry.detector.DataClassDetection 6 | import com.jerryjeon.logjerry.detector.Detection 7 | import com.jerryjeon.logjerry.detector.Detector 8 | import com.jerryjeon.logjerry.detector.JsonDetection 9 | import com.jerryjeon.logjerry.log.Log 10 | import com.jerryjeon.logjerry.log.LogContent 11 | import com.jerryjeon.logjerry.log.LogContentView 12 | import kotlinx.serialization.json.Json 13 | import kotlinx.serialization.json.JsonObject 14 | 15 | object LogAnnotation { 16 | val json: Json = Json { prettyPrint = true } 17 | 18 | fun separateAnnotationStrings(log: Log, detectionResults: List): List { 19 | val sortedDetections = detectionResults.sortedBy { it.range.first } 20 | val overlapRemovedDetections = removeOverlappingDetections(sortedDetections) 21 | val originalLog = log.log 22 | 23 | var lastEnded = 0 24 | val logContents = mutableListOf() 25 | overlapRemovedDetections.forEach { 26 | val newStart = it.range.first 27 | val newEnd = it.range.last 28 | // Assume that there are no overlapping areas. 29 | when (it) { 30 | is JsonDetection -> { 31 | if (lastEnded != newStart) { 32 | logContents.add(LogContent.Text(originalLog.substring(lastEnded, newStart))) 33 | } 34 | logContents.add( 35 | LogContent.Json( 36 | json.encodeToString(JsonObject.serializer(), it.json) 37 | ) 38 | ) 39 | lastEnded = newEnd + 1 40 | } 41 | 42 | is DataClassDetection -> { 43 | if (lastEnded != newStart) { 44 | logContents.add(LogContent.Text(originalLog.substring(lastEnded, newStart))) 45 | } 46 | logContents.add(LogContent.DataClass(prettifyDataClass(it.map))) 47 | lastEnded = newEnd + 1 48 | } 49 | } 50 | } 51 | if (lastEnded < originalLog.length) { 52 | logContents.add(LogContent.Text(originalLog.substring(lastEnded))) 53 | } 54 | return logContents 55 | } 56 | 57 | private fun removeOverlappingDetections(detections: List): List { 58 | // Sort the detections by start range 59 | val sortedDetections = detections.sortedBy { it.range.first } 60 | .filter { it is JsonDetection || it is DataClassDetection } // Only consider Json and DataClass detections 61 | // TODO Refactor to use shownAsBlock field in detector 62 | 63 | val result = mutableListOf() 64 | var lastEnd = -1 65 | 66 | for (detection in sortedDetections) { 67 | // If this detection doesn't overlap with the previous one, add it to the result 68 | if (detection.range.first > lastEnd) { 69 | result.add(detection) 70 | lastEnd = detection.range.last 71 | } else if (detection is JsonDetection && result.last() is DataClassDetection) { 72 | // If current detection is of type JsonDetection and the last added is of type DataClassDetection, replace the last one 73 | result.removeAt(result.size - 1) 74 | result.add(detection) 75 | lastEnd = detection.range.last 76 | } 77 | } 78 | 79 | return result 80 | } 81 | 82 | fun annotate(log: Log, logContents: List, detectors: List>): List { 83 | val result = logContents.map { logContent -> 84 | when (logContent) { 85 | is LogContent.Json, is LogContent.DataClass -> { 86 | val initial = AnnotatedString.Builder(logContent.text) 87 | val newDetections = detectors.filter { !it.shownAsBlock }.flatMap { detection -> 88 | detection.detect(logContent.text, log.index) 89 | } 90 | 91 | val builder = newDetections.fold(initial) { acc, next -> 92 | acc.apply { 93 | addStyle( 94 | SpanStyle(), 95 | next.range.first, 96 | next.range.last + 1 97 | ) 98 | } 99 | } 100 | val lineCount = logContent.text.lines().size 101 | LogContentView.Block( 102 | if(logContent is LogContent.Json) "JSON" else "Kotlin Data Class", 103 | builder.toAnnotatedString(), 104 | JsonDetection.detectedStyle.background, 105 | lineCount 106 | ) 107 | } 108 | is LogContent.Text -> { 109 | val initial = AnnotatedString.Builder(logContent.text) 110 | val newDetections = detectors.filter { !it.shownAsBlock }.flatMap { detection -> 111 | detection.detect(logContent.text, log.index) 112 | } 113 | 114 | val builder = newDetections.fold(initial) { acc, next -> 115 | acc.apply { 116 | addStyle( 117 | next.style, 118 | next.range.first, 119 | next.range.last 120 | ) 121 | } 122 | } 123 | LogContentView.Simple(builder.toAnnotatedString()) 124 | } 125 | } 126 | } 127 | 128 | return result 129 | } 130 | } 131 | 132 | private fun prettifyDataClass(parsedData: Map, indent: String = ""): String { 133 | val builder = StringBuilder() 134 | 135 | builder.append("${parsedData["class"]}(\n") 136 | for ((key, value) in parsedData.filterKeys { it != "class" }) { 137 | builder.append("$indent $key=") 138 | if (value is Map<*, *>) { 139 | @Suppress("UNCHECKED_CAST") 140 | builder.append(prettifyDataClass(value as Map, "$indent ")) 141 | } else { 142 | builder.append(value) 143 | } 144 | builder.append("\n") 145 | } 146 | builder.append("$indent)") 147 | 148 | return builder.toString() 149 | } 150 | 151 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/MarkDialog.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) 2 | 3 | package com.jerryjeon.logjerry.ui 4 | 5 | import androidx.compose.foundation.* 6 | import androidx.compose.foundation.layout.* 7 | import androidx.compose.foundation.shape.CircleShape 8 | import androidx.compose.material.* 9 | import androidx.compose.material.icons.Icons 10 | import androidx.compose.material.icons.filled.Check 11 | import androidx.compose.runtime.* 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.ExperimentalComposeUiApi 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.focus.FocusRequester 16 | import androidx.compose.ui.focus.focusRequester 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.input.key.Key 19 | import androidx.compose.ui.input.key.KeyEventType 20 | import androidx.compose.ui.input.key.key 21 | import androidx.compose.ui.input.key.type 22 | import androidx.compose.ui.unit.dp 23 | import androidx.compose.ui.window.Dialog 24 | import androidx.compose.ui.window.DialogState 25 | import com.jerryjeon.logjerry.logview.RefinedLog 26 | import com.jerryjeon.logjerry.mark.LogMark 27 | import com.jerryjeon.logjerry.util.isCtrlOrMetaPressed 28 | 29 | @Composable 30 | fun MarkDialog( 31 | showMarkDialog: MutableState, 32 | setMark: (logMark: LogMark) -> Unit 33 | ) { 34 | val focusRequester = remember { FocusRequester() } 35 | 36 | val targetLog = showMarkDialog.value 37 | if (targetLog != null) { 38 | var note by remember { mutableStateOf(targetLog.mark?.note ?: "") } 39 | val colors = listOf( 40 | Color(0xFFFDF4F5), 41 | Color(0xFFE8A0BF), 42 | Color(0xFFBA90C6), 43 | Color(0xFFC0DBEA), 44 | Color(0xFFFFF2CC), 45 | Color(0xFFFFD966), 46 | ) 47 | var selectedColorIndex by remember { 48 | mutableStateOf(colors.indexOfFirst { it == targetLog.mark?.color }.coerceAtLeast(0)) 49 | } 50 | val cancelFunction = { 51 | note = "" 52 | showMarkDialog.value = null 53 | } 54 | val okFunction = { 55 | setMark(LogMark(targetLog.log, note, colors[selectedColorIndex])) 56 | note = "" 57 | showMarkDialog.value = null 58 | } 59 | Dialog( 60 | onCloseRequest = { showMarkDialog.value = null }, 61 | title = "Mark a row", 62 | state = DialogState(width = 400.dp, height = 600.dp), 63 | onPreviewKeyEvent = { keyEvent -> 64 | when { 65 | keyEvent.key == Key.Escape && keyEvent.type == KeyEventType.KeyDown -> { 66 | cancelFunction() 67 | true 68 | } 69 | keyEvent.isCtrlOrMetaPressed && keyEvent.key == Key.W && keyEvent.type == KeyEventType.KeyDown -> { 70 | cancelFunction() 71 | true 72 | } 73 | keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown -> { 74 | okFunction() 75 | true 76 | } 77 | keyEvent.isCtrlOrMetaPressed && keyEvent.key == Key.DirectionRight && keyEvent.type == KeyEventType.KeyDown -> { 78 | if (selectedColorIndex >= colors.size - 1) { 79 | selectedColorIndex = 0 80 | } else { 81 | selectedColorIndex++ 82 | } 83 | true 84 | } 85 | keyEvent.isCtrlOrMetaPressed && keyEvent.key == Key.DirectionLeft && keyEvent.type == KeyEventType.KeyDown -> { 86 | if (selectedColorIndex <= 0) { 87 | selectedColorIndex = colors.size - 1 88 | } else { 89 | selectedColorIndex-- 90 | } 91 | true 92 | } 93 | else -> { 94 | false 95 | } 96 | } 97 | }, 98 | resizable = false 99 | ) { 100 | Surface( 101 | modifier = Modifier.focusRequester(focusRequester), 102 | color = MaterialTheme.colors.surface, 103 | contentColor = MaterialTheme.colors.onSurface 104 | ) { 105 | Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { 106 | TextField( 107 | value = note, 108 | onValueChange = { note = it }, 109 | label = { Text("Note") } 110 | ) 111 | 112 | Spacer(Modifier.height(12.dp)) 113 | 114 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { 115 | colors.forEachIndexed { index, color -> 116 | val baseModifier = 117 | Modifier.size(40.dp).background(color, shape = CircleShape) 118 | .onClick { selectedColorIndex = index } 119 | val modifier = if (selectedColorIndex == index) { 120 | baseModifier.border(2.dp, MaterialTheme.colors.onSurface, shape = CircleShape) 121 | } else { 122 | baseModifier 123 | } 124 | Box(modifier = modifier) { 125 | if (selectedColorIndex == index) { 126 | Image( 127 | imageVector = Icons.Default.Check, 128 | contentDescription = "Checked color", 129 | modifier = Modifier.size(20.dp).align(Alignment.Center) 130 | ) 131 | } 132 | } 133 | } 134 | } 135 | 136 | Spacer(Modifier.height(12.dp)) 137 | 138 | Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { 139 | Button(onClick = cancelFunction) { 140 | Text("Cancel") 141 | } 142 | Spacer(androidx.compose.ui.Modifier.width(12.dp)) 143 | Button(onClick = okFunction) { 144 | Text("OK") 145 | } 146 | } 147 | } 148 | } 149 | } 150 | LaunchedEffect(targetLog) { 151 | focusRequester.requestFocus() 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/filter/FilterManager.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.filter 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import com.jerryjeon.logjerry.log.Priority 5 | import com.jerryjeon.logjerry.preferences.SortOrderPreferences 6 | import kotlinx.coroutines.CoroutineScope 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.* 9 | 10 | class FilterManager( 11 | originalLogsFlow: StateFlow>, 12 | defaultSortOption: SortOrderPreferences = SortOrderPreferences.load(), 13 | ) { 14 | private val filterScope = CoroutineScope(Dispatchers.Default) 15 | 16 | val textFiltersFlow: MutableStateFlow> = MutableStateFlow(emptyList()) 17 | val priorityFilterFlow: MutableStateFlow = MutableStateFlow(PriorityFilter(Priority.Verbose)) 18 | val hiddenLogIndicesFlow: MutableStateFlow = MutableStateFlow(HiddenFilter(emptySet())) 19 | val packageFiltersFlow = MutableStateFlow(PackageFilters(emptyList())) 20 | val tagFiltersFlow = MutableStateFlow(TagFilters(emptyList())) 21 | 22 | val packageFilterSortOptionFlow = MutableStateFlow( 23 | defaultSortOption.packageFilterSortOption to defaultSortOption.packageFilterSortOrder 24 | ) 25 | val tagFilterSortOptionFlow = MutableStateFlow( 26 | defaultSortOption.tagFilterSortOption to defaultSortOption.tagFilterSortOrder 27 | ) 28 | 29 | private val packageFilterComparator = packageFilterSortOptionFlow.map { 30 | val (option, order) = it 31 | when (option) { 32 | FilterSortOption.Frequency -> compareByDescending { it.frequency } 33 | FilterSortOption.Name -> compareByDescending { it.packageName } 34 | }.let { comparator -> 35 | if (order == SortOrder.Ascending) { 36 | comparator.reversed() 37 | } else { 38 | comparator 39 | } 40 | } 41 | } 42 | 43 | private val tagFilterComparator = tagFilterSortOptionFlow.map { it -> 44 | val (option, order) = it 45 | when (option) { 46 | FilterSortOption.Frequency -> compareByDescending { it.frequency } 47 | FilterSortOption.Name -> compareByDescending { it.tag } 48 | }.let { comparator -> 49 | if (order == SortOrder.Ascending) { 50 | comparator.reversed() 51 | } else { 52 | comparator 53 | } 54 | } 55 | } 56 | 57 | val sortedPackageFiltersFlow = packageFiltersFlow.combine(packageFilterComparator) { packageFilters, comparator -> 58 | packageFilters.copy(filters = packageFilters.filters.sortedWith(comparator)) 59 | } 60 | .stateIn(filterScope, SharingStarted.Eagerly, PackageFilters(emptyList())) 61 | val sortedTagFiltersFlow = tagFiltersFlow.combine(tagFilterComparator) { tagFilters, comparator -> 62 | tagFilters.copy(filters = tagFilters.filters.sortedWith(comparator)) 63 | } 64 | .stateIn(filterScope, SharingStarted.Eagerly, TagFilters(emptyList())) 65 | 66 | val filtersFlow = combine( 67 | textFiltersFlow, 68 | priorityFilterFlow, 69 | hiddenLogIndicesFlow, 70 | packageFiltersFlow, 71 | tagFiltersFlow, 72 | ) { textFilters, priorityFilter, hiddenLogIndices, packageFilters, tagFilters -> 73 | textFilters + listOf(priorityFilter, hiddenLogIndices) + packageFilters + tagFilters 74 | } 75 | 76 | init { 77 | originalLogsFlow.onEach { originalLogs -> 78 | val packageFilters = originalLogs.groupingBy { it.packageName }.eachCount() 79 | .map { (packageName, frequency) -> 80 | PackageFilter(packageName, frequency, true) 81 | } 82 | .let { PackageFilters(it) } 83 | packageFiltersFlow.value = packageFilters 84 | } 85 | .launchIn(filterScope) 86 | 87 | originalLogsFlow.onEach { originalLogs -> 88 | val tagFilters = originalLogs.groupingBy { it.tag }.eachCount() 89 | .map { (tag, frequency) -> 90 | TagFilter(tag, frequency, true) 91 | } 92 | .let { TagFilters(it) } 93 | tagFiltersFlow.value = tagFilters 94 | } 95 | .launchIn(filterScope) 96 | } 97 | 98 | fun addTextFilter(textFilter: TextFilter) { 99 | textFiltersFlow.value = textFiltersFlow.value + textFilter 100 | } 101 | 102 | fun removeTextFilter(textFilter: TextFilter) { 103 | textFiltersFlow.value = textFiltersFlow.value - textFilter 104 | } 105 | 106 | fun setPriorityFilter(priorityFilter: PriorityFilter) { 107 | this.priorityFilterFlow.value = priorityFilter 108 | } 109 | 110 | fun hide(logIndex: Int) { 111 | hiddenLogIndicesFlow.update { it.copy(hiddenLogIndices = it.hiddenLogIndices + logIndex) } 112 | } 113 | 114 | fun unhide(logIndex: Int) { 115 | hiddenLogIndicesFlow.update { it.copy(hiddenLogIndices = it.hiddenLogIndices - logIndex) } 116 | } 117 | 118 | fun togglePackageFilter(packageFilter: PackageFilter) { 119 | packageFiltersFlow.update { packageFilters -> 120 | packageFilters.copy( 121 | filters = packageFilters.filters.map { 122 | if (it.packageName == packageFilter.packageName) { 123 | it.copy(include = !it.include) 124 | } else { 125 | it 126 | } 127 | } 128 | ) 129 | } 130 | } 131 | 132 | fun setAllPackageFilter(include: Boolean) { 133 | packageFiltersFlow.update { packageFilters -> 134 | packageFilters.copy( 135 | filters = packageFilters.filters.map { 136 | it.copy(include = include) 137 | } 138 | ) 139 | } 140 | } 141 | 142 | fun toggleTagFilter(tagFilter: TagFilter) { 143 | tagFiltersFlow.update { tagFilters -> 144 | tagFilters.copy( 145 | filters = tagFilters.filters.map { 146 | if (it.tag == tagFilter.tag) { 147 | it.copy(include = !it.include) 148 | } else { 149 | it 150 | } 151 | } 152 | ) 153 | } 154 | } 155 | 156 | fun setAllTagFilter(include: Boolean) { 157 | tagFiltersFlow.update { tagFilters -> 158 | tagFilters.copy( 159 | filters = tagFilters.filters.map { 160 | it.copy(include = include) 161 | } 162 | ) 163 | } 164 | } 165 | 166 | fun setPackageFilterSortOption(option: FilterSortOption, order: SortOrder) { 167 | packageFilterSortOptionFlow.value = option to order 168 | SortOrderPreferences.save( 169 | SortOrderPreferences( 170 | tagFilterSortOptionFlow.value.first, 171 | tagFilterSortOptionFlow.value.second, 172 | option, 173 | order, 174 | ) 175 | ) 176 | } 177 | 178 | fun setTagFilterSortOption(option: FilterSortOption, order: SortOrder) { 179 | tagFilterSortOptionFlow.value = option to order 180 | SortOrderPreferences.save( 181 | SortOrderPreferences( 182 | option, 183 | order, 184 | packageFilterSortOptionFlow.value.first, 185 | packageFilterSortOptionFlow.value.second, 186 | ) 187 | ) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/StudioLogcatBelowChipmunkParser.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import java.time.LocalDate 5 | import java.time.LocalTime 6 | import java.util.concurrent.atomic.AtomicInteger 7 | 8 | data class StudioLogcatBelowChipmunkParser( 9 | // Log format configuration before AS Chipmunk version 10 | val includeDateTime: Boolean, 11 | val includePidTid: Boolean, 12 | val includePackageName: Boolean, 13 | val includeTag: Boolean 14 | ) : LogParser { 15 | 16 | private val number = AtomicInteger(1) 17 | override fun parse(rawLines: List): ParseResult { 18 | val logs = mutableListOf() 19 | val invalidSentences = mutableListOf>() 20 | var lastLog: Log? = null 21 | rawLines.forEachIndexed { index, s -> 22 | lastLog = try { 23 | val log = parseSingleLineLog(s) 24 | 25 | // Custom continuation 26 | if (log.log.startsWith("Cont(")) { 27 | lastLog?.let { 28 | it.copy(log = "${it.log}${log.log.substringAfter(") ")}") 29 | } ?: log 30 | } else { 31 | lastLog?.let { logs.add(it) } 32 | log 33 | } 34 | } catch (e: Exception) { 35 | val continuedLog = if (lastLog == null) { 36 | invalidSentences.add(index to s) 37 | return@forEachIndexed 38 | } else { 39 | lastLog!! 40 | } 41 | continuedLog.copy(log = "${continuedLog.log}\n$s") 42 | } 43 | } 44 | lastLog?.let { logs.add(it) } 45 | return ParseResult(logs, invalidSentences) 46 | } 47 | private fun parseSingleLineLog(raw: String): Log { 48 | var segmentCount = 5 49 | if (!includeDateTime) segmentCount -= 2 50 | if (!includePidTid && !includePackageName) segmentCount-- 51 | 52 | val split = raw.split(" ", limit = segmentCount) 53 | 54 | var currentIndex = 0 55 | 56 | val date: String? 57 | val time: String? 58 | if (includeDateTime) { 59 | date = split[currentIndex++] 60 | time = split[currentIndex++] 61 | } else { 62 | date = null 63 | time = null 64 | } 65 | 66 | val pid: Long? 67 | val tid: Long? 68 | val packageName: String? 69 | when { 70 | includePidTid && includePackageName -> { 71 | val thirdSegment = split[currentIndex++].split("-", "/") 72 | pid = thirdSegment[0].toLong() 73 | tid = thirdSegment[1].toLong() 74 | packageName = thirdSegment[2].takeIf { it != "?" } 75 | } 76 | includePidTid -> { 77 | val thirdSegment = split[currentIndex++].split("-") 78 | pid = thirdSegment[0].toLong() 79 | tid = thirdSegment[1].toLong() 80 | packageName = null 81 | } 82 | includePackageName -> { 83 | pid = null 84 | tid = null 85 | packageName = split[currentIndex++] 86 | } 87 | else -> { 88 | pid = null 89 | tid = null 90 | packageName = null 91 | } 92 | } 93 | 94 | val priorityText: String 95 | val tag: String? 96 | if (includeTag) { 97 | val fourthSegment = split[currentIndex++].split("/") 98 | priorityText = fourthSegment[0] 99 | tag = fourthSegment[1].removeSuffix(":") 100 | } else { 101 | priorityText = split[currentIndex++].removeSuffix(":") 102 | tag = null 103 | } 104 | 105 | val originalLog = split[currentIndex] 106 | 107 | return Log(number.getAndIncrement(), date, time, pid, tid, packageName, priorityText, tag, originalLog) 108 | } 109 | 110 | override fun toString(): String { 111 | return "DefaultParser(includeDateTime=$includeDateTime, includePidTid=$includePidTid, includePackageName=$includePackageName, includeTag=$includeTag)" 112 | } 113 | companion object : ParserFactory { 114 | 115 | private val priorityChars = setOf('V', 'D', 'I', 'W', 'E', 'A') 116 | 117 | private fun String.isPriority(): Boolean { 118 | return length == 1 && first() in priorityChars 119 | } 120 | override fun create(sample: String): LogParser? { 121 | try { 122 | val split = sample.split(" ", limit = 5) 123 | val iterator = split.listIterator() 124 | 125 | var currentToken = iterator.next() 126 | 127 | val includeDate = try { 128 | LocalDate.parse(currentToken) 129 | currentToken = iterator.next() 130 | true 131 | } catch (e: Exception) { 132 | false 133 | } 134 | 135 | val includeTime = try { 136 | LocalTime.parse(currentToken) 137 | currentToken = iterator.next() 138 | true 139 | } catch (e: Exception) { 140 | false 141 | } 142 | 143 | // Only supports both exist or not exist at all 144 | if (includeDate xor includeTime) return null 145 | 146 | val pidTidRegex = Regex("\\d*[-/]\\d*") 147 | val packageNameRegex = Regex("^([A-Za-z][A-Za-z\\d_]*\\.)+[A-Za-z][A-Za-z\\d_]*$") 148 | 149 | // pit-tid/packageName 150 | var includePidTid: Boolean = false 151 | var includePackageName: Boolean = false 152 | if (currentToken.contains("/")) { 153 | // both exist 154 | val tokens = currentToken.split("/") 155 | if (tokens[0].matches(pidTidRegex) && (tokens[1] == "?" || tokens[1].matches(packageNameRegex))) { 156 | includePidTid = true 157 | includePackageName = true 158 | currentToken = iterator.next() 159 | } 160 | } else { 161 | if (currentToken.matches(pidTidRegex)) { 162 | includePidTid = true 163 | includePackageName = false 164 | currentToken = iterator.next() 165 | } else if (currentToken == "?" || currentToken.matches(packageNameRegex)) { 166 | includePidTid = false 167 | includePackageName = true 168 | currentToken = iterator.next() 169 | } 170 | } 171 | 172 | var includeTag = false 173 | if (currentToken.contains("/")) { 174 | // both exist 175 | val tokens = currentToken.split("/") 176 | // Check what's faster: list and regex 177 | if (tokens[0].isPriority()) { 178 | includeTag = true 179 | } else { 180 | // invalid 181 | return null 182 | } 183 | } else if (currentToken.isPriority()) { 184 | includeTag = false 185 | } 186 | 187 | if (currentToken.last() != ':') { 188 | return null 189 | } 190 | 191 | if (!iterator.hasNext()) { 192 | return null 193 | } 194 | 195 | return StudioLogcatBelowChipmunkParser(includeDate, includePidTid, includePackageName, includeTag) 196 | } catch (e: Exception) { 197 | return null 198 | } 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/StudioLogcatAboveDolphinParser.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import java.time.LocalDate 5 | import java.time.LocalTime 6 | import java.util.concurrent.atomic.AtomicInteger 7 | 8 | data class StudioLogcatAboveDolphinParser( 9 | // Log format configuration after AS Dolphin version 10 | val includeDate: Boolean, 11 | val includeTime: Boolean, 12 | val includePid: Boolean, 13 | val includeTid: Boolean, 14 | val includeTag: Boolean, 15 | val includePackageName: Boolean 16 | ) : LogParser { 17 | 18 | private val number = AtomicInteger(1) 19 | 20 | override fun parse(rawLines: List): ParseResult { 21 | val logs = mutableListOf() 22 | val invalidSentences = mutableListOf>() 23 | var lastLog: Log? = null 24 | rawLines.forEachIndexed { index, s -> 25 | lastLog = try { 26 | val log = parseSingleLineLog(s) 27 | 28 | // Custom continuation 29 | if (log.log.startsWith("Cont(")) { 30 | lastLog?.let { 31 | it.copy(log = "${it.log}${log.log.substringAfter(") ")}") 32 | } ?: log 33 | } else { 34 | lastLog?.let { logs.add(it) } 35 | log 36 | } 37 | } catch (e: Exception) { 38 | val continuedLog = if (lastLog == null) { 39 | invalidSentences.add(index to s) 40 | return@forEachIndexed 41 | } else { 42 | lastLog!! 43 | } 44 | continuedLog.copy(log = "${continuedLog.log}\n$s") 45 | } 46 | } 47 | lastLog?.let { logs.add(it) } 48 | return ParseResult(logs, invalidSentences) 49 | } 50 | 51 | // The algorithm is inefficient. From my machine it's ok for 5000 lines. Improve later if there's an issue 52 | private fun parseSingleLineLog(raw: String): Log { 53 | val split = raw.split(" ").filter { it.isNotBlank() } 54 | 55 | var currentIndex = 0 56 | 57 | val date = if (includeDate) { 58 | split[currentIndex++] 59 | } else { 60 | null 61 | } 62 | 63 | val time = if(includeTime) { 64 | split[currentIndex++] 65 | } else { 66 | null 67 | } 68 | 69 | val pid: Long? 70 | val tid: Long? 71 | when { 72 | includePid && includeTid -> { 73 | val ids = split[currentIndex++].split("-") 74 | pid = ids[0].toLong() 75 | tid = ids[1].toLong() 76 | } 77 | includePid -> { 78 | pid = split[currentIndex++].toLong() 79 | tid = null 80 | } 81 | else -> { 82 | pid = null 83 | tid = null 84 | } 85 | } 86 | 87 | val tag = if (includeTag) { 88 | split[currentIndex++] 89 | } else { 90 | null 91 | } 92 | 93 | val packageName = if(includePackageName) { 94 | split[currentIndex++] 95 | } else { 96 | null 97 | } 98 | 99 | val priorityText = split[currentIndex++] 100 | 101 | val originalLog = split.drop(currentIndex).joinToString(separator = " ") 102 | 103 | return Log(number.getAndIncrement(), date, time, pid, tid, packageName, priorityText, tag, originalLog) 104 | } 105 | 106 | override fun toString(): String { 107 | return "StudioLogcatAboveDolphinParser(includeDate=$includeDate, includeTime=$includeTime, includePid=$includePid, includeTid=$includeTid, includeTag=$includeTag, includePackageName=$includePackageName, number=$number)" 108 | } 109 | 110 | companion object : ParserFactory { 111 | 112 | private val priorityChars = setOf('V', 'D', 'I', 'W', 'E', 'A') 113 | private val packageNameRegex = Regex("^([A-Za-z][A-Za-z\\d_]*\\.)+[A-Za-z][A-Za-z\\d_]*$") 114 | private val packageNameRegex2 = Regex("^pid-\\d*$") 115 | 116 | private fun String.isPriority(): Boolean { 117 | return length == 1 && first() in priorityChars 118 | } 119 | 120 | override fun create(sample: String): LogParser? { 121 | try { 122 | val split = sample.split(" ").filter { it.isNotBlank() } 123 | val iterator = split.listIterator() 124 | 125 | var currentToken = iterator.next() 126 | 127 | val includeDate = try { 128 | LocalDate.parse(currentToken) 129 | currentToken = iterator.next() 130 | true 131 | } catch (e: Exception) { 132 | false 133 | } 134 | 135 | val includeTime = try { 136 | LocalTime.parse(currentToken) 137 | currentToken = iterator.next() 138 | true 139 | } catch (e: Exception) { 140 | false 141 | } 142 | 143 | val includePid: Boolean? 144 | val includeTid: Boolean? 145 | when { 146 | currentToken.matches(Regex("\\d*")) -> { // only pid 147 | includePid = true 148 | includeTid = false 149 | currentToken = iterator.next() 150 | } 151 | currentToken.matches(Regex("\\d*-\\d*")) -> { // pid-tid 152 | includePid = true 153 | includeTid = true 154 | currentToken = iterator.next() 155 | } 156 | else -> { 157 | includePid = false 158 | includeTid = false 159 | } 160 | } 161 | 162 | if (currentToken.isPriority()) { 163 | return StudioLogcatAboveDolphinParser(includeDate, includeTime, includePid, includeTid, includeTag = false, includePackageName = false) 164 | } 165 | 166 | // Check package first, because for the tag there's no way to validate it 167 | 168 | // TODO Find more cleaner way 169 | if (currentToken.isPackageName()) { 170 | currentToken = iterator.next() 171 | return if (currentToken.isPriority() && iterator.hasNext()) { 172 | StudioLogcatAboveDolphinParser(includeDate, includeTime, includePid, includeTid, includeTag = false, includePackageName = true) 173 | } else { 174 | null 175 | } 176 | } else { 177 | currentToken = iterator.next() 178 | if (currentToken.isPriority()) { 179 | return if (currentToken.isPriority() && iterator.hasNext()) { 180 | StudioLogcatAboveDolphinParser(includeDate, includeTime, includePid, includeTid, includeTag = true, includePackageName = false) 181 | } else { 182 | null 183 | } 184 | } else if (currentToken.isPackageName()) { 185 | currentToken = iterator.next() 186 | return if (currentToken.isPriority() && iterator.hasNext()) { 187 | StudioLogcatAboveDolphinParser(includeDate, includeTime, includePid, includeTid, includeTag = true, includePackageName = true) 188 | } else { 189 | null 190 | } 191 | } 192 | } 193 | 194 | return null 195 | } catch (e: Exception) { 196 | return null 197 | } 198 | } 199 | 200 | private fun String.isPackageName(): Boolean { 201 | return this == "system_process" 202 | || this.matches(packageNameRegex) 203 | || this.matches(packageNameRegex2) 204 | } 205 | } 206 | 207 | } 208 | 209 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/parse/AdbLogcatDefaultFormatParser.kt: -------------------------------------------------------------------------------- 1 | package com.jerryjeon.logjerry.parse 2 | 3 | import com.jerryjeon.logjerry.log.Log 4 | import java.time.LocalDate 5 | import java.time.LocalTime 6 | import java.util.concurrent.atomic.AtomicInteger 7 | 8 | data class AdbLogcatDefaultFormatParser( 9 | val includeDate: Boolean, 10 | val includeTime: Boolean, 11 | val includePid: Boolean, 12 | val includeTid: Boolean, 13 | val includeTag: Boolean, 14 | val includePackageName: Boolean 15 | ) : LogParser { 16 | 17 | private val number = AtomicInteger(1) 18 | 19 | override fun parse(rawLines: List): ParseResult { 20 | val logs = mutableListOf() 21 | val invalidSentences = mutableListOf>() 22 | var lastLog: Log? = null 23 | rawLines.forEachIndexed { index, s -> 24 | lastLog = try { 25 | val log = parseSingleLineLog(s) 26 | 27 | // Custom continuation 28 | if (log.log.startsWith("Cont(")) { 29 | lastLog?.let { 30 | it.copy(log = "${it.log}${log.log.substringAfter(") ")}") 31 | } ?: log 32 | } else { 33 | lastLog?.let { logs.add(it) } 34 | log 35 | } 36 | } catch (e: Exception) { 37 | val continuedLog = if (lastLog == null) { 38 | invalidSentences.add(index to s) 39 | return@forEachIndexed 40 | } else { 41 | lastLog!! 42 | } 43 | continuedLog.copy(log = "${continuedLog.log}\n$s") 44 | } 45 | } 46 | lastLog?.let { logs.add(it) } 47 | return ParseResult(logs, invalidSentences) 48 | } 49 | 50 | // The algorithm is inefficient. From my machine it's ok for 5000 lines. Improve later if there's an issue 51 | private fun parseSingleLineLog(raw: String): Log { 52 | val split = raw.split(" ").filter { it.isNotBlank() } 53 | 54 | var currentIndex = 0 55 | 56 | val date = if (includeDate) { 57 | split[currentIndex++] 58 | } else { 59 | null 60 | } 61 | 62 | val time = if (includeTime) { 63 | split[currentIndex++] 64 | } else { 65 | null 66 | } 67 | 68 | val pid: Long? 69 | val tid: Long? 70 | when { 71 | includePid && includeTid -> { 72 | val ids = split[currentIndex++].split("-") 73 | pid = ids[0].toLong() 74 | tid = ids[1].toLong() 75 | } 76 | 77 | includePid -> { 78 | pid = split[currentIndex++].toLong() 79 | tid = null 80 | } 81 | 82 | else -> { 83 | pid = null 84 | tid = null 85 | } 86 | } 87 | 88 | val tag = if (includeTag) { 89 | split[currentIndex++] 90 | } else { 91 | null 92 | } 93 | 94 | val packageName = if (includePackageName) { 95 | split[currentIndex++] 96 | } else { 97 | null 98 | } 99 | 100 | val priorityText = split[currentIndex++] 101 | 102 | val originalLog = split.drop(currentIndex).joinToString(separator = " ") 103 | 104 | return Log(number.getAndIncrement(), date, time, pid, tid, packageName, priorityText, tag, originalLog) 105 | } 106 | 107 | override fun toString(): String { 108 | return "AdbLogcatDefaultFormatParser(includeDate=$includeDate, includeTime=$includeTime, includePid=$includePid, includeTid=$includeTid, includeTag=$includeTag, includePackageName=$includePackageName, number=$number)" 109 | } 110 | 111 | companion object : ParserFactory { 112 | 113 | private val priorityChars = setOf('V', 'D', 'I', 'W', 'E', 'A') 114 | private val packageNameRegex = Regex("^([A-Za-z][A-Za-z\\d_]*\\.)+[A-Za-z][A-Za-z\\d_]*$") 115 | private val packageNameRegex2 = Regex("^pid-\\d*$") 116 | 117 | private fun String.isPriority(): Boolean { 118 | return length == 1 && first() in priorityChars 119 | } 120 | 121 | override fun create(sample: String): LogParser? { 122 | try { 123 | val split = sample.split(" ").filter { it.isNotBlank() } 124 | val iterator = split.listIterator() 125 | 126 | var currentToken = iterator.next() 127 | 128 | val includeDate = try { 129 | LocalDate.parse(currentToken) 130 | currentToken = iterator.next() 131 | true 132 | } catch (e: Exception) { 133 | val yearAdded = "${LocalDate.now().year}-$currentToken" 134 | LocalDate.parse(yearAdded) 135 | currentToken = iterator.next() 136 | true 137 | } catch (e: Exception) { 138 | false 139 | } 140 | 141 | val includeTime = try { 142 | LocalTime.parse(currentToken) 143 | currentToken = iterator.next() 144 | true 145 | } catch (e: Exception) { 146 | false 147 | } 148 | 149 | val includePid: Boolean? 150 | val includeTid: Boolean? 151 | when { 152 | currentToken.matches(Regex("\\d*")) -> { // only pid 153 | includePid = true 154 | includeTid = false 155 | currentToken = iterator.next() 156 | } 157 | 158 | currentToken.matches(Regex("\\d*-\\d*")) -> { // pid-tid 159 | includePid = true 160 | includeTid = true 161 | currentToken = iterator.next() 162 | } 163 | 164 | else -> { 165 | includePid = false 166 | includeTid = false 167 | } 168 | } 169 | 170 | if (currentToken.isPriority()) { 171 | return AdbLogcatDefaultFormatParser( 172 | includeDate, 173 | includeTime, 174 | includePid, 175 | includeTid, 176 | includeTag = false, 177 | includePackageName = false 178 | ) 179 | } 180 | 181 | // Check package first, because for the tag there's no way to validate it 182 | 183 | // TODO Find more cleaner way 184 | if (currentToken.isPackageName()) { 185 | currentToken = iterator.next() 186 | return if (currentToken.isPriority() && iterator.hasNext()) { 187 | AdbLogcatDefaultFormatParser( 188 | includeDate, 189 | includeTime, 190 | includePid, 191 | includeTid, 192 | includeTag = false, 193 | includePackageName = true 194 | ) 195 | } else { 196 | null 197 | } 198 | } else { 199 | currentToken = iterator.next() 200 | if (currentToken.isPriority()) { 201 | return if (currentToken.isPriority() && iterator.hasNext()) { 202 | AdbLogcatDefaultFormatParser( 203 | includeDate, 204 | includeTime, 205 | includePid, 206 | includeTid, 207 | includeTag = true, 208 | includePackageName = false 209 | ) 210 | } else { 211 | null 212 | } 213 | } else if (currentToken.isPackageName()) { 214 | currentToken = iterator.next() 215 | return if (currentToken.isPriority() && iterator.hasNext()) { 216 | AdbLogcatDefaultFormatParser( 217 | includeDate, 218 | includeTime, 219 | includePid, 220 | includeTid, 221 | includeTag = true, 222 | includePackageName = true 223 | ) 224 | } else { 225 | null 226 | } 227 | } 228 | } 229 | 230 | return null 231 | } catch (e: Exception) { 232 | return null 233 | } 234 | } 235 | 236 | private fun String.isPackageName(): Boolean { 237 | return this == "system_process" || 238 | this.matches(packageNameRegex) || 239 | this.matches(packageNameRegex2) 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/preferences/PreferencesViewModel.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalSerializationApi::class, ExperimentalSerializationApi::class) 2 | 3 | package com.jerryjeon.logjerry.preferences 4 | 5 | import androidx.compose.ui.graphics.Color 6 | import androidx.compose.ui.graphics.toArgb 7 | import com.jerryjeon.logjerry.log.Priority 8 | import com.jerryjeon.logjerry.table.ColumnInfo 9 | import com.jerryjeon.logjerry.table.Header 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.flow.* 13 | import kotlinx.coroutines.launch 14 | import kotlinx.serialization.ExperimentalSerializationApi 15 | import kotlinx.serialization.json.Json 16 | import kotlinx.serialization.json.decodeFromStream 17 | import kotlinx.serialization.json.encodeToStream 18 | 19 | @OptIn(ExperimentalSerializationApi::class) 20 | class PreferencesViewModel { 21 | private val preferenceScope = CoroutineScope(Dispatchers.Default) 22 | 23 | // TODO not to read on the main thread 24 | val preferencesFlow = MutableStateFlow( 25 | try { 26 | Preferences.file.inputStream().use { json.decodeFromStream(it) } 27 | } catch (e: Exception) { 28 | Preferences.default 29 | } 30 | ) 31 | 32 | val colorThemeFlow = MutableStateFlow(preferencesFlow.value.colorTheme) 33 | fun changeColorTheme(colorTheme: ColorTheme) { 34 | colorThemeFlow.value = colorTheme 35 | } 36 | 37 | //region white theme 38 | val whiteColorStrings = MutableStateFlow(preferencesFlow.value.lightColorByPriority.toColorStrings()) 39 | val whiteValidColorsByPriority = whiteColorStrings.map { 40 | it.mapValues { (_, color) -> 41 | try { 42 | Color(parseColor(color)) 43 | } catch (e: Exception) { 44 | null 45 | } 46 | } 47 | } 48 | .stateIn(preferenceScope, SharingStarted.Lazily, Priority.values().associateWith { Color.Black }) 49 | val whiteBackgroundColorString = MutableStateFlow(preferencesFlow.value.lightBackgroundColor.toColorString()) 50 | val whiteBackgroundValidColor = whiteBackgroundColorString.map { 51 | try { 52 | Color(parseColor(it)) 53 | } catch (e: Exception) { 54 | null 55 | } 56 | } 57 | .stateIn(preferenceScope, SharingStarted.Lazily, Preferences.default.lightBackgroundColor) 58 | 59 | fun changeWhiteColorString(priority: Priority, colorString: String) { 60 | whiteColorStrings.value = whiteColorStrings.value + (priority to colorString) 61 | } 62 | 63 | fun changeWhiteBackgroundColor(colorString: String) { 64 | whiteBackgroundColorString.value = colorString 65 | } 66 | // endregion 67 | 68 | //region dark theme 69 | val darkColorStrings = MutableStateFlow(preferencesFlow.value.darkColorByPriority.toColorStrings()) 70 | val darkValidColorsByPriority = darkColorStrings.map { 71 | it.mapValues { (_, color) -> 72 | try { 73 | Color(parseColor(color)) 74 | } catch (e: Exception) { 75 | null 76 | } 77 | } 78 | } 79 | .stateIn(preferenceScope, SharingStarted.Lazily, Priority.values().associateWith { Color.Black }) 80 | val darkBackgroundColorString = MutableStateFlow(preferencesFlow.value.darkBackgroundColor.toColorString()) 81 | val darkBackgroundValidColor = darkBackgroundColorString.map { 82 | try { 83 | Color(parseColor(it)) 84 | } catch (e: Exception) { 85 | null 86 | } 87 | } 88 | .stateIn(preferenceScope, SharingStarted.Lazily, Preferences.default.darkBackgroundColor) 89 | 90 | fun changeDarkColorString(priority: Priority, colorString: String) { 91 | darkColorStrings.value = darkColorStrings.value + (priority to colorString) 92 | } 93 | 94 | fun changeDarkBackgroundColor(colorString: String) { 95 | darkBackgroundColorString.value = colorString 96 | } 97 | //endregion 98 | 99 | var saveEnabled = whiteValidColorsByPriority.map { map -> map.values.all { it != null } } 100 | .zip(darkValidColorsByPriority.map { map -> map.values.all { it != null } }) { b1, b2 -> b1 && b2 } 101 | .stateIn(preferenceScope, SharingStarted.Lazily, false) 102 | 103 | val showExceptionDetection = MutableStateFlow(preferencesFlow.value.showExceptionDetection) 104 | val showInvalidSentences = MutableStateFlow(preferencesFlow.value.showInvalidSentences) 105 | 106 | fun changeShowExceptionDetection(showExceptionDetection: Boolean) { 107 | this.showExceptionDetection.value = showExceptionDetection 108 | } 109 | 110 | fun changeShowInvalidSentences(showInvalidSentences: Boolean) { 111 | this.showInvalidSentences.value = showInvalidSentences 112 | } 113 | 114 | val jsonPreviewSizeString = MutableStateFlow(preferencesFlow.value.jsonPreviewSize.toString()) 115 | val jsonPreviewSize = jsonPreviewSizeString.map { it.toIntOrNull() } 116 | .stateIn(preferenceScope, SharingStarted.Lazily, preferencesFlow.value.jsonPreviewSize) 117 | 118 | fun changeJsonPreviewSize(jsonPreviewSize: String) { 119 | this.jsonPreviewSizeString.value = jsonPreviewSize 120 | } 121 | 122 | val maximizeWindow = MutableStateFlow(preferencesFlow.value.windowSizeWhenOpened == null) 123 | fun changeMaximizeWindow(maximizeWindow: Boolean) { 124 | this.maximizeWindow.value = maximizeWindow 125 | } 126 | 127 | val widthWhenOpenedString = MutableStateFlow(preferencesFlow.value.windowSizeWhenOpened?.width?.toString() ?: "900") 128 | val widthWhenOpened = widthWhenOpenedString.map { it.toIntOrNull() } 129 | .stateIn(preferenceScope, SharingStarted.Lazily, preferencesFlow.value.windowSizeWhenOpened?.width) 130 | 131 | fun changeWidthWhenOpened(widthWhenOpened: String) { 132 | this.widthWhenOpenedString.value = widthWhenOpened 133 | } 134 | 135 | val heightWhenOpenedString = MutableStateFlow(preferencesFlow.value.windowSizeWhenOpened?.height?.toString() ?: "900") 136 | val heightWhenOpened = heightWhenOpenedString.map { it.toIntOrNull() } 137 | .stateIn(preferenceScope, SharingStarted.Lazily, preferencesFlow.value.windowSizeWhenOpened?.height) 138 | 139 | fun changeHeightWhenOpened(heightWhenOpened: String) { 140 | this.heightWhenOpenedString.value = heightWhenOpened 141 | } 142 | 143 | fun save() { 144 | val whiteSavingColors = whiteValidColorsByPriority.value 145 | val whiteSavingBackgroundColor = whiteBackgroundValidColor.value 146 | val darkSavingColors = darkValidColorsByPriority.value 147 | val darkSavingBackgroundColor = darkBackgroundValidColor.value 148 | if (whiteSavingColors.any { (_, color) -> color == null } || 149 | whiteSavingBackgroundColor == null || 150 | darkSavingColors.any { (_, color) -> color == null } || 151 | darkSavingBackgroundColor == null 152 | ) { 153 | return 154 | } 155 | val jsonPreviewSizeValue = jsonPreviewSize.value ?: return 156 | 157 | val maximizeWindow = maximizeWindow.value 158 | val windowSizeWhenOpened = if (maximizeWindow) { 159 | null 160 | } else { 161 | val widthWhenOpened = widthWhenOpened.value ?: return 162 | val heightWhenOpened = heightWhenOpened.value ?: return 163 | WindowSize(widthWhenOpened, heightWhenOpened) 164 | } 165 | 166 | preferencesFlow.value = preferencesFlow.value.copy( 167 | colorTheme = colorThemeFlow.value, 168 | lightColorByPriority = whiteSavingColors.mapValues { (_, color) -> color!! }, 169 | lightBackgroundColor = whiteSavingBackgroundColor, 170 | darkColorByPriority = darkSavingColors.mapValues { (_, color) -> color!! }, 171 | darkBackgroundColor = darkSavingBackgroundColor, 172 | showExceptionDetection = showExceptionDetection.value, 173 | showInvalidSentences = showInvalidSentences.value, 174 | jsonPreviewSize = jsonPreviewSizeValue, 175 | windowSizeWhenOpened = windowSizeWhenOpened 176 | ) 177 | 178 | Preferences.file.outputStream().use { 179 | json.encodeToStream(preferencesFlow.value, it) 180 | } 181 | } 182 | 183 | fun restoreToDefault() { 184 | whiteColorStrings.value = Preferences.default.lightColorByPriority.toColorStrings() 185 | whiteBackgroundColorString.value = Preferences.default.lightBackgroundColor.toColorString() 186 | darkColorStrings.value = Preferences.default.darkColorByPriority.toColorStrings() 187 | darkBackgroundColorString.value = Preferences.default.darkBackgroundColor.toColorString() 188 | showExceptionDetection.value = Preferences.default.showExceptionDetection 189 | showInvalidSentences.value = Preferences.default.showInvalidSentences 190 | jsonPreviewSizeString.value = Preferences.default.jsonPreviewSize.toString() 191 | } 192 | 193 | private fun Color.toColorString() = String.format("#%x", this.toArgb()) 194 | 195 | private fun Map.toColorStrings() = mapValues { (_, c) -> c.toColorString() } 196 | private fun parseColor(colorString: String): Int { 197 | if (colorString[0] == '#') { // Use a long to avoid rollovers on #ffXXXXXX 198 | var color = colorString.substring(1).toLong(16) 199 | if (colorString.length == 7) { // Set the alpha value 200 | color = color or -0x1000000 201 | } else require(colorString.length == 9) { "Unknown color" } 202 | return color.toInt() 203 | } 204 | throw IllegalArgumentException("Unknown color") 205 | } 206 | 207 | fun setColumnInfoVisibility(columnInfo: ColumnInfo, visible: Boolean) { 208 | preferencesFlow.value.headerFlow.update { it.copyOf(columnInfo.columnType, columnInfo.copy(visible = visible)) } 209 | preferenceScope.launch { 210 | Header.file.outputStream().use { 211 | json.encodeToStream(preferencesFlow.value.headerFlow.value, it) 212 | } 213 | } 214 | } 215 | 216 | companion object { 217 | val json = Json { 218 | ignoreUnknownKeys = true 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/main/kotlin/com/jerryjeon/logjerry/ui/ParseCompletedView.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class) 2 | 3 | package com.jerryjeon.logjerry.ui 4 | 5 | import androidx.compose.foundation.layout.* 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.OutlinedButton 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.* 10 | import androidx.compose.ui.ExperimentalComposeUiApi 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.geometry.Offset 13 | import androidx.compose.ui.layout.onGloballyPositioned 14 | import androidx.compose.ui.layout.positionInRoot 15 | import androidx.compose.ui.unit.dp 16 | import com.jerryjeon.logjerry.detector.DetectorKey 17 | import com.jerryjeon.logjerry.detector.KeywordDetectionView 18 | import com.jerryjeon.logjerry.filter.FilterManager 19 | import com.jerryjeon.logjerry.log.Log 20 | import com.jerryjeon.logjerry.log.ParseCompleted 21 | import com.jerryjeon.logjerry.preferences.Preferences 22 | import com.jerryjeon.logjerry.table.Header 23 | import com.jerryjeon.logjerry.ui.popup.PackageFilterPopup 24 | import com.jerryjeon.logjerry.ui.popup.PriorityFilterPopup 25 | import com.jerryjeon.logjerry.ui.popup.TagFilterPopup 26 | import com.jerryjeon.logjerry.ui.popup.TextFilterPopup 27 | import kotlinx.coroutines.flow.StateFlow 28 | 29 | @Composable 30 | fun ParseCompletedView( 31 | preferences: Preferences, 32 | header: Header, 33 | parseCompleted: ParseCompleted, 34 | openNewTab: (StateFlow>) -> Unit, 35 | ) { 36 | val filterManager = parseCompleted.filterManager 37 | val detectorManager = parseCompleted.detectorManager 38 | val refineResult by parseCompleted.refineResultFlow.collectAsState() 39 | val optimizedHeader by parseCompleted.optimizedHeader.collectAsState() 40 | Column( 41 | modifier = Modifier 42 | ) { 43 | Row(modifier = Modifier.height(IntrinsicSize.Min)) { 44 | val statusByKey by refineResult.statusByKey.collectAsState() 45 | Row( 46 | modifier = Modifier 47 | .padding(12.dp) 48 | .height(IntrinsicSize.Min) 49 | ) { 50 | FilterView(filterManager) 51 | Spacer(Modifier.width(8.dp)) 52 | statusByKey[DetectorKey.Json]?.let { 53 | DetectionView( 54 | modifier = Modifier.fillMaxHeight(), 55 | detectionStatus = it, 56 | title = "JSON", 57 | moveToPreviousOccurrence = refineResult::selectPreviousDetection, 58 | moveToNextOccurrence = refineResult::selectNextDetection, 59 | ) 60 | } 61 | Spacer(Modifier.width(8.dp)) 62 | statusByKey[DetectorKey.DataClass]?.let { 63 | DetectionView( 64 | modifier = Modifier.fillMaxHeight(), 65 | detectionStatus = it, 66 | title = "Data class", 67 | moveToPreviousOccurrence = refineResult::selectPreviousDetection, 68 | moveToNextOccurrence = refineResult::selectNextDetection, 69 | ) 70 | } 71 | Spacer(Modifier.width(8.dp)) 72 | statusByKey[DetectorKey.Exception]?.let { 73 | DetectionView( 74 | modifier = Modifier.fillMaxHeight(), 75 | detectionStatus = it, 76 | title = "Exception", 77 | moveToPreviousOccurrence = refineResult::selectPreviousDetection, 78 | moveToNextOccurrence = refineResult::selectNextDetection, 79 | ) 80 | } 81 | } 82 | 83 | Spacer(modifier = Modifier.weight(1f)) 84 | 85 | val keywordDetectionRequest by detectorManager.keywordDetectionRequestFlow.collectAsState() 86 | KeywordDetectionView( 87 | keywordDetectionRequest = keywordDetectionRequest, 88 | detectionStatus = statusByKey[DetectorKey.Keyword], 89 | find = detectorManager::findKeyword, 90 | setFindEnabled = detectorManager::setKeywordDetectionEnabled, 91 | moveToPreviousOccurrence = refineResult::selectPreviousDetection, 92 | moveToNextOccurrence = refineResult::selectNextDetection, 93 | ) 94 | } 95 | 96 | val textFilters by filterManager.textFiltersFlow.collectAsState() 97 | Column(modifier = Modifier.padding(horizontal = 12.dp)) { 98 | textFilters.chunked(2).forEach { 99 | Row { 100 | it.forEach { filter -> 101 | AppliedTextFilter(filter, filterManager::removeTextFilter) 102 | Spacer(Modifier.width(8.dp)) 103 | } 104 | } 105 | } 106 | } 107 | Spacer(Modifier.height(4.dp)) 108 | 109 | LogsView( 110 | refineResult = refineResult, 111 | parseCompleted = parseCompleted, 112 | preferences = preferences, 113 | detectorManager = detectorManager, 114 | header = optimizedHeader, 115 | hide = filterManager::hide, 116 | moveToPreviousMark = { refineResult.selectPreviousDetection(DetectorKey.Mark) }, 117 | moveToNextMark = { refineResult.selectNextDetection(DetectorKey.Mark) } 118 | ) 119 | } 120 | } 121 | 122 | @Composable 123 | private fun FilterView(filterManager: FilterManager) { 124 | var showTextFilterPopup by remember { mutableStateOf(false) } 125 | var textFilterAnchor by remember { mutableStateOf(Offset.Zero) } 126 | 127 | var showLogLevelPopup by remember { mutableStateOf(false) } 128 | var logLevelAnchor by remember { mutableStateOf(Offset.Zero) } 129 | val priorityFilter by filterManager.priorityFilterFlow.collectAsState() 130 | 131 | var showPackageFilterPopup by remember { mutableStateOf(false) } 132 | var packageFilterAnchor by remember { mutableStateOf(Offset.Zero) } 133 | val packageFilters by filterManager.packageFiltersFlow.collectAsState() 134 | 135 | var showTagFilterPopup by remember { mutableStateOf(false) } 136 | var tagFilterAnchor by remember { mutableStateOf(Offset.Zero) } 137 | val tagFilters by filterManager.tagFiltersFlow.collectAsState() 138 | 139 | val sortedTagFilters by filterManager.sortedTagFiltersFlow.collectAsState() 140 | val tagFilterSortOption by filterManager.tagFilterSortOptionFlow.collectAsState() 141 | val sortedPackageFilters by filterManager.sortedPackageFiltersFlow.collectAsState() 142 | val packageFilterSortOption by filterManager.packageFilterSortOptionFlow.collectAsState() 143 | 144 | OutlinedButton( 145 | onClick = { 146 | showTextFilterPopup = true 147 | }, 148 | modifier = Modifier 149 | .height(48.dp) 150 | .onGloballyPositioned { coordinates -> 151 | textFilterAnchor = coordinates.positionInRoot() 152 | }, 153 | ) { 154 | Text("Add Filter") 155 | } 156 | Spacer(Modifier.width(8.dp)) 157 | OutlinedButton( 158 | onClick = { 159 | showLogLevelPopup = true 160 | }, 161 | modifier = Modifier 162 | .height(48.dp) 163 | .onGloballyPositioned { coordinates -> 164 | logLevelAnchor = coordinates.positionInRoot() 165 | }, 166 | ) { 167 | Text("Log Level | ${priorityFilter.priority.name}") 168 | } 169 | Spacer(Modifier.width(8.dp)) 170 | OutlinedButton( 171 | onClick = { 172 | showPackageFilterPopup = true 173 | }, 174 | modifier = Modifier 175 | .height(48.dp) 176 | .onGloballyPositioned { coordinates -> 177 | packageFilterAnchor = coordinates.positionInRoot() 178 | }, 179 | ) { 180 | Column { 181 | Row { 182 | Text("Packages") 183 | Spacer(Modifier.width(4.dp)) 184 | Text( 185 | text = "(${packageFilters.filters.count { it.include }}/${packageFilters.filters.size})", 186 | ) 187 | } 188 | packageFilters.filters.singleOrNull { it.include }?.let { 189 | Text(it.packageName ?: "?", style = MaterialTheme.typography.caption) 190 | } 191 | } 192 | } 193 | Spacer(Modifier.width(8.dp)) 194 | OutlinedButton( 195 | onClick = { 196 | showTagFilterPopup = true 197 | }, 198 | modifier = Modifier 199 | .height(48.dp) 200 | .onGloballyPositioned { coordinates -> 201 | tagFilterAnchor = coordinates.positionInRoot() 202 | }, 203 | ) { 204 | Column { 205 | Row { 206 | Text("Tags") 207 | Spacer(Modifier.width(4.dp)) 208 | Text( 209 | text = "(${tagFilters.filters.count { it.include }}/${tagFilters.filters.size})", 210 | ) 211 | } 212 | tagFilters.filters.singleOrNull { it.include }?.let { 213 | Text(it.tag ?: "?", style = MaterialTheme.typography.caption) 214 | } 215 | } 216 | } 217 | 218 | TextFilterPopup( 219 | showTextFilterPopup = showTextFilterPopup, 220 | textFilterAnchor = textFilterAnchor, 221 | dismiss = { showTextFilterPopup = false }, 222 | addTextFilter = filterManager::addTextFilter 223 | ) 224 | PriorityFilterPopup( 225 | showPopup = showLogLevelPopup, 226 | anchor = logLevelAnchor, 227 | priorityFilter = priorityFilter, 228 | dismiss = { showLogLevelPopup = false }, 229 | setPriorityFilter = filterManager::setPriorityFilter 230 | ) 231 | PackageFilterPopup( 232 | showPackageFilterPopup = showPackageFilterPopup, 233 | packageFilterAnchor = packageFilterAnchor, 234 | dismiss = { showPackageFilterPopup = false }, 235 | packageFilters = sortedPackageFilters, 236 | packageFilterSortOption = packageFilterSortOption, 237 | togglePackageFilter = filterManager::togglePackageFilter, 238 | includeAll = { filterManager.setAllPackageFilter(true) }, 239 | excludeAll = { filterManager.setAllPackageFilter(false) }, 240 | setPackageFilterSortOption = filterManager::setPackageFilterSortOption, 241 | ) 242 | TagFilterPopup( 243 | showTagFilterPopup = showTagFilterPopup, 244 | tagFilterAnchor = tagFilterAnchor, 245 | dismiss = { showTagFilterPopup = false }, 246 | tagFilters = sortedTagFilters, 247 | tagFilterSortOption = tagFilterSortOption, 248 | toggleTagFilter = filterManager::toggleTagFilter, 249 | includeAll = { filterManager.setAllTagFilter(true) }, 250 | excludeAll = { filterManager.setAllTagFilter(false) }, 251 | setTagFilterSortOption = filterManager::setTagFilterSortOption, 252 | ) 253 | } 254 | --------------------------------------------------------------------------------