diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..04e93c1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,76 @@
+.DS_Store
+.idea/shelf
+/confluence/target
+/dependencies/repo
+/android.tests.dependencies
+/dependencies/android.tests.dependencies
+/dist
+/local
+/gh-pages
+/ideaSDK
+/clionSDK
+/android-studio/sdk
+out/
+/tmp
+/intellij
+workspace.xml
+*.versionsBackup
+/idea/testData/debugger/tinyApp/classes*
+/jps-plugin/testData/kannotator
+/js/js.translator/testData/out/
+/js/js.translator/testData/out-min/
+/js/js.translator/testData/out-pir/
+.gradle/
+build/
+!**/src/**/build
+!**/test/**/build
+*.iml
+!**/testData/**/*.iml
+.idea/remote-targets.xml
+.idea/libraries/Gradle*.xml
+.idea/libraries/Maven*.xml
+.idea/artifacts/PILL_*.xml
+.idea/artifacts/KotlinPlugin.xml
+.idea/modules
+.idea/runConfigurations/JPS_*.xml
+.idea/runConfigurations/PILL_*.xml
+.idea/runConfigurations/_FP_*.xml
+.idea/runConfigurations/_MT_*.xml
+.idea/libraries
+.idea/modules.xml
+.idea/gradle.xml
+.idea/compiler.xml
+.idea/inspectionProfiles/profiles_settings.xml
+.idea/.name
+.idea/artifacts/dist_auto_*
+.idea/artifacts/dist.xml
+.idea/artifacts/ideaPlugin.xml
+.idea/artifacts/kotlinc.xml
+.idea/artifacts/kotlin_compiler_jar.xml
+.idea/artifacts/kotlin_plugin_jar.xml
+.idea/artifacts/kotlin_jps_plugin_jar.xml
+.idea/artifacts/kotlin_daemon_client_jar.xml
+.idea/artifacts/kotlin_imports_dumper_compiler_plugin_jar.xml
+.idea/artifacts/kotlin_main_kts_jar.xml
+.idea/artifacts/kotlin_compiler_client_embeddable_jar.xml
+.idea/artifacts/kotlin_reflect_jar.xml
+.idea/artifacts/kotlin_stdlib_js_ir_*
+.idea/artifacts/kotlin_test_js_ir_*
+.idea/artifacts/kotlin_stdlib_wasm_*
+.idea/artifacts/kotlinx_atomicfu_runtime_*
+.idea/jarRepositories.xml
+.idea/csv-plugin.xml
+.idea/libraries-with-intellij-classes.xml
+.idea/misc.xml
+node_modules/
+.rpt2_cache/
+libraries/tools/kotlin-test-js-runner/lib/
+local.properties
+buildSrcTmp/
+distTmp/
+outTmp/
+/test.output
+/kotlin-native/dist
+kotlin-ide/
+sessions.db
+filters
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/runConfigurations/Goflog__run_.xml b/.idea/runConfigurations/Goflog__run_.xml
new file mode 100644
index 0000000..9f8f59e
--- /dev/null
+++ b/.idea/runConfigurations/Goflog__run_.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+ false
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..8428c17
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,61 @@
+import org.jetbrains.compose.compose
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+plugins {
+ kotlin("jvm") version "1.5.31"
+ id("org.jetbrains.compose") version "1.0.0"
+}
+
+group = "com.gi"
+version = "1.0"
+
+repositories {
+ google()
+ mavenCentral()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+}
+
+dependencies {
+ testImplementation(kotlin("test"))
+ implementation(compose.desktop.currentOs)
+ // https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j-impl
+ implementation("org.apache.logging.log4j:log4j-slf4j-impl:2.17.0")
+ implementation("org.apache.logging.log4j:log4j-api:2.17.0")
+ implementation("org.apache.logging.log4j:log4j-core:2.17.0")
+ // types parser for object to map conversion
+ implementation("com.github.drapostolos:type-parser:0.7.0")
+ // embedded database
+ implementation("org.mapdb:mapdb:3.0.8")
+ implementation("org.snakeyaml:snakeyaml-engine:2.3")
+ // https://mvnrepository.com/artifact/io.netty/netty-resolver-dns-native-macos
+ runtimeOnly("io.netty:netty-resolver-dns-native-macos:4.1.72.Final") // not sure if needed now
+ implementation("com.android.tools.ddms:ddmlib:30.2.0-alpha06")
+ implementation("com.google.code.gson:gson:2.8.9")
+ // https://mvnrepository.com/artifact/com.googlecode.cqengine/cqengine
+ implementation("com.googlecode.cqengine:cqengine:3.6.0")
+ implementation("org.jetbrains.kotlin:kotlin-reflect:1.6.10")
+}
+
+tasks.test {
+ useJUnit()
+}
+
+tasks.withType {
+ kotlinOptions.jvmTarget = "1.8"
+}
+
+tasks.withType().configureEach {
+ kotlinOptions.freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
+}
+
+compose.desktop {
+ application {
+ mainClass = "MainKt"
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "logvue"
+ packageVersion = "1.0.0"
+ }
+ }
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..9930c09
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,2 @@
+kotlin.code.style=official
+#kotlin.native.binary.memoryModel=experimental
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..7454180
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..69a9715
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..744e882
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MSYS* | MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..0303029
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,10 @@
+pluginManagement {
+ repositories {
+ google()
+ gradlePluginPortal()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ }
+
+}
+rootProject.name = "logvue"
+
diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt
new file mode 100644
index 0000000..fa485c2
--- /dev/null
+++ b/src/main/kotlin/Main.kt
@@ -0,0 +1,100 @@
+import androidx.compose.desktop.ui.tooling.preview.Preview
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.window.WindowDraggableArea
+import androidx.compose.material.Divider
+import androidx.compose.material.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.*
+import inputs.adb.ddmlib.AdbHelper
+import processor.MainProcessor
+import storage.Db
+import ui.AppTheme
+import ui.CustomTheme
+import ui.components.BodyPanel
+import ui.components.SideNavigation
+import utils.APP_NAME
+import utils.Helpers
+import utils.Log
+import java.awt.Desktop
+
+@Composable
+@Preview
+fun App() {
+ val processor = remember { MainProcessor() }
+ val isLightTheme by Helpers.isThemeLightMode.collectAsState()
+ AppTheme(isLightTheme) {
+ Row(Modifier.fillMaxSize().background(CustomTheme.colors.background)) {
+ var sessionId by remember { mutableStateOf(Db.sessionId()) }
+ SideNavigation(
+ processor, sessionId, Modifier.fillMaxHeight().weight(0.2f)
+ .background(CustomTheme.colors.componentBackground)
+ ) {
+ sessionId = it.orEmpty()
+ }
+ Divider(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray.copy(alpha = 0.3f)))
+ BodyPanel(processor, sessionId, Modifier.fillMaxHeight().weight(0.8f))
+ }
+ LaunchedEffect(Unit) {
+ AdbHelper.init()
+ }
+ }
+}
+
+
+@Composable
+private fun RemainingItems(state: LazyListState, lastIndex: Int) {
+ val fVIOfState = state.firstVisibleItemIndex
+ if (lastIndex - fVIOfState < 3) {
+ val firstVisibleItemIndex = fVIOfState - state.layoutInfo.visibleItemsInfo.size
+ Log.d("firstVisibleItemIndex", "${lastIndex - firstVisibleItemIndex}")
+ }
+}
+
+@Composable
+private fun ParameterList(list: List, modifier: Modifier) {
+ LazyColumn(modifier) {
+ items(list, key = { item: String -> item }) {
+ Column {
+ Text(it)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+fun main() = application(false) {
+ fun onClose(source: String) {
+ Log.d("QuitHandler", "Quiting : $source")
+ AdbHelper.close()
+ Db.close()
+ }
+ Desktop.getDesktop().setQuitHandler { e, response ->
+ onClose(e.source.toString())
+ response.performQuit()
+ }
+ val onCloseRequest = {
+ onClose("User Close")
+ exitApplication()
+ }
+ val windowState = rememberWindowState(WindowPlacement.Floating, size = DpSize(1440.dp, 1024.dp))
+ Window(onCloseRequest = onCloseRequest, title = APP_NAME, state = windowState) {
+// window.exceptionHandler = WindowExceptionHandler {
+// println(it)
+// }
+ App()
+ }
+}
+
+@Composable
+private fun WindowScope.AppWindowTitleBar() = WindowDraggableArea {
+ Box(Modifier.fillMaxWidth().height(24.dp).background(Color.White))
+}
diff --git a/src/main/kotlin/inputs/adb/AdbCommand.kt b/src/main/kotlin/inputs/adb/AdbCommand.kt
new file mode 100644
index 0000000..d66e009
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/AdbCommand.kt
@@ -0,0 +1,3 @@
+package inputs.adb
+
+interface AdbCommand
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/AndroidLogStreamer.kt b/src/main/kotlin/inputs/adb/AndroidLogStreamer.kt
new file mode 100644
index 0000000..85928eb
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/AndroidLogStreamer.kt
@@ -0,0 +1,18 @@
+package inputs.adb
+
+import inputs.adb.ddmlib.AdbHelper
+import kotlinx.coroutines.flow.Flow
+import models.LogCatMessage2
+import utils.Either
+
+class AndroidLogStreamer {
+
+ fun stream(packageName: String): Flow>> {
+ return AdbHelper.monitorLogs(packageName)
+ }
+
+ fun stop() {
+ AdbHelper.closeLogs()
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/CancelException.kt b/src/main/kotlin/inputs/adb/CancelException.kt
new file mode 100644
index 0000000..bbf6e06
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/CancelException.kt
@@ -0,0 +1,8 @@
+package inputs.adb
+
+class CancelException : Exception {
+ constructor() : super("Logging is Cancelled")
+ constructor(message: String) : super(message)
+ constructor(message: String, cause: Throwable) : super(message, cause)
+ constructor(cause: Throwable) : super("Logging is Cancelled", cause)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/CommonStdConsumer.kt b/src/main/kotlin/inputs/adb/CommonStdConsumer.kt
new file mode 100644
index 0000000..409f272
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/CommonStdConsumer.kt
@@ -0,0 +1,16 @@
+package inputs.adb
+
+import java.io.Serializable
+import java.util.function.Consumer
+
+class CommonStdConsumer : Consumer, Serializable {
+
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+
+ override fun accept(t: String?) {
+ println(t)
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/LogCatErrors.kt b/src/main/kotlin/inputs/adb/LogCatErrors.kt
new file mode 100644
index 0000000..f9a0b29
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/LogCatErrors.kt
@@ -0,0 +1,17 @@
+package inputs.adb
+
+import java.io.Serializable
+
+sealed class LogCatErrors : Exception(),Serializable {
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+}
+object LogErrorNotEnabledForFA : LogCatErrors()
+object LogErrorDeviceNotConnected : LogCatErrors()
+object LogErrorNoSession : LogCatErrors()
+object LogErrorPackageIssue : LogCatErrors()
+object LogErrorADBIssue : LogCatErrors()
+class LogErrorUnknown(val exception: Exception = Exception()) : LogCatErrors() {
+ constructor(exception: String) : this(Exception(exception))
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/LogCatExceptions.kt b/src/main/kotlin/inputs/adb/LogCatExceptions.kt
new file mode 100644
index 0000000..fe7b299
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/LogCatExceptions.kt
@@ -0,0 +1,3 @@
+package inputs.adb
+
+class LogCatExceptions : Exception()
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/UnicodeReader.kt b/src/main/kotlin/inputs/adb/UnicodeReader.kt
new file mode 100644
index 0000000..b267344
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/UnicodeReader.kt
@@ -0,0 +1,31 @@
+package inputs.adb
+
+object UnicodeReader {
+ private const val CarriageReturn = 0xD
+ private const val LineFeed = 0xA
+
+ fun getLineBreak(startIndex: Int, unicodeBytes: ByteArray): Int {
+ val len: Int = unicodeBytes.size
+ var pos = startIndex
+ while (pos < len - 1) {
+ if (unicodeBytes[pos].toInt() == CarriageReturn && unicodeBytes[pos + 2].toInt() == LineFeed && unicodeBytes[pos + 1].toInt() == 0 && unicodeBytes[pos + 3].toInt() == 0) return pos
+ pos += 2
+ }
+ return -1
+ }
+
+ fun getAllLines(unicodeBytes: ByteArray, onNewLine: (str: String) -> Unit) {
+ var pos = 0
+ var lastPos = 0
+ while (pos > -1) {
+ pos = getLineBreak(pos, unicodeBytes)
+ onNewLine(String(unicodeBytes, lastPos, pos - lastPos))
+ lastPos = pos
+ pos += 4
+ }
+ if (lastPos < unicodeBytes.size - 2) {
+ onNewLine(String(unicodeBytes, lastPos, unicodeBytes.size - lastPos))
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/ddmlib/AdbHelper.kt b/src/main/kotlin/inputs/adb/ddmlib/AdbHelper.kt
new file mode 100644
index 0000000..d74a908
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/ddmlib/AdbHelper.kt
@@ -0,0 +1,147 @@
+package inputs.adb.ddmlib
+
+import com.android.ddmlib.AdbInitOptions
+import com.android.ddmlib.AndroidDebugBridge
+import com.android.ddmlib.IDevice
+import com.android.ddmlib.Log
+import inputs.adb.LogErrorDeviceNotConnected
+import inputs.adb.LogErrorPackageIssue
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.buffer
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.cancellable
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.withContext
+import models.LogCatMessage2
+import utils.Either
+import java.io.File
+import java.util.*
+import java.util.concurrent.TimeUnit
+import kotlin.concurrent.thread
+
+object AdbHelper {
+
+ private var bridge: AndroidDebugBridge? = null
+ private const val PACKAGES_COMMAND = "pm list packages -3 -e"
+ // list of lines with format : package:com.ea.games.r3_row
+
+ fun init() {
+ val options = AdbInitOptions.builder().setClientSupportEnabled(true).build()
+ AndroidDebugBridge.init(options)
+ AndroidDebugBridge.addDeviceChangeListener(Devices())
+ val adbPath = adbPath()
+ bridge = if (!adbPath.isNullOrBlank()) {
+ AndroidDebugBridge.createBridge(adbPath, false, 10, TimeUnit.SECONDS)
+ } else {
+ AndroidDebugBridge.createBridge(10, TimeUnit.SECONDS)
+ }
+ AndroidDebugBridge.addDebugBridgeChangeListener {
+ bridge = it
+ }
+ AndroidDebugBridge.addClientChangeListener { client, changeMask ->
+ Log.d("ClientChange", "$client : $changeMask")
+ }
+ }
+
+ fun close() {
+ try {
+ AndroidDebugBridge.terminate()
+ } catch (e: Exception) {
+ // ignore
+ }
+ }
+
+ private var stopLogs = false
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun monitorLogs(
+ packageName: String,
+ filters: Array = arrayOf("FA", "FA-SVC")
+ ) = callbackFlow {
+ stopLogs = false
+ val currentSelectedDevice = Devices.currentDevice?.device
+ if (currentSelectedDevice == null || !currentSelectedDevice.isOnline) {
+ send(Either.Left(LogErrorDeviceNotConnected))
+ awaitClose()
+ return@callbackFlow
+ }
+ currentSelectedDevice.emptyShellCommand("setprop log.tag.FA VERBOSE")
+ currentSelectedDevice.emptyShellCommand("setprop log.tag.FA-SVC VERBOSE")
+ val client = currentSelectedDevice.getClient(packageName)
+ var clientPid = -1
+ if (client == null) {
+ currentSelectedDevice.executeShellCommand("pidof -s $packageName",
+ SingleValueReceiver {
+ clientPid = it.toIntOrNull() ?: -1
+ })
+ } else {
+ clientPid = client.clientData.pid
+ }
+ if (clientPid < 0) {
+ println("Client is null")
+ send(Either.Left(LogErrorPackageIssue))
+ close()
+ awaitClose()
+ return@callbackFlow
+ }
+ val logTask = LogCatRunner(currentSelectedDevice, clientPid.toLong(), filters)
+ val listener: (msgList: ArrayList) -> Unit = {
+ if (stopLogs) {
+ close()
+ } else if (isActive) {
+ trySend(Either.Right(it))
+ }
+ }
+ logTask.addLogCatListener(listener)
+ thread {
+ logTask.run()
+ }
+ awaitClose {
+ logTask.removeLogCatListener(listener)
+ logTask.stop()
+ }
+ }.buffer(capacity = Channel.UNLIMITED).cancellable()
+
+ fun closeLogs() {
+ stopLogs = true
+ }
+
+ suspend fun getPackages(device: IDevice, onValue: (packages: List) -> Unit) = withContext(Dispatchers.IO) {
+ device.executeShellCommand(
+ PACKAGES_COMMAND, PackagesReceiver(onValue),
+ 10, TimeUnit.SECONDS
+ )
+ }
+
+ private fun IDevice.emptyShellCommand(command: String) {
+ executeShellCommand(
+ command,
+ EmptyReceiver, 10, TimeUnit.SECONDS
+ )
+ }
+
+ private fun adbPath(): String? {
+ val androidEnvHome: File? = try {
+ System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT")
+ } catch (e: SecurityException) {
+ null
+ }?.let { File(it) }
+
+ val os = System.getProperty("os.name").lowercase(Locale.ENGLISH)
+ val adbBinaryName = when {
+ os.contains("win") -> {
+ "adb.exe"
+ }
+ else -> "adb"
+ }
+
+ val adb = androidEnvHome?.let { File(it, "platform-tools" + File.separator + adbBinaryName) }
+ ?: return null
+ if (!adb.isFile) return null
+ return adb.absolutePath
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/ddmlib/Devices.kt b/src/main/kotlin/inputs/adb/ddmlib/Devices.kt
new file mode 100644
index 0000000..e7ecea6
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/ddmlib/Devices.kt
@@ -0,0 +1,46 @@
+package inputs.adb.ddmlib
+
+import com.android.ddmlib.AndroidDebugBridge
+import com.android.ddmlib.IDevice
+import kotlinx.coroutines.flow.MutableStateFlow
+import models.DeviceDetails2
+
+class Devices : AndroidDebugBridge.IDeviceChangeListener {
+
+ companion object {
+ private val _devicesFlow: MutableStateFlow> = MutableStateFlow(emptyList())
+ val devicesFlow: MutableStateFlow> = _devicesFlow
+ private val devices: HashSet = hashSetOf()
+ private val _currentDeviceFlow: MutableStateFlow = MutableStateFlow(null)
+
+ val currentDeviceFlow = _currentDeviceFlow
+ val currentDevice
+ get() = currentDeviceFlow.value
+
+ fun setCurrentDevice(serial: DeviceDetails2?) {
+ _currentDeviceFlow.value = serial
+ }
+ }
+
+ override fun deviceConnected(device: IDevice) {
+ val details2 = DeviceDetails2(device)
+ devices.add(details2)
+ _devicesFlow.value = currentDevices()
+ }
+
+ override fun deviceDisconnected(device: IDevice) {
+ val details2 = DeviceDetails2(device)
+ devices.remove(details2)
+ _devicesFlow.value = currentDevices()
+ }
+
+ override fun deviceChanged(device: IDevice, changeMask: Int) {
+ var details2 = DeviceDetails2(device)
+ devices.remove(details2)
+ details2 = DeviceDetails2(device)
+ devices.add(details2)
+ _devicesFlow.value = currentDevices()
+ }
+
+ private fun currentDevices() = devices.toList().sortedBy { it.sortKey() }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/ddmlib/EmptyReceiver.kt b/src/main/kotlin/inputs/adb/ddmlib/EmptyReceiver.kt
new file mode 100644
index 0000000..50ede39
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/ddmlib/EmptyReceiver.kt
@@ -0,0 +1,3 @@
+package inputs.adb.ddmlib
+
+val EmptyReceiver = SingleValueReceiver {}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/ddmlib/LogCatListener2.kt b/src/main/kotlin/inputs/adb/ddmlib/LogCatListener2.kt
new file mode 100644
index 0000000..cd382a8
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/ddmlib/LogCatListener2.kt
@@ -0,0 +1,8 @@
+package inputs.adb.ddmlib
+
+import models.LogCatMessage2
+
+
+fun interface LogCatListener2 {
+ fun log(msgList: ArrayList)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/ddmlib/LogCatRunner.kt b/src/main/kotlin/inputs/adb/ddmlib/LogCatRunner.kt
new file mode 100644
index 0000000..28343c9
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/ddmlib/LogCatRunner.kt
@@ -0,0 +1,121 @@
+package inputs.adb.ddmlib
+
+import com.android.ddmlib.*
+import com.android.ddmlib.logcat.LogCatMessageParser
+import models.LogCatHeader2
+import models.LogCatMessage2
+import java.io.IOException
+import java.time.Instant
+import java.util.*
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.annotation.concurrent.GuardedBy
+
+
+class LogCatRunner(
+ val mDevice: IDevice, pid: Long, filters: Array = arrayOf("FA", "FA-SVC")
+) {
+
+ companion object {
+ private const val DEVICE_POLL_INTERVAL_MSEC = 1000
+
+ private val sDeviceDisconnectedMsg: LogCatMessage2 = newLogCatMessage("Device disconnected: 1")
+
+ private val sConnectionTimeoutMsg: LogCatMessage2 = newLogCatMessage("LogCat Connection timed out")
+
+ private val sConnectionErrorMsg: LogCatMessage2 = newLogCatMessage("LogCat Connection error")
+
+ private fun newLogCatMessage(message: String): LogCatMessage2 {
+ return LogCatMessage2(
+ LogCatHeader2(
+ Log.LogLevel.ERROR, -1, -1, "", "",
+ Instant.EPOCH
+ ), message
+ )
+ }
+ }
+
+ private val mParser = LogCatMessageParser()
+
+ private val mCancelled = AtomicBoolean(false)
+ private val mReceiver = LogCatOutputReceiver()
+
+ // TODO: Check if filters is empty then show all logs
+ private val logcatCommand = "logcat -s ${filters.joinToString(" ")} --pid=$pid -v long"
+
+ @GuardedBy("this")
+ private val mListeners = hashSetOf()
+
+ fun run() {
+ // wait while device comes online
+ while (!mDevice.isOnline) {
+ try {
+ Thread.sleep(DEVICE_POLL_INTERVAL_MSEC.toLong())
+ } catch (e: InterruptedException) {
+ return
+ }
+ }
+ try {
+ mDevice.executeShellCommand(logcatCommand, mReceiver, 0, TimeUnit.SECONDS)
+ } catch (e: TimeoutException) {
+ notifyListeners(arrayListOf(sConnectionTimeoutMsg))
+ } catch (ignored: AdbCommandRejectedException) {
+ // will not be thrown as long as the shell supports logcat
+ } catch (ignored: ShellCommandUnresponsiveException) {
+ // this will not be thrown since the last argument is 0
+ } catch (e: IOException) {
+ notifyListeners(arrayListOf(sConnectionErrorMsg))
+ }
+ notifyListeners(arrayListOf(sDeviceDisconnectedMsg))
+ }
+
+ fun stop() {
+ mCancelled.set(true)
+ }
+
+ private inner class LogCatOutputReceiver : MultiLineReceiver() {
+ init {
+ setTrimLine(false)
+ }
+
+ /** Implements [IShellOutputReceiver.isCancelled]. */
+ override fun isCancelled(): Boolean {
+ return mCancelled.get()
+ }
+
+ override fun processNewLines(lines: Array) {
+ if (!mCancelled.get()) {
+ processLogLines(lines)
+ }
+ }
+
+ private fun processLogLines(lines: Array) {
+ val newMessages: List = mParser.processLogLines(lines, mDevice).map { LogCatMessage2(it) }
+ if (newMessages.isNotEmpty()) {
+ notifyListeners(arrayListOf().also { logCatMessage2s ->
+ logCatMessage2s.addAll(
+ newMessages
+ )
+ })
+ }
+ }
+ }
+
+ @Synchronized
+ fun addLogCatListener(l: LogCatListener2) {
+ mListeners.add(l)
+ }
+
+ @Synchronized
+ fun removeLogCatListener(l: LogCatListener2) {
+ mListeners.remove(l)
+ }
+
+ @Synchronized
+ private fun notifyListeners(messages: ArrayList) {
+ for (l in mListeners) {
+ l.log(messages)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/ddmlib/PackagesReceiver.kt b/src/main/kotlin/inputs/adb/ddmlib/PackagesReceiver.kt
new file mode 100644
index 0000000..36cab2a
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/ddmlib/PackagesReceiver.kt
@@ -0,0 +1,17 @@
+package inputs.adb.ddmlib
+
+import com.android.ddmlib.MultiLineReceiver
+
+class PackagesReceiver(val onValue: (packages: List) -> Unit) : MultiLineReceiver() {
+ var isResultPending = false
+
+ override fun isCancelled(): Boolean = isResultPending
+
+ override fun processNewLines(lines: Array?) {
+ if (!lines.isNullOrEmpty()) {
+ val packages = lines.mapNotNull { it.split(Regex(":"), 2).lastOrNull() }.toList()
+ onValue(packages)
+ }
+ isResultPending = true
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/ddmlib/SingleValueReceiver.kt b/src/main/kotlin/inputs/adb/ddmlib/SingleValueReceiver.kt
new file mode 100644
index 0000000..0367fed
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/ddmlib/SingleValueReceiver.kt
@@ -0,0 +1,16 @@
+package inputs.adb.ddmlib
+
+import com.android.ddmlib.MultiLineReceiver
+
+class SingleValueReceiver(val onValue : (value : String) -> Unit) : MultiLineReceiver() {
+ var isResultPending = false
+
+ override fun isCancelled(): Boolean = isResultPending
+
+ override fun processNewLines(lines: Array?) {
+ if (!lines.isNullOrEmpty()) {
+ onValue(lines.first())
+ }
+ isResultPending = true
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/models/DeviceDetails2.kt b/src/main/kotlin/models/DeviceDetails2.kt
new file mode 100644
index 0000000..216f90f
--- /dev/null
+++ b/src/main/kotlin/models/DeviceDetails2.kt
@@ -0,0 +1,36 @@
+package models
+
+import com.android.ddmlib.IDevice
+
+class DeviceDetails2(
+ val device: IDevice
+) {
+
+ val serial: String = device.serialNumber
+ val name: String = device.getProperty("ro.product.device") ?: device.name
+
+ fun isOnline(): Boolean {
+ return device.isOnline
+ }
+
+ fun stateText(): String {
+ return if (isOnline()) "Connected" else "Offline"
+ }
+
+ fun sortKey(): String {
+ return (if (isOnline()) 0 else 1).toString() + serial
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is DeviceDetails2) return false
+ if (serial != other.serial) return false
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return serial.hashCode()
+ }
+
+
+}
diff --git a/src/main/kotlin/models/InternalContent.kt b/src/main/kotlin/models/InternalContent.kt
new file mode 100644
index 0000000..3f365ad
--- /dev/null
+++ b/src/main/kotlin/models/InternalContent.kt
@@ -0,0 +1,6 @@
+package models
+
+sealed interface InternalContent
+
+data class NoLogsContent(val msg: String) : InternalContent
+data class ErrorContent(val error: String) : InternalContent
\ No newline at end of file
diff --git a/src/main/kotlin/models/ItemSource.kt b/src/main/kotlin/models/ItemSource.kt
new file mode 100644
index 0000000..4ef61a8
--- /dev/null
+++ b/src/main/kotlin/models/ItemSource.kt
@@ -0,0 +1,12 @@
+package models
+
+import java.io.Serializable
+
+sealed class ItemSource(val type : String) : Serializable {
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+}
+
+object SourceFA : ItemSource("Firebase")
+object SourceInternalContent : ItemSource("Content")
\ No newline at end of file
diff --git a/src/main/kotlin/models/LogCatHeader2.kt b/src/main/kotlin/models/LogCatHeader2.kt
new file mode 100644
index 0000000..2fc3869
--- /dev/null
+++ b/src/main/kotlin/models/LogCatHeader2.kt
@@ -0,0 +1,32 @@
+package models
+
+import com.android.ddmlib.Log.LogLevel
+import com.android.ddmlib.logcat.LogCatHeader
+import java.time.Instant
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeFormatterBuilder
+import java.time.temporal.ChronoField
+import java.util.*
+
+private val EPOCH_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatterBuilder()
+ .appendValue(ChronoField.INSTANT_SECONDS)
+ .appendFraction(ChronoField.MILLI_OF_SECOND, 3, 3, true)
+ .toFormatter(Locale.ROOT)
+
+data class LogCatHeader2(
+ val logLevel: LogLevel,
+ val pid: Int,
+ val tid: Int,
+ val appName: String,
+ val tag: String,
+ val timestamp: Instant,
+) {
+ constructor(header: LogCatHeader) : this(
+ header.logLevel, header.pid, header.tid, header.appName, header.tag, header.timestamp)
+
+ override fun toString(): String {
+ val epoch = EPOCH_TIME_FORMATTER.format(timestamp)
+ val priority = logLevel.priorityLetter
+ return "$epoch: $priority/$tag($pid:$tid) $appName"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/models/LogCatMessage2.kt b/src/main/kotlin/models/LogCatMessage2.kt
new file mode 100644
index 0000000..76d04b4
--- /dev/null
+++ b/src/main/kotlin/models/LogCatMessage2.kt
@@ -0,0 +1,17 @@
+package models
+
+import com.android.ddmlib.logcat.LogCatMessage
+import java.io.Serializable
+
+data class LogCatMessage2(val header: LogCatHeader2, val message: String) : Serializable {
+
+ constructor(log: LogCatMessage) : this(LogCatHeader2(log.header), log.message)
+
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+
+ override fun toString(): String {
+ return "$header: $message"
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/models/LogItem.kt b/src/main/kotlin/models/LogItem.kt
new file mode 100644
index 0000000..70afe00
--- /dev/null
+++ b/src/main/kotlin/models/LogItem.kt
@@ -0,0 +1,49 @@
+package models
+
+import androidx.compose.ui.text.AnnotatedString
+import processor.attribute
+import utils.Helpers
+import utils.hashMapEntityOf
+import java.io.Serializable
+import javax.annotation.concurrent.GuardedBy
+
+data class LogItem(
+ val source: ItemSource,
+ val eventName: String,
+ val properties: HashMap = hashMapEntityOf(),
+ val localTime: Long = System.currentTimeMillis(),
+ val internalContent: InternalContent? = null
+) : Serializable {
+ companion object {
+ private const val serialVersionUID = 1L
+ val EVENT_NAME = attribute("eventName", LogItem::eventName)
+ val PROPERTY = attribute("properties", LogItem::properties)
+
+ fun noContent(msg: String) = LogItem(SourceInternalContent, "No Logs", internalContent = NoLogsContent(msg))
+ fun errorContent(error: String) = LogItem(SourceInternalContent, "Error", internalContent = ErrorContent(error))
+
+ }
+
+ @Transient
+ var _propertiesAString: AnnotatedString? = null
+
+ @Transient
+ private val lock = true
+
+ val propertiesAString: AnnotatedString
+ @GuardedBy("lock")
+ get() {
+ if (_propertiesAString == null) {
+ synchronized(lock) {
+ if (_propertiesAString == null) {
+ _propertiesAString = Helpers.createAnnotatedString(properties)
+ }
+ }
+ }
+ return _propertiesAString!!
+ }
+
+ var isSelected: Boolean = false
+
+ fun key() = "${source.type}_${eventName}_${localTime}_${properties.hashCode()}"
+}
diff --git a/src/main/kotlin/models/ParameterFormats.kt b/src/main/kotlin/models/ParameterFormats.kt
new file mode 100644
index 0000000..4a63b33
--- /dev/null
+++ b/src/main/kotlin/models/ParameterFormats.kt
@@ -0,0 +1,8 @@
+package models
+
+sealed class ParameterFormats(val key: String, val text: String)
+object FormatJsonPretty : ParameterFormats("jsonpretty", "Json with pretty print")
+object FormatJsonCompact : ParameterFormats("json", "Compact Json")
+object FormatYaml : ParameterFormats("yaml", "Yaml")
+
+val DefaultFormats = listOf(FormatJsonPretty, FormatJsonCompact, FormatYaml)
\ No newline at end of file
diff --git a/src/main/kotlin/models/SessionInfo.kt b/src/main/kotlin/models/SessionInfo.kt
new file mode 100644
index 0000000..f1d7e03
--- /dev/null
+++ b/src/main/kotlin/models/SessionInfo.kt
@@ -0,0 +1,19 @@
+package models
+
+import java.io.Serializable
+
+data class SessionInfo(
+ val description: String,
+ val appPackage: String
+) : Serializable {
+ companion object {
+ private const val serialVersionUID = 1L
+ const val DESC_MAX_LENGTH = 20
+ }
+
+ init {
+ check(description.length <= DESC_MAX_LENGTH) {
+ "Session description should be less than $DESC_MAX_LENGTH characters"
+ }
+ }
+}
diff --git a/src/main/kotlin/processor/DbSink.kt b/src/main/kotlin/processor/DbSink.kt
new file mode 100644
index 0000000..c75a404
--- /dev/null
+++ b/src/main/kotlin/processor/DbSink.kt
@@ -0,0 +1,29 @@
+package processor
+
+import models.LogItem
+import models.SourceInternalContent
+import storage.Db
+
+
+object DbSink {
+
+ fun save(value: LogItem?) {
+ val currentSession = Db.currentSession()
+ if (value == null || value.source is SourceInternalContent || currentSession == null) return
+ currentSession[value.key()] = value
+ }
+
+ fun saveAll(list: List) {
+ val map = hashMapOf()
+ list.forEach {
+ map[it.key()] = it
+ }
+ saveAll(map)
+ }
+
+ fun saveAll(value: Map) {
+ val filteredMap = value.filterValues { it != null && it.source !is SourceInternalContent }
+ val currentSession = Db.currentSession() ?: return
+ currentSession.putAll(filteredMap)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/processor/Exporter.kt b/src/main/kotlin/processor/Exporter.kt
new file mode 100644
index 0000000..94991d6
--- /dev/null
+++ b/src/main/kotlin/processor/Exporter.kt
@@ -0,0 +1,62 @@
+package processor
+
+import com.google.gson.GsonBuilder
+import com.google.gson.ToNumberPolicy
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import models.*
+import utils.Helpers
+import java.io.PrintWriter
+import java.nio.file.Path
+import java.nio.file.StandardOpenOption
+import java.text.SimpleDateFormat
+import kotlin.io.path.bufferedWriter
+
+object Exporter {
+
+ suspend fun exportList(
+ sessionInfo: SessionInfo,
+ list: List,
+ filePath: Path,
+ selectedFormat: ParameterFormats
+ ) = withContext(Dispatchers.IO) {
+ val formatter = SimpleDateFormat("dd-MM-yyyy hh:mm:ss.S")
+ val buffer = filePath.bufferedWriter(options = arrayOf(StandardOpenOption.WRITE, StandardOpenOption.CREATE))
+ PrintWriter(buffer).use { printWriter ->
+ printWriter.append("Session: ")
+ printWriter.append(sessionInfo.description)
+ buffer.newLine()
+ printWriter.append("App package: ")
+ printWriter.append(sessionInfo.appPackage)
+ val gsonBuilder = GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
+ if (selectedFormat == FormatJsonPretty) {
+ gsonBuilder.setPrettyPrinting()
+ }
+ val gson = gsonBuilder.create()
+ val yamlWriter = YamlWriter(printWriter)
+ list.stream().filter { it.source != SourceInternalContent }.forEach {
+ printWriter.newLine()
+ printWriter.newLine()
+ printWriter.append("Event: ")
+ printWriter.append(it.eventName)
+ printWriter.newLine()
+ printWriter.append("Time: ")
+ val time = it.localTime
+ val timeString = formatter.format(time)
+ printWriter.append(timeString)
+ printWriter.newLine()
+ val params = it.properties
+ if (params.isNotEmpty()) {
+ if (selectedFormat == FormatYaml) {
+ Helpers.convertToYaml(params, yamlWriter)
+ } else {
+ gson.toJson(params, printWriter)
+ }
+ }
+ }
+ }
+ }
+
+}
+
+private fun PrintWriter.newLine() = append(System.lineSeparator())
\ No newline at end of file
diff --git a/src/main/kotlin/processor/MainProcessor.kt b/src/main/kotlin/processor/MainProcessor.kt
new file mode 100644
index 0000000..ce426d1
--- /dev/null
+++ b/src/main/kotlin/processor/MainProcessor.kt
@@ -0,0 +1,144 @@
+package processor
+
+import inputs.adb.AndroidLogStreamer
+import inputs.adb.LogCatErrors
+import inputs.adb.LogErrorNoSession
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import models.LogItem
+import models.SessionInfo
+import storage.Db
+import utils.Helpers
+import utils.Log
+import utils.failureOrNull
+import utils.getOrNull
+
+
+class MainProcessor {
+
+ private val streamer = AndroidLogStreamer()
+ private var filterQuery: String? = null
+
+ suspend fun getSessions() = withContext(Dispatchers.IO) {
+ Db.getAllSessions().asReversed()
+ }
+
+ fun getSessionInfo(sessionId: String) = Db.getSessionInfo(sessionId)
+
+ fun isSameSession(sessionId: String) = sessionId == getCurrentSessionId()
+
+ fun startOldSession(session: String) {
+ pause()
+ filterQuery = null
+ Db.changeSession(session)
+ }
+
+ fun createNewSession(sessionInfo: SessionInfo) {
+ pause()
+ filterQuery = null
+ Db.createNewSession(sessionInfo)
+ }
+
+ fun deleteSession(sessionId: String) {
+ if (sessionId == getCurrentSessionId()) {
+ pause()
+ filterQuery = null
+ }
+ Db.deleteSession(sessionId)
+ }
+
+ fun getCurrentSessionId() = Db.sessionId()
+
+ suspend fun fetchOldStream(filterQuery: String? = null, onMessage: (msg: List) -> Unit) =
+ withContext(Dispatchers.IO) {
+ this@MainProcessor.filterQuery = filterQuery
+ val lastItems = Db.currentSession()
+ ?.map { it.value }
+ if (!lastItems.isNullOrEmpty()) {
+ uiFlowSink(flowOf(lastItems), false, onMessage)
+ } else {
+ onMessage(listOf(LogItem.noContent("Record logs using the start button above")))
+ }
+ }
+
+ suspend fun observeNewStream(
+ onError: (logError: LogCatErrors) -> Unit,
+ onMessage: (msg: List) -> Unit
+ ) = withContext(Dispatchers.IO) {
+ val sessionId = getCurrentSessionId()
+ if (sessionId == null) {
+ onError(LogErrorNoSession)
+ return@withContext
+ }
+ val sessionInfo = getSessionInfo(sessionId)
+ if (sessionInfo == null) {
+ onError(LogErrorNoSession)
+ return@withContext
+ }
+ val packageName = sessionInfo.appPackage
+ val stream = streamer.stream(packageName)
+ launch {
+ val successStream = stream.filter { it.isSuccess }.map { it.getOrNull() }
+ .filterNotNull()
+ .mapNotNull { logCatMessage2s ->
+ logCatMessage2s.filter {
+ Helpers.validateFALogString(it.message) && it.header.logLevel != com.android.ddmlib.Log.LogLevel.ERROR
+ }.map {
+ Helpers.parseFALogs(it)
+ }
+ }.buffer()
+ launch {
+ uiFlowSink(successStream, true, onMessage)
+ }
+ launch {
+ successStream.collect { list ->
+ DbSink.saveAll(list)
+ }
+ }
+ }
+ launch {
+ stream.filter { it.isFailure }
+ .map { it.failureOrNull() }
+ .filterNotNull()
+ .collect { onError(it) }
+ }
+ }
+
+ private suspend fun uiFlowSink(
+ logItemStream: Flow>,
+ isNewStream: Boolean,
+ onMessage: (msg: List) -> Unit
+ ) {
+ val indexedCollection by lazy(LazyThreadSafetyMode.NONE) { queryCollection() }
+ val parser by lazy(LazyThreadSafetyMode.NONE) { sqlParser() }
+ val fQuery = filterQuery?.trim()
+ logItemStream.collect { list ->
+ val filterResult = if (fQuery.isNullOrBlank() || fQuery == QUERY_PREFIX) {
+ filterLogs(indexedCollection, list, parser, "Select * from logs")
+ } else {
+ try {
+ filterLogs(indexedCollection, list, parser, fQuery)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ listOf(LogItem.errorContent("Error in query\n${e.message}"))
+ }
+ }
+ if (filterResult.isEmpty() && !isNewStream) {
+ onMessage(listOf(LogItem.noContent("No results found for this query")))
+ } else {
+ onMessage(filterResult)
+ }
+ }
+ }
+
+ fun pause() {
+ try {
+ streamer.stop()
+ } catch (e: Exception) {
+ Log.d("unnecessary", "keeping exception for now in pause")
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/processor/ParameterizedAttribute.kt b/src/main/kotlin/processor/ParameterizedAttribute.kt
new file mode 100644
index 0000000..1a2cb15
--- /dev/null
+++ b/src/main/kotlin/processor/ParameterizedAttribute.kt
@@ -0,0 +1,72 @@
+package processor
+
+import com.googlecode.cqengine.attribute.SimpleNullableAttribute
+import com.googlecode.cqengine.query.option.QueryOptions
+import javassist.NotFoundException
+import models.LogItem
+
+
+class ParameterizedAttribute(private val mapKey: String, private val clazz: Class) :
+ SimpleNullableAttribute(LogItem::class.java, clazz, mapKey) {
+
+ override fun getValue(logItem: LogItem, queryOptions: QueryOptions?): T? {
+ val result = getNestedValue(logItem)
+ if (result == null || attributeType.isAssignableFrom(clazz)) {
+ return clazz.cast(result)
+ }
+ throw ClassCastException("Cannot cast " + result.javaClass.name + " to " + attributeType.name + " for map key: " + mapKey);
+ }
+
+ private fun getNestedValue(logItem: LogItem): Any? {
+ val map = logItem.properties
+ if (map.isEmpty()) {
+ throw NotFoundException("$mapKey not found in properties as it is empty")
+ }
+ val nestedKeys = mapKey.split(".")
+ if (nestedKeys.isEmpty()) {
+ throw IllegalArgumentException("Key should not be empty")
+ }
+ val nSize = nestedKeys.size
+ if (nSize == 1) {
+ return map[mapKey]
+ }
+ var innerMap = map
+ var value: Any? = null
+ nestedKeys.forEachIndexed { index, it ->
+ value = innerMap[it]
+ if (value == null) {
+ return null // todo: not sure about this logic
+ }
+ if (value !is Map<*, *> && index != (nSize - 1)) {
+ val ex = IllegalArgumentException(
+ "Nested structure should be in a map/object. " +
+ "Nested key = $nestedKeys with current key = $it and value = $value.\n" +
+ "Log Item is $logItem"
+ )
+ ex.printStackTrace()
+ return null
+ }
+ if (index != (nSize - 1)) {
+ @Suppress("UNCHECKED_CAST")
+ innerMap = value as HashMap
+ }
+ }
+ return value
+ }
+
+ override fun hashCode(): Int {
+ var result = super.hashCode()
+ result = 31 * result + mapKey.hashCode()
+ return result
+ }
+
+ override fun canEqual(other: Any?): Boolean {
+ return other is ParameterizedAttribute<*>
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return super.equals(other) && mapKey == (other as? ParameterizedAttribute<*>)?.mapKey
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/processor/QueryHelper.kt b/src/main/kotlin/processor/QueryHelper.kt
new file mode 100644
index 0000000..9fbd3a5
--- /dev/null
+++ b/src/main/kotlin/processor/QueryHelper.kt
@@ -0,0 +1,88 @@
+package processor
+
+import com.googlecode.cqengine.ConcurrentIndexedCollection
+import com.googlecode.cqengine.attribute.support.FunctionalSimpleAttribute
+import com.googlecode.cqengine.index.hash.HashIndex
+import com.googlecode.cqengine.index.radix.RadixTreeIndex
+import com.googlecode.cqengine.index.radixinverted.InvertedRadixTreeIndex
+import com.googlecode.cqengine.index.radixreversed.ReversedRadixTreeIndex
+import com.googlecode.cqengine.query.parser.sql.SQLParser
+import models.LogItem
+import utils.Log
+import kotlin.reflect.KProperty1
+import kotlin.time.ExperimentalTime
+import kotlin.time.measureTimedValue
+
+const val QUERY_PREFIX = "Select * from logs where"
+
+inline fun attribute(name: String, accessor: KProperty1): FunctionalSimpleAttribute {
+ return FunctionalSimpleAttribute(O::class.java, A::class.java, name) { accessor.get(it) }
+}
+
+fun queryCollection(): ConcurrentIndexedCollection {
+ return ConcurrentIndexedCollection().apply {
+ addIndex(HashIndex.onAttribute(LogItem.EVENT_NAME))
+ addIndex(RadixTreeIndex.onAttribute(LogItem.EVENT_NAME))
+ addIndex(InvertedRadixTreeIndex.onAttribute(LogItem.EVENT_NAME))
+ addIndex(ReversedRadixTreeIndex.onAttribute(LogItem.EVENT_NAME))
+ }
+}
+
+fun sqlParser(): SQLParser {
+ return SQLParser.forPojo(LogItem::class.java).apply {
+ registerAttribute(LogItem.EVENT_NAME)
+ registerAttribute(LogItem.PROPERTY)
+ }
+}
+
+@OptIn(ExperimentalTime::class)
+fun filterLogs(
+ indexedCollection: ConcurrentIndexedCollection,
+ list: List,
+ parser: SQLParser,
+ filterQuery: String?
+): List {
+ indexedCollection.addAll(list)
+ registerPropertiesInParser(list, parser)
+ val filterResult = measureTimedValue {
+ parser.retrieve(indexedCollection, filterQuery)
+ }
+ Log.d("filtering", "Time taken: ${filterResult.duration} , Retrieval Cost: ${filterResult.value.retrievalCost}")
+ return filterResult.value.toList().sortedBy { it.localTime }
+}
+
+private fun registerPropertiesInParser(
+ list: List,
+ parser: SQLParser
+) {
+ val propertySet = hashSetOf()
+ list.forEach {
+ registerMapPropertiesInParser(it.properties, propertySet, parser)
+ }
+}
+
+private fun registerMapPropertiesInParser(
+ properties: Map,
+ propertySet: HashSet,
+ parser: SQLParser,
+ parentKey: String = ""
+) {
+ properties.forEach { (k, v) ->
+ if (!propertySet.contains(k)) {
+ val att: ParameterizedAttribute = ParameterizedAttribute("$parentKey$k", v.javaClass)
+ if (v is Map<*, *>) {
+ @Suppress("UNCHECKED_CAST")
+ registerMapPropertiesInParser(
+ v as Map, propertySet,
+ parser, "$parentKey$k."
+ )
+ } else {
+ println("Attribute : ${att.attributeName} with first value = $v and v class = ${v.javaClass.name}")
+ parser.registerAttribute(att)
+ propertySet.add(k)
+ }
+ } else {
+// println("Duplicate Attribute : $k = $v")
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/processor/YamlWriter.kt b/src/main/kotlin/processor/YamlWriter.kt
new file mode 100644
index 0000000..0162774
--- /dev/null
+++ b/src/main/kotlin/processor/YamlWriter.kt
@@ -0,0 +1,18 @@
+package processor
+
+import org.snakeyaml.engine.v2.api.StreamDataWriter
+import java.io.PrintWriter
+
+class YamlWriter(private val printWriter: PrintWriter) : StreamDataWriter {
+ override fun write(str: String) {
+ printWriter.write(str)
+ }
+
+ override fun write(str: String, off: Int, len: Int) {
+ printWriter.write(str, off, len)
+ }
+
+ override fun flush() {
+ printWriter.flush()
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/storage/Db.kt b/src/main/kotlin/storage/Db.kt
new file mode 100644
index 0000000..e719590
--- /dev/null
+++ b/src/main/kotlin/storage/Db.kt
@@ -0,0 +1,152 @@
+package storage
+
+import models.LogItem
+import models.SessionInfo
+import org.mapdb.DBMaker
+import org.mapdb.HTreeMap
+import org.mapdb.Serializer
+import storage.serializer.ObjectSerializer
+
+object Db {
+
+ private const val PREFIX = "Session-"
+
+ private val db by lazy {
+ DBMaker.fileDB("sessions.db").fileMmapEnableIfSupported().checksumHeaderBypass().make()
+ }
+
+ private var session: HTreeMap? = null
+ private var sessionId: String? = null
+ private val LOCK = Any()
+
+ val configs by lazy {
+ db.hashMap("configs", Serializer.STRING, Serializer.STRING).createOrOpen()
+ }
+
+ private val sessionInfoMap by lazy {
+ db.hashMap("sessionInfo", Serializer.STRING, ObjectSerializer()).createOrOpen()
+ }
+
+ init {
+ if (!areNoSessionsCreated()) {
+ getOrCreateSession(getPreviousSessionNumber())
+ }
+ }
+
+ fun getSessionInfo(sessionId: String): SessionInfo? {
+ return sessionInfoMap[sessionId]
+ }
+
+ fun getAllSessions() = db.getAllNames().filter { it.startsWith(PREFIX) }.sortedBy { getSessionNumber(it) }
+
+ fun getLastSessionNumber(): Int {
+ val lastSessionId = getAllSessions().lastOrNull() ?: sessionIdFromNumber(0)
+ return getSessionNumber(lastSessionId)
+ }
+
+ fun getPreviousSessionNumber(): Int {
+ val lastDbSessionId = configs["lastSessionId"]
+ val lastSessionId = if (lastDbSessionId.isNullOrBlank() || !lastDbSessionId.startsWith(PREFIX)) {
+ getAllSessions().lastOrNull() ?: sessionIdFromNumber(0)
+ } else {
+ lastDbSessionId
+ }
+ return getSessionNumber(lastSessionId)
+ }
+
+ fun getSessionNumber(sessionId: String) = sessionId.split("-").lastOrNull()?.toIntOrNull() ?: 0
+
+ fun areNoSessionsCreated() = getAllSessions().isEmpty()
+
+ fun isThisTheOnlySession(sessionId: String): Boolean {
+ val sessions = getAllSessions()
+ if (sessions.size != 1) return false
+ return sessions.first() == sessionId
+ }
+
+ fun createNewSession(sessionInfo: SessionInfo) {
+ val sessionNumber = getLastSessionNumber()
+ val sessionIdFromNumber = sessionIdFromNumber(sessionNumber + 1)
+ changeSession(sessionIdFromNumber)
+ sessionInfoMap[sessionIdFromNumber] = sessionInfo
+ }
+
+ fun deleteSession(sessionId: String) {
+ if (sessionId == sessionId() && !isThisTheOnlySession(sessionId)) {
+ val sessionNumber = getLastSessionNumber()
+ changeSession(sessionIdFromNumber(sessionNumber + 1))
+ } else if (sessionId == sessionId()) {
+ changeSession(null)
+ }
+ val oldSession = db.hashMap(sessionId, Serializer.STRING, ObjectSerializer())
+ .open()
+ oldSession.clear()
+ sessionInfoMap.remove(sessionId)
+ val recIds = arrayListOf()
+ db.nameCatalogParamsFor(sessionId).forEach { (t, u) ->
+ if (t.endsWith("rootRecids")) {
+ u.split(",").forEach { value ->
+ val recId = value.trim().toLongOrNull()
+ recId?.let { recIds.add(it) }
+ }
+ return@forEach
+ }
+ }
+ recIds.forEach {
+ db.getStore().delete(it, Serializer.STRING)
+ }
+ val newCatalog = db.nameCatalogLoad()
+ val keys = newCatalog.keys.filter { it.startsWith(sessionId) }
+ keys.forEach {
+ newCatalog.remove(it)
+ }
+ db.nameCatalogSave(newCatalog)
+ db.getAllNames().forEach {
+ println(it)
+ }
+ db.commit()
+ }
+
+ fun changeSession(sessionId: String?) {
+ if (sessionId == null) {
+ getOrCreateSession(null)
+ return
+ }
+ val number = getSessionNumber(sessionId)
+ getOrCreateSession(number)
+ }
+
+ private fun getOrCreateSession(sessionNumber: Int?) {
+ synchronized(LOCK) {
+ if (sessionNumber == null) {
+ this.sessionId = null
+ configs.remove("lastSessionId")
+ this.session = null
+ return
+ }
+ if (sessionNumber < 1) throw Exception("Session number must be greater than 1")
+ val sessionId = sessionIdFromNumber(sessionNumber)
+ val session = db
+ .hashMap(sessionId, Serializer.STRING, ObjectSerializer())
+ .createOrOpen()
+ this.sessionId = sessionId
+ configs["lastSessionId"] = sessionId
+ this.session = session
+ }
+ }
+
+ private fun sessionIdFromNumber(sessionNumber: Int) = "$PREFIX$sessionNumber"
+
+ fun currentSession() = synchronized(LOCK) {
+ session
+ }
+
+ fun sessionId() = synchronized(LOCK) {
+ sessionId
+ }
+
+ fun close() {
+ db.close()
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/storage/serializer/ObjectSerializer.kt b/src/main/kotlin/storage/serializer/ObjectSerializer.kt
new file mode 100644
index 0000000..bd10c03
--- /dev/null
+++ b/src/main/kotlin/storage/serializer/ObjectSerializer.kt
@@ -0,0 +1,71 @@
+package storage.serializer
+
+import org.mapdb.CC
+import org.mapdb.DataInput2
+import org.mapdb.DataOutput2
+import org.mapdb.serializer.GroupSerializerObjectArray
+import java.io.*
+
+
+class ObjectSerializer(val classLoader: ClassLoader = Thread.currentThread().contextClassLoader) : GroupSerializerObjectArray() {
+
+ override fun serialize(out: DataOutput2, value: T) {
+ val out2 = ObjectOutputStream(out as OutputStream)
+ out2.writeObject(value)
+ out2.flush()
+ }
+
+ override fun deserialize(input: DataInput2, available: Int): T {
+ return try {
+ val in2: ObjectInputStream = ObjectInputStreamWithLoader(DataInput2.DataInputToStream(input))
+ in2.readObject() as T
+ } catch (e: ClassNotFoundException) {
+ throw IOException(e)
+ } catch (e: ClassCastException) {
+ throw IOException(e)
+ }
+ }
+
+ @Throws(IOException::class)
+ override fun valueArrayDeserialize(`in`: DataInput2?, size: Int): Array? {
+ return try {
+ val in2: ObjectInputStream = ObjectInputStreamWithLoader(DataInput2.DataInputToStream(`in`))
+ val ret = in2.readObject()
+ if (CC.PARANOID && size != valueArraySize(ret)) throw AssertionError()
+ ret as Array
+ } catch (e: ClassNotFoundException) {
+ throw IOException(e)
+ }
+ }
+
+ @Throws(IOException::class)
+ override fun valueArraySerialize(out: DataOutput2?, vals: Any?) {
+ val out2 = ObjectOutputStream(out as OutputStream?)
+ out2.writeObject(vals)
+ out2.flush()
+ }
+
+ /**
+ * This subclass of ObjectInputStream delegates loading of classes to
+ * an existing ClassLoader.
+ */
+ internal inner class ObjectInputStreamWithLoader
+ /**
+ * Loader must be non-null;
+ */
+ (`in`: InputStream?) : ObjectInputStream(`in`) {
+ /**
+ * Use the given ClassLoader rather than using the system class
+ */
+ @Throws(IOException::class, ClassNotFoundException::class)
+ override fun resolveClass(desc: ObjectStreamClass): Class<*> {
+ val name = desc.name
+ return try {
+ Class.forName(name, false, classLoader)
+ } catch (ex: ClassNotFoundException) {
+ super.resolveClass(desc)
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/CustomHeading.kt b/src/main/kotlin/ui/CustomHeading.kt
new file mode 100644
index 0000000..6fa3867
--- /dev/null
+++ b/src/main/kotlin/ui/CustomHeading.kt
@@ -0,0 +1,21 @@
+package ui
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+@Immutable
+data class CustomHeading(
+ val h2: TextStyle = TextStyle(fontSize = 32.sp),
+ val h3: TextStyle = TextStyle(fontSize = 28.sp),
+ val h5: TextStyle = TextStyle(
+ fontSize = 18.sp, fontWeight = FontWeight.SemiBold,
+ letterSpacing = (1.5).sp
+ ),
+ val h6: TextStyle = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Bold),
+ val h6Semi: TextStyle = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.SemiBold),
+ val h6Medium: TextStyle = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Medium, letterSpacing = (1.2).sp),
+ val caption: TextStyle = TextStyle(fontSize = 12.sp),
+ val semiText: TextStyle = TextStyle(fontSize = 11.sp),
+)
\ No newline at end of file
diff --git a/src/main/kotlin/ui/Theme.kt b/src/main/kotlin/ui/Theme.kt
new file mode 100644
index 0000000..277d35b
--- /dev/null
+++ b/src/main/kotlin/ui/Theme.kt
@@ -0,0 +1,174 @@
+package ui
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.platform.Font
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun AppTheme(isLightTheme: Boolean = true, content: @Composable () -> Unit) =
+ CustomTheme(isLightTheme, content = content)
+
+
+@Immutable
+data class CustomColors(
+ val background: Color = Color.Unspecified,
+ val componentBackground: Color = Color.Unspecified,
+ val componentBackground2: Color = Color.Unspecified,
+ val highContrast: Color = Color.Unspecified,
+ val mediumContrast: Color = Color.Unspecified,
+ val lowContrast: Color = Color.Unspecified,
+ val componentOutline: Color = Color.Unspecified,
+ val accent: Color = Color.Unspecified,
+ val alertColors: CustomAlertColors = CustomAlertColors()
+)
+
+@Immutable
+data class CustomAlertColors(
+ val danger: Color = Color.Unspecified,
+ val success: Color = Color.Unspecified,
+)
+
+@Immutable
+data class CustomTypography(
+ val body: TextStyle,
+ val bodySmall: TextStyle,
+ val title: TextStyle,
+ val headings: CustomHeading
+)
+
+@Immutable
+data class CustomElevation(
+ val default: Dp,
+ val pressed: Dp
+)
+
+val LocalCustomColors = staticCompositionLocalOf {
+ CustomColors()
+}
+val LocalCustomTypography = staticCompositionLocalOf {
+ CustomTypography(
+ body = TextStyle.Default,
+ bodySmall = TextStyle.Default,
+ title = TextStyle.Default,
+ headings = CustomHeading()
+ )
+}
+val LocalCustomElevation = staticCompositionLocalOf {
+ CustomElevation(
+ default = Dp.Unspecified,
+ pressed = Dp.Unspecified
+ )
+}
+val LocalCustomShape = staticCompositionLocalOf {
+ Shapes()
+}
+
+@Composable
+fun CustomTheme(
+ isLightTheme: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val fontFamily = FontFamily(
+ Font("WorkSans-Bold.ttf", FontWeight.Bold),
+ Font("WorkSans-Regular.ttf", FontWeight.Normal),
+ Font("WorkSans-SemiBold.ttf", FontWeight.SemiBold),
+ Font("WorkSans-Medium.ttf", FontWeight.Medium),
+ )
+ val alertColors = CustomAlertColors(
+ danger = Color(0xFFDC3545),
+ success = Color(0xFF28A745)
+ )
+ val customColors = if (isLightTheme) {
+ CustomColors(
+ background = Color(0xFFF5F5F5),
+ componentBackground = Color.White,
+ componentBackground2 = Color(0xFFF9F9F9),
+ highContrast = Color(0xFF364A59),
+ mediumContrast = Color(0xFF566976),
+ lowContrast = Color(0xFFACBAC3),
+ accent = Color(0xFF31AAB7),
+ alertColors = alertColors,
+ componentOutline = Color(0xFFDAE2E8)
+ )
+ } else {
+ CustomColors(
+ background = Color(0xFF2E3438),
+ componentBackground = Color(0xFF111B22),
+ componentBackground2 = Color(0xFF383E42),
+ highContrast = Color.White,
+ mediumContrast = Color(0xFFACBAC3),
+ lowContrast = Color(0xFF566976),
+ accent = Color(0xFF31AAB7),
+ alertColors = alertColors,
+ componentOutline = Color(0xFF3A4349)
+ )
+ }
+ val customTypography = CustomTypography(
+ body = TextStyle(fontSize = 16.sp, fontFamily = fontFamily),
+ bodySmall = TextStyle(fontSize = 12.sp, fontFamily = fontFamily),
+ title = TextStyle(fontSize = 32.sp, fontFamily = fontFamily),
+ headings = CustomHeading()
+ )
+ val customElevation = CustomElevation(
+ default = 4.dp,
+ pressed = 8.dp
+ )
+ val customShapes = Shapes(
+ small = RoundedCornerShape(8.dp), medium = RoundedCornerShape(8.dp),
+ large = RoundedCornerShape(8.dp)
+ )
+ val materialColors = Colors(
+ primary = customColors.accent,
+ background = customColors.background,
+ surface = customColors.componentBackground,
+ onPrimary = customColors.componentBackground,
+ primaryVariant = customColors.accent.copy(alpha = 0.7f),
+ secondary = customColors.highContrast, secondaryVariant = customColors.mediumContrast,
+ error = customColors.alertColors.danger,
+ onSecondary = customColors.componentBackground,
+ onBackground = customColors.highContrast,
+ onError = customColors.componentBackground,
+ onSurface = customColors.highContrast, isLight = isLightTheme
+ )
+ MaterialTheme(
+ typography = Typography(defaultFontFamily = fontFamily), shapes = customShapes,
+ colors = materialColors
+ ) {
+ CompositionLocalProvider(
+ LocalCustomColors provides customColors,
+ LocalCustomTypography provides customTypography,
+ LocalCustomElevation provides customElevation,
+ LocalCustomShape provides customShapes,
+ LocalContentColor provides customColors.highContrast
+ ) {
+ content()
+ }
+ }
+}
+
+// Use with eg. CustomTheme.elevation.small
+object CustomTheme {
+ val colors: CustomColors
+ @Composable
+ get() = LocalCustomColors.current
+ val typography: CustomTypography
+ @Composable
+ get() = LocalCustomTypography.current
+ val elevation: CustomElevation
+ @Composable
+ get() = LocalCustomElevation.current
+ val shapes: Shapes
+ @Composable
+ get() = LocalCustomShape.current
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/ActionBar.kt b/src/main/kotlin/ui/components/ActionBar.kt
new file mode 100644
index 0000000..299d0c0
--- /dev/null
+++ b/src/main/kotlin/ui/components/ActionBar.kt
@@ -0,0 +1,63 @@
+package ui.components
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import ui.views.flow.FlowRow
+
+@Composable
+fun ActionBar(
+ menus: List = ActionMenu.DefaultList,
+ modifier: Modifier = Modifier,
+ onMenuClick: (action: ActionMenu) -> Unit
+) {
+ FlowRow(modifier, mainAxisSpacing = 16.dp, crossAxisSpacing = 16.dp) {
+ menus.forEach {
+ val painter = painterResource(it.icon)
+ val onClick = {
+ onMenuClick(it)
+ }
+ if (it.isPrimary) {
+ Button(onClick, content = {
+ ButtonContent(painter, it, ButtonDefaults.buttonColors())
+ }, shape = RoundedCornerShape(4.dp), elevation = ButtonDefaults.elevation(0.dp))
+ } else {
+ TextButton(onClick, content = {
+ ButtonContent(painter, it, ButtonDefaults.textButtonColors())
+ })
+ }
+ }
+ }
+}
+
+@Composable
+private fun ButtonContent(
+ painter: Painter,
+ it: ActionMenu,
+ bgColors: ButtonColors
+) {
+ Icon(
+ painter, it.text, Modifier.size(24.dp),
+ tint = bgColors.contentColor(true).value
+ )
+ Text(it.text, Modifier.padding(start = 8.dp))
+}
+
+sealed class ActionMenu(val text: String, val isPrimary: Boolean, val icon: String = "") {
+ companion object {
+ val DefaultList = arrayListOf(ActionStart, ActionExport, ActionFeedback)
+ val PauseList = arrayListOf(ActionPause, ActionExport, ActionFeedback)
+ }
+}
+
+// TODO: Add enable flag to disable button when maybe device is not connected or there is no data to export
+object ActionStart : ActionMenu("Start", isPrimary = true, icon = "icons/ico_play.svg")
+object ActionPause : ActionMenu("Pause", isPrimary = true, icon = "icons/ico_pause.svg")
+object ActionExport : ActionMenu("Export Session Data", isPrimary = false, icon = "icons/ico-share.svg")
+object ActionFeedback : ActionMenu("Feedback", isPrimary = false, icon = "icons/ico-email.svg")
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/BasicComponents.kt b/src/main/kotlin/ui/components/BasicComponents.kt
new file mode 100644
index 0000000..d41c813
--- /dev/null
+++ b/src/main/kotlin/ui/components/BasicComponents.kt
@@ -0,0 +1,92 @@
+package ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.text.ClickableText
+import androidx.compose.material.Icon
+import androidx.compose.material.Switch
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
+import ui.CustomTheme
+
+@Composable
+fun SwitchItem(
+ checked: Boolean,
+ title: String,
+ modifier: Modifier = Modifier,
+ subTitle: String? = null,
+ icon: Painter? = null,
+ onCheckedChange: ((Boolean) -> Unit)?
+) {
+ Row(modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ if (icon != null) {
+ Icon(icon, title, Modifier.padding(top = 4.dp))
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(title, style = CustomTheme.typography.headings.h6Medium)
+ if (subTitle != null) {
+ Text(
+ subTitle,
+ style = CustomTheme.typography.headings.semiText,
+ color = CustomTheme.colors.mediumContrast
+ )
+ }
+ }
+ Switch(checked, onCheckedChange, Modifier.height(20.dp))
+ }
+}
+
+@Composable
+fun SimpleListItem(
+ title: String?,
+ modifier: Modifier = Modifier,
+ subTitle: String? = null,
+ icon: Painter? = null
+) {
+ Row(modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ if (icon != null) {
+ Icon(icon, title, Modifier.padding(top = 4.dp))
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (title != null) {
+ Text(title, style = CustomTheme.typography.headings.h6Medium)
+ }
+ if (subTitle != null) {
+ Text(
+ subTitle,
+ style = CustomTheme.typography.headings.semiText,
+ color = CustomTheme.colors.mediumContrast
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ClickableListItem(
+ title: AnnotatedString?,
+ modifier: Modifier = Modifier,
+ subTitle: AnnotatedString? = null,
+ icon: Painter? = null,
+ onClick: (Int) -> Unit
+) {
+ Row(modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ if (icon != null) {
+ Icon(icon, title?.text ?: "Icon", Modifier.padding(top = 4.dp))
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (title != null) {
+ Text(title, style = CustomTheme.typography.headings.h6Medium)
+ }
+ if (subTitle != null) {
+ ClickableText(
+ subTitle,
+ style = CustomTheme.typography.headings.semiText, onClick = onClick
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/BodyHeader.kt b/src/main/kotlin/ui/components/BodyHeader.kt
new file mode 100644
index 0000000..2b70e62
--- /dev/null
+++ b/src/main/kotlin/ui/components/BodyHeader.kt
@@ -0,0 +1,148 @@
+package ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.dp
+import processor.QUERY_PREFIX
+import ui.CustomTheme
+
+@Composable
+fun BodyHeader(
+ sessionId: String?, modifier: Modifier = Modifier,
+ filtersEnabled: Boolean = true,
+ onFilterUpdated: (filterText: String) -> Unit
+) = FilterSearchHeader(modifier, sessionId, filtersEnabled, onFilterUpdated)
+
+@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
+@Composable
+private fun FilterSearchHeader(
+ modifier: Modifier,
+ sessionId: String?,
+ filtersEnabled: Boolean,
+ onFilterUpdated: (filterText: String) -> Unit
+) {
+ var filterText by remember(sessionId) { mutableStateOf(TextFieldValue("")) }
+ val focusManager = LocalFocusManager.current
+ var isFocused by remember { mutableStateOf(false) }
+
+ fun sendFilterBack() {
+ val text = QUERY_PREFIX + " " + filterText.text.trim()
+ onFilterUpdated(text)
+ }
+
+ Row(modifier, verticalAlignment = Alignment.CenterVertically) {
+ val m1 = Modifier.weight(1f).padding(horizontal = 20.dp, vertical = 8.dp).onPreviewKeyEvent {
+ if (it.key == Key.Enter) {
+ sendFilterBack()
+ true
+ } else
+ false
+ }.onFocusChanged {
+ isFocused = it.isFocused
+ }
+ FilterSearchBar(filterText, m1, filtersEnabled, isFocused) {
+ filterText = it
+ }
+ HeaderEndIconsPanel(filterText.text, isFocused, Modifier.height(IntrinsicSize.Max).padding(end = 8.dp), {
+ sendFilterBack()
+ }, {
+ filterText = TextFieldValue()
+ focusManager.clearFocus()
+ onFilterUpdated("")
+ })
+ }
+}
+
+@Composable
+private fun FilterSearchBar(
+ filterText: TextFieldValue,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ isFocused: Boolean = false,
+ endIcons: @Composable (() -> Unit)? = null,
+ onValueChange: (TextFieldValue) -> Unit
+) {
+ val colors = TextFieldDefaults.textFieldColors(
+ backgroundColor = CustomTheme.colors.componentBackground,
+ focusedIndicatorColor = Color.Unspecified,
+ unfocusedIndicatorColor = Color.Unspecified,
+ )
+ val placeholderText = if (isFocused) {
+ ""
+ } else if (enabled) {
+ "Filter logs..."
+ } else {
+ "Pause stream to filter logs..."
+ }
+ TextField(filterText, onValueChange, modifier, enabled = enabled, placeholder = {
+ Text(placeholderText, color = CustomTheme.colors.lowContrast)
+ }, leadingIcon = {
+ val painter = painterResource("icons/ico-search.svg")
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(painter, "Search filters", tint = CustomTheme.colors.highContrast)
+ Spacer(Modifier.width(8.dp))
+ if (isFocused || filterText.text.isNotBlank()) {
+ Text(QUERY_PREFIX, color = CustomTheme.colors.lowContrast)
+ }
+ }
+ }, trailingIcon = endIcons, shape = RectangleShape, colors = colors, singleLine = true
+ )
+}
+
+@Composable
+private fun HeaderEndIconsPanel(
+ text: String,
+ isFocused: Boolean,
+ modifier: Modifier = Modifier,
+ onSearchClick: () -> Unit, onCloseClick: () -> Unit
+) {
+ Row(
+ modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ if (text.isNotBlank()) {
+ IconButton(onSearchClick) {
+ val painter = painterResource("icons/ico_filter.svg")
+ Icon(painter, "Close", tint = CustomTheme.colors.highContrast)
+ }
+ }
+ if (isFocused) {
+ IconButton(onCloseClick) {
+ val painter = painterResource("icons/ico_close.xml")
+ Icon(painter, "Close", tint = CustomTheme.colors.highContrast)
+ }
+ }
+ IconButton({}) {
+ val painter = painterResource("icons/ico_info.svg")
+ Icon(painter, "Close", tint = CustomTheme.colors.highContrast)
+ }
+ Box(Modifier.width(1.dp).fillMaxHeight().background(CustomTheme.colors.lowContrast))
+ var showSettingDialog by remember { mutableStateOf(false) }
+ IconButton({
+ showSettingDialog = true
+ }) { // TODO: Settings
+ val painter = painterResource("icons/ico-settings.svg")
+ Icon(painter, "Settings", tint = CustomTheme.colors.highContrast)
+ }
+ if (showSettingDialog) {
+ SettingsDialog {
+ showSettingDialog = false
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/BodyPanel.kt b/src/main/kotlin/ui/components/BodyPanel.kt
new file mode 100644
index 0000000..68ad4fd
--- /dev/null
+++ b/src/main/kotlin/ui/components/BodyPanel.kt
@@ -0,0 +1,291 @@
+package ui.components
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.LocalScrollbarStyle
+import androidx.compose.foundation.VerticalScrollbar
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.rememberScrollbarAdapter
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import inputs.adb.*
+import inputs.adb.ddmlib.Devices
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import models.LogItem
+import models.SourceInternalContent
+import processor.MainProcessor
+import ui.CustomTheme
+
+@Composable
+fun BodyPanel(
+ processor: MainProcessor,
+ sessionId: String?,
+ modifier: Modifier = Modifier
+) {
+ val logItems = remember(sessionId) { mutableStateListOf() }
+ var streamRunning by remember(sessionId) { mutableStateOf(false) }
+ Column(modifier) {
+ val scope = rememberCoroutineScope()
+ val state = rememberSaveable(saver = LazyListState.Saver, key = sessionId) {
+ LazyListState()
+ }
+ var actionMenuItems by remember(sessionId) { mutableStateOf(ActionMenu.DefaultList) }
+ val currentDevice by Devices.currentDeviceFlow.collectAsState()
+ var errorString by remember(currentDevice) {
+ mutableStateOf(if (currentDevice == null) "No device is connected" else "")
+ }
+ val onNewMessage: (msg: List) -> Unit = { msg ->
+ logItems.addAll(msg)
+ }
+ val onError: (logError: LogCatErrors) -> Unit = {
+ actionMenuItems = ActionMenu.DefaultList
+ streamRunning = false
+ errorString = when (it) {
+ is LogErrorADBIssue -> {
+ "There is some issue with device. Check if your device is connected and your app is running"
+ }
+ is LogErrorDeviceNotConnected -> {
+ "Please connect your device or start an emulator"
+ }
+ is LogErrorNoSession -> {
+ "Create a new session to start logging data"
+ }
+ is LogErrorNotEnabledForFA -> {
+ "Unable to enable logs for firebase"
+ }
+ is LogErrorPackageIssue -> {
+ "The app might not be installed or app processed is not running on the device. Please check."
+ }
+ is LogErrorUnknown -> {
+ "This is some unknown error in collecting logs. \n ${it.exception.localizedMessage}"
+ }
+ }
+ }
+
+ fun oldStreamFun(filterQuery: String? = null) {
+ fetchOldData(processor, scope, filterQuery) {
+ onNewMessage(it)
+ if (logItems.isNotEmpty()) {
+ scope.launch {
+ state.scrollToItem((logItems.size - 1).coerceAtLeast(0))
+ }
+ }
+ }
+ }
+ BodyHeader(
+ sessionId,
+ Modifier.fillMaxWidth().background(CustomTheme.colors.componentBackground),
+ !streamRunning
+ ) {
+ logItems.clear()
+ oldStreamFun(it)
+ }
+ if (errorString.isNotBlank()) {
+ ErrorBar(errorString)
+ }
+ var isOpen by remember { mutableStateOf(false) }
+ if (isOpen) {
+ val sessionInfo = processor.getSessionInfo(sessionId.orEmpty())
+ if (sessionInfo != null) {
+ ExportDialog(sessionInfo, logItems) {
+ isOpen = false
+ }
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ "Analytics Logs", Modifier.padding(24.dp),
+ style = CustomTheme.typography.headings.h3
+ )
+ if (!sessionId.isNullOrBlank()) {
+ ActionBar(
+ actionMenuItems, Modifier
+ .padding(horizontal = 24.dp, vertical = 8.dp)
+ ) {
+ when (it) {
+ ActionExport -> {
+ isOpen = true
+ }
+ ActionPause -> {
+ pauseProcessor(processor)
+ actionMenuItems = ActionMenu.DefaultList
+ streamRunning = false
+ }
+ ActionStart -> {
+ streamData(processor, scope, onError, onNewMessage)
+ actionMenuItems = ActionMenu.PauseList
+ streamRunning = true
+ errorString = ""
+ }
+ }
+ }
+ }
+ }
+ MainBodyContent(logItems, Modifier.fillMaxSize(), streamRunning, sessionId, state)
+ LaunchedEffect(sessionId) {
+ oldStreamFun()
+ }
+ }
+}
+
+@Composable
+private fun ErrorBar(errorString: String) {
+ Row(
+ Modifier.fillMaxWidth().background(CustomTheme.colors.alertColors.danger)
+ .padding(24.dp, 8.dp), horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ painterResource("icons/ico-alert.svg"), "Alert",
+ tint = Color.White
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(errorString, color = Color.White)
+ }
+}
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+private fun MainBodyContent(
+ logItems: SnapshotStateList, modifier: Modifier = Modifier,
+ streamRunning: Boolean = false,
+ sessionId: String?,
+ state: LazyListState
+) {
+ val lastIndex = (logItems.size - 1).coerceAtLeast(0)
+ Row(modifier) {
+ var selectedItem by remember(sessionId) { mutableStateOf(null) }
+ LogListView(
+ logItems, state, lastIndex, streamRunning,
+ Modifier.fillMaxHeight().weight(0.6f)
+ ) {
+ selectedItem?.isSelected = false
+ selectedItem = it
+ selectedItem?.isSelected = true
+ }
+ if (isHaveLogItems(logItems)) {
+ DetailedCard(
+ selectedItem,
+ Modifier.fillMaxHeight().weight(0.4f)
+ ) {
+ selectedItem?.isSelected = false
+ selectedItem = null
+ }
+ }
+ }
+}
+
+private fun isHaveLogItems(logItems: SnapshotStateList) =
+ logItems.isNotEmpty() && !(logItems.size == 1 && logItems.first().source == SourceInternalContent)
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+private fun DetailedCard(selectedItem: LogItem?, modifier: Modifier, onCloseClick: () -> Unit) {
+ Card(
+ modifier,
+ shape = RoundedCornerShape(topStart = 16.dp),
+ elevation = 8.dp
+ ) {
+ AnimatedContent(selectedItem) {
+ if (it != null) {
+ DetailCard(it, Modifier.fillMaxSize(), onCloseClick)
+ } else {
+ DetailCardEmpty()
+ }
+ }
+ }
+}
+
+@Composable
+private fun LogListView(
+ logItems: SnapshotStateList,
+ state: LazyListState,
+ lastIndex: Int,
+ streamRunning: Boolean,
+ modifier: Modifier,
+ onClick: (logItem: LogItem) -> Unit
+) {
+ Box(modifier) {
+ if (logItems.isNotEmpty()) {
+ LogList(logItems, state = state, onClick = onClick)
+ VerticalScrollbar(
+ modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight().padding(end = 4.dp),
+ adapter = rememberScrollbarAdapter(
+ scrollState = state
+ ),
+ reverseLayout = true,
+ style = LocalScrollbarStyle.current.copy(minimalHeight = 24.dp)
+ )
+ if (isHaveLogItems(logItems)) {
+ PortalToTopButton(state, lastIndex, Modifier.align(Alignment.BottomEnd).padding(24.dp))
+ }
+ } else {
+ LoadingAnimation(Modifier.align(Alignment.Center))
+ }
+ }
+}
+
+@Composable
+fun LoadingAnimation(modifier: Modifier = Modifier) {
+ CircularProgressIndicator(modifier)
+}
+
+@Composable
+private fun PortalToTopButton(state: LazyListState, lastIndex: Int, modifier: Modifier = Modifier) {
+ val scope = rememberCoroutineScope()
+ ExtendedFloatingActionButton({
+ Text("Scroll to Top")
+ }, {
+ scope.launch {
+ state.animateScrollToItem(lastIndex)
+ }
+ }, modifier, {
+ Icon(
+ painterResource("icons/ico-carrot-right.svg"), "scroll up",
+ Modifier.rotate(-90f)
+ )
+ })
+}
+
+private fun streamData(
+ processor: MainProcessor, scope: CoroutineScope,
+ onError: (logError: LogCatErrors) -> Unit, onMessage: (msg: List) -> Unit
+) {
+ scope.launch {
+ processor.observeNewStream(onError) { msg ->
+// Log.d("Got Message" , msg)
+ onMessage(msg)
+ }
+ }
+}
+
+private fun fetchOldData(
+ processor: MainProcessor,
+ scope: CoroutineScope,
+ filterQuery: String? = null,
+ onMessage: (msg: List) -> Unit
+) {
+ scope.launch {
+ processor.fetchOldStream(filterQuery, onMessage)
+ }
+}
+
+private fun pauseProcessor(processor: MainProcessor) {
+ try {
+ processor.pause()
+ } catch (ex: CancelException) {
+ println(ex.message)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/Chip.kt b/src/main/kotlin/ui/components/Chip.kt
new file mode 100644
index 0000000..f340e70
--- /dev/null
+++ b/src/main/kotlin/ui/components/Chip.kt
@@ -0,0 +1,41 @@
+package ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import ui.CustomTheme
+import ui.LocalCustomColors
+import ui.LocalCustomTypography
+
+@Composable
+fun Chip(
+ text: String, modifier: Modifier = Modifier,
+ bgColor: Color = LocalCustomColors.current.componentBackground,
+ textColor: Color = LocalCustomColors.current.highContrast,
+ addBorder: Boolean = false,
+ icon: @Composable (() -> Unit)? = null,
+) {
+ var modifier1 = modifier.background(bgColor, CustomTheme.shapes.small)
+ if (addBorder) {
+ modifier1 = modifier1
+ .border((0.2).dp, textColor, CustomTheme.shapes.small)
+ }
+ modifier1 = modifier1.padding(horizontal = 8.dp, vertical = 4.dp)
+ Row(
+ modifier1, verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(text, color = textColor, style = LocalCustomTypography.current.bodySmall)
+ if (icon != null) {
+ Box(Modifier.size(18.dp)) {
+ icon()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/CustomDialog.kt b/src/main/kotlin/ui/components/CustomDialog.kt
new file mode 100644
index 0000000..11568e1
--- /dev/null
+++ b/src/main/kotlin/ui/components/CustomDialog.kt
@@ -0,0 +1,125 @@
+package ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.rememberDialogState
+import com.google.common.primitives.Floats
+import ui.CustomTheme
+
+@Composable
+fun StyledCustomVerticalDialog(onDismissRequest: () -> Unit, content: @Composable BoxScope.() -> Unit) {
+ CustomDialog(
+ dialogWidthRatio = 0.28f,
+ dialogHeightRatio = 0.56f, onDismissRequest = onDismissRequest
+ ) {
+ Box {
+ val painter = painterResource("icons/layered_waves.svg")
+ Image(painter, "styled", Modifier.fillMaxWidth()
+ .graphicsLayer {
+ rotationX = 180f
+ rotationY = 180f
+ translationY = -50f
+ }
+ .align(Alignment.BottomCenter), Alignment.BottomCenter)
+ content()
+ }
+ }
+}
+
+@Composable
+fun SimpleVerticalDialog(header: String, onDismissRequest: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
+ CustomDialog(
+ dialogWidthRatio = 0.28f,
+ dialogHeightRatio = 0.56f, onDismissRequest = onDismissRequest
+ ) {
+ Column(Modifier.fillMaxSize().padding(16.dp)) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(header, Modifier.weight(1f), style = CustomTheme.typography.headings.h2)
+ IconButton(
+ onDismissRequest, Modifier.size(36.dp).background(
+ CustomTheme.colors.componentBackground2,
+ CircleShape
+ )
+ ) {
+ Icon(painterResource("icons/ico_close.xml"), "Close")
+ }
+ }
+ Spacer(Modifier.height(16.dp))
+ Divider(color = CustomTheme.colors.componentOutline, thickness = (0.5).dp)
+ Spacer(Modifier.height(16.dp))
+ content()
+ }
+ }
+}
+
+@Suppress("UnstableApiUsage")
+@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
+@Composable
+fun CustomDialog(
+ backgroundAlpha: Float = 0.5f,
+ dialogWidthRatio: Float = 0.4f,
+ dialogHeightRatio: Float = 0.4f,
+ dialogShape: Shape = RoundedCornerShape(8.dp),
+ dialogElevation: Dp = 8.dp,
+ onDismissRequest: () -> Unit,
+ content: @Composable () -> Unit
+) {
+ Floats.constrainToRange(backgroundAlpha, 0.0f, 1.0f)
+ Floats.constrainToRange(dialogWidthRatio, 0.1f, 1.0f)
+ Floats.constrainToRange(dialogHeightRatio, 0.1f, 1.0f)
+ with(UndecoratedWindowAlertDialogProvider) {
+ AlertDialog(onDismissRequest) {
+ Dialog(
+ onCloseRequest = onDismissRequest,
+ state = rememberDialogState(width = Dp.Unspecified, height = Dp.Unspecified),
+ undecorated = true,
+ resizable = false,
+ transparent = true,
+ onKeyEvent = {
+ if (it.key == Key.Escape) {
+ onDismissRequest()
+ true
+ } else {
+ false
+ }
+ },
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color.DarkGray.copy(backgroundAlpha))
+ .clickable(MutableInteractionSource(), null) { onDismissRequest() },
+ contentAlignment = Alignment.Center
+ ) {
+ Surface(
+ Modifier
+ .fillMaxWidth(dialogWidthRatio)
+ .fillMaxHeight(dialogHeightRatio)
+ .clickable(MutableInteractionSource(), null) {}, dialogShape, elevation = dialogElevation
+ ) {
+ content()
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/DetailCard.kt b/src/main/kotlin/ui/components/DetailCard.kt
new file mode 100644
index 0000000..9cce4ba
--- /dev/null
+++ b/src/main/kotlin/ui/components/DetailCard.kt
@@ -0,0 +1,95 @@
+package ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Divider
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Popup
+import models.LogItem
+import ui.CustomTheme
+
+@Composable
+fun DetailCard(logItem: LogItem, modifier: Modifier = Modifier, onCloseClick: () -> Unit) {
+ val text = logItem.propertiesAString ?: AnnotatedString("")
+ Column(modifier) {
+ var copyClicked by remember { mutableStateOf(false) }
+ DetailHeader(logItem, Modifier.fillMaxWidth().padding(16.dp), onCloseClick) {
+ copyClicked = true
+ }
+ if (copyClicked) {
+ val copyText = AnnotatedString(logItem.eventName + "\n\n") + text
+ LocalClipboardManager.current.setText(copyText)
+ Popup { Text("Text Copied") } // TODO: Change this to inline or something else
+ }
+ Divider(Modifier.padding(horizontal = 16.dp).fillMaxWidth(), Color.Gray)
+ val scrollState = rememberScrollState()
+ SelectionContainer {
+ Text(text, Modifier.padding(16.dp).fillMaxHeight().verticalScroll(scrollState), lineHeight = 8.sp)
+ }
+ }
+}
+
+@Composable
+fun DetailCardEmpty() {
+ Column(
+ Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // show empty state
+ Image(
+ painterResource("icons/waiting.svg"),
+ "Select log",
+ Modifier.fillMaxWidth(0.6f).graphicsLayer { rotationY = 180f },
+ contentScale = ContentScale.FillWidth
+ )
+ Spacer(Modifier.height(24.dp))
+ Text("Select log to see full details", textAlign = TextAlign.Center)
+ }
+}
+
+@Composable
+fun DetailHeader(
+ logItem: LogItem, modifier: Modifier = Modifier, onCloseClick: () -> Unit,
+ onCopyClick: () -> Unit
+) {
+ // TODO: Move to something like in ActionBar
+ Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onCloseClick) {
+ Icon(
+ painterResource("icons/ico_close.xml"), "Close",
+ tint = CustomTheme.colors.highContrast
+ )
+ }
+ LogIcon(logItem)
+ LogTitle(logItem, Modifier.padding(8.dp))
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton({
+ onCopyClick()
+ }) {
+ Icon(
+ painterResource("icons/ico_copy.svg"), "Copy",
+ tint = CustomTheme.colors.highContrast
+ )
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/ui/components/DeviceList.kt b/src/main/kotlin/ui/components/DeviceList.kt
new file mode 100644
index 0000000..d141423
--- /dev/null
+++ b/src/main/kotlin/ui/components/DeviceList.kt
@@ -0,0 +1,63 @@
+package ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import inputs.adb.ddmlib.Devices
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import models.DeviceDetails2
+import ui.CustomTheme
+
+@Composable
+fun DeviceList(devices: List, modifier: Modifier = Modifier) {
+ val scope = rememberCoroutineScope { Dispatchers.IO }
+ val currentDeviceSelected by Devices.currentDeviceFlow.collectAsState()
+ if (devices.isEmpty()) {
+ scope.launch {
+ Devices.setCurrentDevice(null)
+ }
+ } else if (devices.size == 1) {
+ scope.launch {
+ Devices.setCurrentDevice(devices.first())
+ }
+ }
+ LazyColumn(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(devices, { item: DeviceDetails2 -> item.serial }) {
+ val shape = RoundedCornerShape(0, 50, 50, 0)
+ var modifier1 = Modifier.clip(shape).clickable {
+ Devices.setCurrentDevice(it)
+ }.fillMaxWidth(0.8f)
+ if (it == currentDeviceSelected) {
+ modifier1 = modifier1.background(
+ CustomTheme.colors.accent.copy(0.4f),
+ shape
+ )
+ }
+ modifier1 = modifier1.padding(start = 24.dp, top = 8.dp, bottom = 8.dp, end = 4.dp)
+ val stateColor = if (it.isOnline()) {
+ CustomTheme.colors.alertColors.success
+ } else {
+ CustomTheme.colors.alertColors.danger
+ }
+ Column(modifier1, verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(it.name)
+ Text(it.stateText(), style = CustomTheme.typography.headings.caption, color = stateColor)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/ExportDialog.kt b/src/main/kotlin/ui/components/ExportDialog.kt
new file mode 100644
index 0000000..8f1b850
--- /dev/null
+++ b/src/main/kotlin/ui/components/ExportDialog.kt
@@ -0,0 +1,139 @@
+package ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.capitalize
+import androidx.compose.ui.text.intl.Locale
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import models.*
+import processor.Exporter
+import storage.Db
+import ui.CustomTheme
+import utils.Helpers
+import java.nio.file.Path
+import kotlin.io.path.absolutePathString
+
+@Composable
+fun ExportDialog(
+ sessionInfo: SessionInfo, logItems: List,
+ onDismissRequest: () -> Unit
+) {
+ StyledCustomVerticalDialog(onDismissRequest) {
+ Column(Modifier.fillMaxHeight().padding(16.dp)) {
+ var exportFilteredLogs by remember { mutableStateOf(true) }
+ var isFileSelectorOpen by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ Text("Export Session", style = CustomTheme.typography.headings.h2)
+ Spacer(Modifier.height(24.dp))
+ SelectCheckBox(exportFilteredLogs, "Export filtered logs") {
+ exportFilteredLogs = !exportFilteredLogs
+ }
+ Spacer(Modifier.height(16.dp))
+ Text("Parameter formats:")
+ Spacer(Modifier.height(8.dp))
+ var selectedFormat by remember { mutableStateOf(FormatJsonPretty) }
+ ParameterFormats(selectedFormat) {
+ selectedFormat = it
+ }
+ Spacer(Modifier.height(24.dp))
+ Button({
+ isFileSelectorOpen = true
+ }) {
+ Icon(painterResource("icons/ico-share.svg"), "Export session")
+ Text("Export")
+ }
+ if (isFileSelectorOpen) {
+ val fileName = sessionInfo.description.replace(" ", "_").capitalize(Locale.current)
+ val appended = if (selectedFormat == FormatYaml) {
+ "_yaml"
+ } else "_json"
+ ExportFile("$fileName$appended.txt") { path ->
+ isFileSelectorOpen = false
+ scope.launch(Dispatchers.IO) {
+ val logs = getListForExport(exportFilteredLogs, logItems)
+ Exporter.exportList(sessionInfo, logs, path, selectedFormat)
+ Db.configs["lastExportFolder"] = path.parent.absolutePathString()
+ Helpers.openFileExplorer(path.parent)
+ Helpers.openFileExplorer(path)
+ onDismissRequest()
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun getListForExport(
+ exportFilteredLogs: Boolean,
+ logItems: List
+) = if (exportFilteredLogs) {
+ logItems
+} else {
+ val session = Db.currentSession()
+ session?.map { it.value }?.sortedBy { it.localTime } ?: emptyList()
+}
+
+@Composable
+private fun ExportFile(fileName: String, onResult: (result: Path) -> Unit) {
+ FileDialog("Choose file to save", fileName) { path ->
+ if (path != null) {
+ onResult(path)
+ }
+ }
+}
+
+@Composable
+private fun ParameterFormats(
+ selected: ParameterFormats,
+ formats: List = DefaultFormats,
+ modifier: Modifier = Modifier,
+ onSelected: (format: ParameterFormats) -> Unit
+) {
+ Column(modifier) {
+ formats.forEach {
+ SelectRadioButton(selected == it, it.text) {
+ onSelected(it)
+ }
+ }
+ }
+
+}
+
+@Composable
+private fun SelectRadioButton(selected: Boolean, text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
+ Row(
+ modifier.clickable(MutableInteractionSource(), null, onClick = { onClick() }),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(selected, onClick)
+ Text(
+ text = text,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+ }
+}
+
+@Composable
+private fun SelectCheckBox(
+ selected: Boolean, text: String, modifier: Modifier = Modifier,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier.clickable(MutableInteractionSource(), null, onClick = { onClick() }),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(selected, null)
+ Text(
+ text = text,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/FileDialog.kt b/src/main/kotlin/ui/components/FileDialog.kt
new file mode 100644
index 0000000..0b8274a
--- /dev/null
+++ b/src/main/kotlin/ui/components/FileDialog.kt
@@ -0,0 +1,41 @@
+package ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.window.AwtWindow
+import storage.Db
+import java.awt.FileDialog
+import java.awt.Frame
+import java.io.File
+import java.nio.file.Path
+
+@Composable
+fun FileDialog(
+ title: String,
+ fileName: String = "file.txt",
+ isLoad: Boolean = false,
+ parent: Frame? = null,
+ onResult: (result: Path?) -> Unit
+) = AwtWindow(
+ create = {
+ object : FileDialog(parent, title, if (isLoad) LOAD else SAVE) {
+ override fun setVisible(value: Boolean) {
+ super.setVisible(value)
+ if (value) {
+ if (file != null) {
+ onResult(File(directory).resolve(file).toPath())
+ } else {
+ onResult(null)
+ }
+ }
+ }
+ }.apply {
+ this.title = title
+ val lastFolderPath = Db.configs["lastExportFolder"]
+ if (!lastFolderPath.isNullOrBlank()) {
+ this.directory = lastFolderPath
+ }
+ this.file = fileName
+ }
+ },
+ dispose = FileDialog::dispose
+)
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/ListItemInternalContent.kt b/src/main/kotlin/ui/components/ListItemInternalContent.kt
new file mode 100644
index 0000000..5232d1a
--- /dev/null
+++ b/src/main/kotlin/ui/components/ListItemInternalContent.kt
@@ -0,0 +1,54 @@
+package ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.Card
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import models.ErrorContent
+import models.InternalContent
+import models.NoLogsContent
+import storage.Db
+import ui.CustomTheme
+
+@Composable
+fun ListItemInternalContent(internalContent: InternalContent?, modifier: Modifier = Modifier) {
+ if (internalContent == null) return
+ when (internalContent) {
+ is NoLogsContent -> ListItemEmptyContent(internalContent, modifier)
+ is ErrorContent -> ListErrorContent(internalContent, modifier)
+ }
+}
+
+@Composable
+private fun ListItemEmptyContent(noLogsContent: NoLogsContent, modifier: Modifier = Modifier) {
+ Card(modifier) {
+ val text = if (Db.sessionId() == null) {
+ "Create a new session from side panel"
+ } else {
+ noLogsContent.msg
+ }
+ Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Image(painterResource("icons/empty_state.svg"), "Use start to log events",
+ Modifier.fillMaxWidth(0.5f).graphicsLayer { rotationY = 180f })
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text, textAlign = TextAlign.Center,
+ style = CustomTheme.typography.headings.h5
+ )
+ }
+ }
+}
+
+@Composable
+private fun ListErrorContent(errorContent: ErrorContent, modifier: Modifier = Modifier) {
+ Card(modifier) {
+ Text(errorContent.error)
+ }
+}
diff --git a/src/main/kotlin/ui/components/LogCard.kt b/src/main/kotlin/ui/components/LogCard.kt
new file mode 100644
index 0000000..c3a5ec2
--- /dev/null
+++ b/src/main/kotlin/ui/components/LogCard.kt
@@ -0,0 +1,118 @@
+package ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Card
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import models.LogItem
+import models.SourceFA
+import ui.CustomTheme
+import ui.views.flow.FlowRow
+import utils.Helpers
+import java.text.SimpleDateFormat
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+fun LogCard(logItem: LogItem, modifier: Modifier = Modifier, onClick: (logItem: LogItem) -> Unit) {
+ val elevation = if (logItem.isSelected) 3.dp else 0.dp
+ Card({
+ onClick(logItem)
+ }, modifier, indication = null, elevation = elevation) {
+ LogCardContent(logItem)
+ }
+}
+
+@Composable
+private fun LogCardContent(logItem: LogItem) {
+ Row {
+ if (logItem.isSelected) {
+ Box(
+ Modifier.width(4.dp).height(48.dp)
+ .align(Alignment.CenterVertically)
+ .background(CustomTheme.colors.accent, RoundedCornerShape(0, 50, 50, 0))
+ )
+ }
+ LogIcon(logItem, Modifier.padding(start = 16.dp, top = 20.dp, end = 8.dp))
+ Column(Modifier.fillMaxWidth().padding(vertical = 20.dp)) {
+ Row(
+ Modifier.fillMaxWidth().padding(start = 2.dp, end = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ LogTitle(logItem)
+ val time = logItem.localTime
+ val formatter = SimpleDateFormat("dd-MM-yyyy hh:mm:ss.S")
+ val timeString = formatter.format(time)
+ Text(
+ timeString, style = CustomTheme.typography.headings.caption,
+ color = CustomTheme.colors.lowContrast
+ )
+ }
+ Spacer(Modifier.height(16.dp))
+ val properties = logItem.properties
+ if (properties.isEmpty()) return@Column
+ HorizontalFlow(properties)
+ }
+ }
+}
+
+@Composable
+fun LogTitle(logItem: LogItem, modifier: Modifier = Modifier) {
+ Text(
+ logItem.eventName, modifier = modifier, color = CustomTheme.colors.highContrast, fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+}
+
+@Composable
+fun LogIcon(logItem: LogItem, modifier: Modifier = Modifier) {
+ Box(modifier.size(48.dp)) {
+ val sourceIcon = when (logItem.source) {
+ is SourceFA -> "icons/firebaseLogo.webp"
+ else -> {
+ "icons/firebaseLogo.webp"
+ }
+ }
+ val sourceIconPainter = painterResource(sourceIcon)
+ Image(sourceIconPainter, logItem.source.type, Modifier.size(36.dp))
+ val typePainter = painterResource("icons/eventType_activity.svg")
+ Image(
+ typePainter, "type",
+ Modifier.size(24.dp).align(Alignment.BottomEnd),
+ contentScale = ContentScale.Fit
+ )
+ }
+}
+
+@Composable
+private fun HorizontalFlow(properties: HashMap) {
+ FlowRow(mainAxisSpacing = 4.dp, crossAxisSpacing = 4.dp) {
+ properties.keys.take(6).forEach {
+ val key = it
+ val value = properties[it]
+ if (value !is Map<*, *>) {
+ val takeValue = Helpers.valueShortText(value)
+ if (takeValue.isBlank()) {
+ return@forEach
+ }
+ val text = "$key : $takeValue"
+ Chip(
+ text,
+ bgColor = CustomTheme.colors.componentBackground2,
+ textColor = CustomTheme.colors.highContrast,
+ addBorder = false
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/LogList.kt b/src/main/kotlin/ui/components/LogList.kt
new file mode 100644
index 0000000..bed855b
--- /dev/null
+++ b/src/main/kotlin/ui/components/LogList.kt
@@ -0,0 +1,76 @@
+package ui.components
+
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import models.LogItem
+import models.SourceInternalContent
+
+@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
+@Composable
+fun LogList(
+ list: List,
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ onClick: (logItem : LogItem) -> Unit
+) {
+ val scope = rememberCoroutineScope()
+ // RemainingItems(state, lastIndex)
+ LazyColumn(
+ modifier.onPreviewKeyEvent {
+ handleArrowKeyScroll(it, state, scope)
+ }, reverseLayout = true, state = state, verticalArrangement = Arrangement.spacedBy(16.dp),
+ contentPadding = PaddingValues(24.dp, 0.dp, 24.dp, 24.dp)
+ ) {
+ items(list, key = { item: LogItem -> (item.key()) }) {
+ if (it.source == SourceInternalContent) {
+ ListItemInternalContent(it.internalContent, Modifier.fillMaxWidth())
+ } else {
+ LogCard(it, Modifier.fillMaxWidth(), onClick)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+private fun handleArrowKeyScroll(
+ keyEvent: KeyEvent,
+ state: LazyListState,
+ scope: CoroutineScope
+): Boolean {
+ return when (keyEvent.key) {
+ // TODO: Doesn't seems to work here
+ Key.DirectionUp -> {
+ scope.launch {
+ state.animateScrollBy(-50f)
+ }
+ true
+ }
+ Key.DirectionDown -> {
+ scope.launch {
+ state.animateScrollBy(50f)
+ }
+ true
+ }
+ else -> {
+ false
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/NewSessionBox.kt b/src/main/kotlin/ui/components/NewSessionBox.kt
new file mode 100644
index 0000000..5087000
--- /dev/null
+++ b/src/main/kotlin/ui/components/NewSessionBox.kt
@@ -0,0 +1,133 @@
+package ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.unit.dp
+import inputs.adb.ddmlib.AdbHelper
+import inputs.adb.ddmlib.Devices
+import models.DeviceDetails2
+import models.SessionInfo
+import ui.CustomTheme
+
+@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
+@Composable
+fun NewSessionBox(onDismissRequest: () -> Unit, onButtonClick: (sessionInfo: SessionInfo) -> Unit) {
+ StyledCustomVerticalDialog(onDismissRequest = onDismissRequest) {
+ Column(Modifier.fillMaxHeight().padding(16.dp)) {
+ Text("Create Session", style = CustomTheme.typography.headings.h2)
+ var description by remember { mutableStateOf(TextFieldValue()) }
+ var dIsError by remember { mutableStateOf(false) }
+ var appName by remember { mutableStateOf(TextFieldValue()) }
+ val device by Devices.currentDeviceFlow.collectAsState()
+ var clients by remember { mutableStateOf(deviceClients(device)) }
+ var submitError by remember { mutableStateOf("") }
+ val dSub = "Keep it short & something you can remember (Max ${SessionInfo.DESC_MAX_LENGTH} characters)"
+ Spacer(Modifier.height(24.dp))
+ CustomTextBox(description, "Session description", dSub, dIsError) {
+ description = it
+ dIsError = it.text.length > SessionInfo.DESC_MAX_LENGTH
+ }
+ Spacer(Modifier.height(16.dp))
+ CustomTextBox(appName, "App package name", "Package name should be exact to avoid any inconsistencies") {
+ appName = it
+ clients = clients.filterPackages(appName.text)
+ }
+ if (clients.isNotEmpty()) {
+ Spacer(Modifier.height(16.dp))
+ Text("Device packages:", style = CustomTheme.typography.headings.h6)
+ Spacer(Modifier.height(8.dp))
+ LazyRow(
+ Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(vertical = 4.dp)
+ ) {
+ items(clients, { item -> item }) {
+ Chip(
+ it,
+ Modifier.clip(CustomTheme.shapes.small)
+ .clickable { appName = TextFieldValue(it, TextRange(it.length)) })
+ }
+ }
+ }
+ val iDevice = device?.device
+ if (iDevice != null) {
+ LaunchedEffect(device?.serial) {
+ AdbHelper.getPackages(iDevice) {
+ clients = it.filterPackages(appName.text)
+ }
+ }
+ }
+
+ // error view
+ if (submitError.isNotBlank()) {
+ Spacer(Modifier.height(12.dp))
+ Text(
+ submitError,
+ Modifier.fillMaxWidth()
+ .background(
+ CustomTheme.colors.alertColors.danger,
+ CustomTheme.shapes.small
+ ).padding(horizontal = 8.dp, vertical = 2.dp),
+ style = CustomTheme.typography.bodySmall,
+ color = Color.White
+ )
+ }
+ Spacer(Modifier.height(16.dp))
+ Button({
+ if (description.text.length > SessionInfo.DESC_MAX_LENGTH) {
+ submitError = "Description should be less than ${SessionInfo.DESC_MAX_LENGTH} characters"
+ return@Button
+ }
+ if (description.text.isBlank()) {
+ submitError = "Description should not be empty"
+ return@Button
+ }
+ if (appName.text.isBlank()) {
+ submitError = "Package name should not be empty"
+ return@Button
+ }
+ onButtonClick(SessionInfo(description.text, appName.text))
+ }) {
+ Icon(painterResource("icons/ico-plus.svg"), "Add session")
+ Text("Create session")
+ }
+ }
+ }
+}
+
+private fun List.filterPackages(appName: String) = this.filter { packageName ->
+ (appName.isBlank() || (appName.isNotBlank() && packageName.contains(appName)))
+}
+
+private fun deviceClients(device: DeviceDetails2?) = device
+ ?.device
+ ?.clients
+ ?.filterNotNull()
+ ?.map { it.clientData.packageName }
+ ?.filter { !it.isNullOrBlank() } ?: emptyList()
+
+@Composable
+private fun CustomTextBox(
+ text: TextFieldValue,
+ placeholder: String,
+ subtext: String,
+ isError: Boolean = false,
+ onValueChange: (TextFieldValue) -> Unit
+) {
+ OutlinedTextField(text, onValueChange, Modifier.fillMaxWidth(), placeholder = {
+ Text(placeholder)
+ }, singleLine = true, isError = isError, shape = CustomTheme.shapes.medium)
+ Text(subtext, Modifier.padding(start = 8.dp, top = 4.dp), style = CustomTheme.typography.headings.caption)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/SessionComponents.kt b/src/main/kotlin/ui/components/SessionComponents.kt
new file mode 100644
index 0000000..ed35d79
--- /dev/null
+++ b/src/main/kotlin/ui/components/SessionComponents.kt
@@ -0,0 +1,108 @@
+package ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.pointer.pointerMoveFilter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import processor.MainProcessor
+import ui.CustomTheme
+
+@Composable
+fun CreateSessionButton(onClick: () -> Unit) {
+ Button(
+ onClick, Modifier.fillMaxWidth(0.8f), elevation = ButtonDefaults.elevation(0.dp),
+ shape = RoundedCornerShape(0, 50, 50, 0)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Icon(painterResource("icons/ico-plus.svg"), "plus")
+ Text("Start New Session", color = contentColorFor(MaterialTheme.colors.primary))
+ }
+ }
+}
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun SessionsList(
+ sessions: List,
+ processor: MainProcessor,
+ modifier: Modifier = Modifier,
+ onSessionChange: (sessionId: String?) -> Unit,
+ onSessionDelete: () -> Unit
+) {
+ LazyColumn(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(sessions, key = { item: String -> item }) {
+ val session = processor.getSessionInfo(it) ?: return@items
+ val currentSession = processor.getCurrentSessionId()
+ val shape = RoundedCornerShape(0, 50, 50, 0)
+ val isThisCurrentSession = currentSession == it
+ var showDeleteIcon by remember(it) { mutableStateOf(false) }
+ var modifier1 = Modifier
+ .clip(shape)
+ .pointerMoveFilter(onEnter = {
+ showDeleteIcon = true
+ false
+ }, onExit = {
+ showDeleteIcon = false
+ false
+ })
+ .clickable {
+ if (!processor.isSameSession(it)) {
+ processor.startOldSession(it)
+ onSessionChange(processor.getCurrentSessionId())
+ }
+ }.fillMaxWidth(0.95f)
+ if (isThisCurrentSession) {
+ modifier1 = modifier1.background(
+ CustomTheme.colors.accent.copy(0.4f),
+ shape
+ )
+ }
+ modifier1 = modifier1.padding(start = 24.dp, top = 8.dp, bottom = 8.dp)
+ Row(
+ modifier1, verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(Modifier.height(36.dp), Arrangement.Center) {
+ Text(session.description, maxLines = 1)
+ Text(session.appPackage, style = CustomTheme.typography.headings.caption, maxLines = 1)
+ }
+ if (showDeleteIcon) {
+ IconButton({
+ processor.deleteSession(it)
+ onSessionDelete()
+ }, Modifier.size(36.dp).padding(end = 16.dp)) {
+ Icon(painterResource("icons/ico-trashcan.svg"), "delete session")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun EmptySession() {
+ val painter = painterResource("icons/ic_illustration_new_session.xml")
+ Image(
+ painter,
+ "Start New session",
+ Modifier.fillMaxWidth(0.8f).padding(start = 16.dp, end = 16.dp, top = 16.dp),
+ contentScale = ContentScale.FillWidth
+ )
+ Text(
+ "Create a new session to get started",
+ Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
+ )
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/SettingsDialog.kt b/src/main/kotlin/ui/components/SettingsDialog.kt
new file mode 100644
index 0000000..9df758d
--- /dev/null
+++ b/src/main/kotlin/ui/components/SettingsDialog.kt
@@ -0,0 +1,122 @@
+package ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.Divider
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import ui.CustomTheme
+import utils.Helpers
+
+@Composable
+fun SettingsDialog(onDismissRequest: () -> Unit) {
+
+ SimpleVerticalDialog(header = "Settings", onDismissRequest = onDismissRequest) {
+ GeneralSettingBlock(Modifier.fillMaxWidth())
+ Spacer(Modifier.height(16.dp))
+ Divider(color = CustomTheme.colors.componentOutline, thickness = (0.5).dp)
+ Spacer(Modifier.height(16.dp))
+ OtherSettingBlock(Modifier.fillMaxWidth())
+ }
+}
+
+@Composable
+fun GeneralSettingBlock(modifier: Modifier = Modifier) {
+ Column(modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ ItemHeader("General")
+ var isDarkMode by remember { mutableStateOf(!Helpers.isThemeLightMode.value) }
+ SwitchItem(
+ isDarkMode, "Dark Mode", Modifier.fillMaxWidth(),
+ "Enable dark mode for less strain on eyes",
+ painterResource("icons/DarkMode.svg")
+ ) {
+ isDarkMode = it
+ Helpers.switchThemes(!it)
+ }
+ var isAutoScroll by remember { mutableStateOf(false) }
+ SwitchItem(
+ isAutoScroll, "Auto Scroll logs", Modifier.fillMaxWidth(),
+ "When recording, auto-scroll to the latest incoming analytics logs",
+ painterResource("icons/Tornado.svg")
+ ) {
+ isAutoScroll = it
+ }
+ }
+}
+
+@Composable
+fun OtherSettingBlock(modifier: Modifier = Modifier) {
+ Column(modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ ItemHeader("Other")
+ SimpleListItem(
+ "Feedback / Issues", Modifier.fillMaxWidth().clickable {
+ openBrowser("mailto://kapoor.aman22@gmail.com")
+ },
+ "If you have any feedback or issues, we would love to hear it from you",
+ painterResource("icons/ico-email.svg")
+ )
+ val aboutUsText = buildAnnotatedString {
+ withStyle(SpanStyle(color = CustomTheme.colors.mediumContrast)) {
+ append("This ")
+ pushStringAnnotation("gitProjectLink", "https://www.github.com/amank22")
+ withStyle(
+ SpanStyle(
+ textDecoration = TextDecoration.Underline,
+ color = CustomTheme.colors.highContrast
+ )
+ ) {
+ append("open-source project")
+ }
+ pop()
+ append(" is created by Aman Kapoor. Connect with him below.")
+ }
+ }
+ ClickableListItem(
+ AnnotatedString("About us"), Modifier.fillMaxWidth(),
+ aboutUsText,
+ painterResource("icons/ico_info.svg")
+ ) { offset ->
+ aboutUsText.getStringAnnotations(
+ tag = "gitProjectLink", start = offset,
+ end = offset
+ ).firstOrNull()?.let {
+ openBrowser(it.item)
+ }
+ }
+ Row(Modifier.padding(start = 32.dp)) {
+ SocialIcons.DefaultIcons.forEach {
+ SocialIcon(it)
+ }
+ }
+ }
+}
+
+@Composable
+private fun SocialIcon(icon: SocialIcons) {
+ IconButton({ openBrowser(icon.url) }) {
+ Icon(painterResource(icon.icon), "social")
+ }
+}
+
+sealed class SocialIcons(val icon: String, val url: String) {
+ companion object {
+ val DefaultIcons = listOf(SocialTwitter, SocialGithub, SocialLinkedin)
+ }
+}
+
+object SocialTwitter : SocialIcons("icons/social/social_twitter.svg", "https://twitter.com/Aman22Kapoor")
+object SocialGithub : SocialIcons("icons/social/social_github.svg", "https://github.com/amank22")
+object SocialLinkedin : SocialIcons("icons/social/social_linkedIn.svg", "https://www.linkedin.com/in/amank22/")
+object SocialFacebook : SocialIcons("icons/social/social_facebook.svg", "")
+
+fun openBrowser(url: String) = Helpers.openInBrowser(url)
+
diff --git a/src/main/kotlin/ui/components/SideNavigation.kt b/src/main/kotlin/ui/components/SideNavigation.kt
new file mode 100644
index 0000000..55c0ed8
--- /dev/null
+++ b/src/main/kotlin/ui/components/SideNavigation.kt
@@ -0,0 +1,92 @@
+package ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.Divider
+import androidx.compose.material.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import inputs.adb.ddmlib.Devices
+import kotlinx.coroutines.launch
+import processor.MainProcessor
+import ui.CustomTheme
+import utils.APP_NAME
+
+@Composable
+fun SideNavigation(
+ processor: MainProcessor, sessionId: String?, modifier: Modifier = Modifier,
+ onSessionChange: (sessionId: String?) -> Unit
+) {
+ Column(modifier) {
+ AppLogo()
+ SideNavHeader("Sessions")
+
+ SessionsBox(sessionId, processor, onSessionChange)
+
+ Divider(Modifier.height(1.dp).fillMaxWidth().background(Color.LightGray.copy(alpha = 0.3f)))
+
+ val devices by Devices.devicesFlow.collectAsState()
+ val deviceHeader = if (devices.isEmpty()) "No Devices" else "Devices"
+ SideNavHeader(deviceHeader)
+
+ DeviceList(devices, Modifier.fillMaxHeight().padding(vertical = 16.dp))
+ }
+}
+
+@Composable
+private fun SessionsBox(
+ sessionId: String?,
+ processor: MainProcessor,
+ onSessionChange: (sessionId: String?) -> Unit
+) {
+ var createSessionBoxShown by remember { mutableStateOf(false) }
+ var sessions by remember { mutableStateOf>(arrayListOf()) }
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(sessionId) {
+ sessions = processor.getSessions()
+ }
+ CreateSessionButton {
+ createSessionBoxShown = true
+ }
+ if (createSessionBoxShown) {
+ NewSessionBox({ createSessionBoxShown = false }) {
+ processor.createNewSession(it)
+ createSessionBoxShown = false
+ onSessionChange(processor.getCurrentSessionId())
+ }
+ }
+
+ if (sessions.isEmpty()) {
+ EmptySession()
+ } else {
+ SessionsList(sessions, processor, Modifier.fillMaxHeight(0.5f).padding(vertical = 16.dp), onSessionChange) {
+ scope.launch {
+ sessions = processor.getSessions()
+ }
+ }
+ }
+}
+
+@Composable
+private fun SideNavHeader(header: String) {
+ Text(
+ header, Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ style = CustomTheme.typography.headings.h3
+ )
+}
+
+@Composable
+private fun AppLogo() {
+ Image(
+ painterResource("icons/logo.svg"), APP_NAME,
+ Modifier.fillMaxWidth(0.8f).padding(24.dp),
+ colorFilter = ColorFilter.tint(CustomTheme.colors.highContrast),
+ contentScale = ContentScale.FillWidth
+ )
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/components/Texts.kt b/src/main/kotlin/ui/components/Texts.kt
new file mode 100644
index 0000000..d9d1b8c
--- /dev/null
+++ b/src/main/kotlin/ui/components/Texts.kt
@@ -0,0 +1,11 @@
+package ui.components
+
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import ui.CustomTheme
+
+@Composable
+fun ItemHeader(text: String, modifier: Modifier = Modifier) {
+ Text(text, modifier, style = CustomTheme.typography.headings.h6Semi)
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/views/flow/Flow.kt b/src/main/kotlin/ui/views/flow/Flow.kt
new file mode 100644
index 0000000..01fc1b7
--- /dev/null
+++ b/src/main/kotlin/ui/views/flow/Flow.kt
@@ -0,0 +1,333 @@
+package ui.views.flow
+
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import kotlin.math.max
+
+/**
+ * A composable that places its children in a horizontal flow. Unlike [Row], if the
+ * horizontal space is too small to put all the children in one row, multiple rows may be used.
+ *
+ * Note that just like [Row], flex values cannot be used with [FlowRow].
+ *
+ * @param modifier The modifier to be applied to the FlowRow.
+ * @param mainAxisSize The size of the layout in the main axis direction.
+ * @param mainAxisAlignment The alignment of each row's children in the main axis direction.
+ * @param mainAxisSpacing The main axis spacing between the children of each row.
+ * @param crossAxisAlignment The alignment of each row's children in the cross axis direction.
+ * @param crossAxisSpacing The cross axis spacing between the rows of the layout.
+ * @param lastLineMainAxisAlignment Overrides the main axis alignment of the last row.
+ */
+@Composable
+public fun FlowRow(
+ modifier: Modifier = Modifier,
+ mainAxisSize: SizeMode = SizeMode.Wrap,
+ mainAxisAlignment: FlowMainAxisAlignment = FlowMainAxisAlignment.Start,
+ mainAxisSpacing: Dp = 0.dp,
+ crossAxisAlignment: FlowCrossAxisAlignment = FlowCrossAxisAlignment.Start,
+ crossAxisSpacing: Dp = 0.dp,
+ lastLineMainAxisAlignment: FlowMainAxisAlignment = mainAxisAlignment,
+ content: @Composable () -> Unit
+) {
+ Flow(
+ modifier = modifier,
+ orientation = LayoutOrientation.Horizontal,
+ mainAxisSize = mainAxisSize,
+ mainAxisAlignment = mainAxisAlignment,
+ mainAxisSpacing = mainAxisSpacing,
+ crossAxisAlignment = crossAxisAlignment,
+ crossAxisSpacing = crossAxisSpacing,
+ lastLineMainAxisAlignment = lastLineMainAxisAlignment,
+ content = content
+ )
+}
+
+/**
+ * A composable that places its children in a vertical flow. Unlike [Column], if the
+ * vertical space is too small to put all the children in one column, multiple columns may be used.
+ *
+ * Note that just like [Column], flex values cannot be used with [FlowColumn].
+ *
+ * @param modifier The modifier to be applied to the FlowColumn.
+ * @param mainAxisSize The size of the layout in the main axis direction.
+ * @param mainAxisAlignment The alignment of each column's children in the main axis direction.
+ * @param mainAxisSpacing The main axis spacing between the children of each column.
+ * @param crossAxisAlignment The alignment of each column's children in the cross axis direction.
+ * @param crossAxisSpacing The cross axis spacing between the columns of the layout.
+ * @param lastLineMainAxisAlignment Overrides the main axis alignment of the last column.
+ */
+@Composable
+public fun FlowColumn(
+ modifier: Modifier = Modifier,
+ mainAxisSize: SizeMode = SizeMode.Wrap,
+ mainAxisAlignment: FlowMainAxisAlignment = FlowMainAxisAlignment.Start,
+ mainAxisSpacing: Dp = 0.dp,
+ crossAxisAlignment: FlowCrossAxisAlignment = FlowCrossAxisAlignment.Start,
+ crossAxisSpacing: Dp = 0.dp,
+ lastLineMainAxisAlignment: FlowMainAxisAlignment = mainAxisAlignment,
+ content: @Composable () -> Unit
+) {
+ Flow(
+ modifier = modifier,
+ orientation = LayoutOrientation.Vertical,
+ mainAxisSize = mainAxisSize,
+ mainAxisAlignment = mainAxisAlignment,
+ mainAxisSpacing = mainAxisSpacing,
+ crossAxisAlignment = crossAxisAlignment,
+ crossAxisSpacing = crossAxisSpacing,
+ lastLineMainAxisAlignment = lastLineMainAxisAlignment,
+ content = content
+ )
+}
+
+/**
+ * Used to specify the alignment of a layout's children, in cross axis direction.
+ */
+public enum class FlowCrossAxisAlignment {
+ /**
+ * Place children such that their center is in the middle of the cross axis.
+ */
+ Center,
+ /**
+ * Place children such that their start edge is aligned to the start edge of the cross axis.
+ */
+ Start,
+ /**
+ * Place children such that their end edge is aligned to the end edge of the cross axis.
+ */
+ End,
+}
+
+public typealias FlowMainAxisAlignment = MainAxisAlignment
+
+/**
+ * Layout model that arranges its children in a horizontal or vertical flow.
+ */
+@Composable
+private fun Flow(
+ modifier: Modifier,
+ orientation: LayoutOrientation,
+ mainAxisSize: SizeMode,
+ mainAxisAlignment: FlowMainAxisAlignment,
+ mainAxisSpacing: Dp,
+ crossAxisAlignment: FlowCrossAxisAlignment,
+ crossAxisSpacing: Dp,
+ lastLineMainAxisAlignment: FlowMainAxisAlignment,
+ content: @Composable () -> Unit
+) {
+ fun Placeable.mainAxisSize() =
+ if (orientation == LayoutOrientation.Horizontal) width else height
+ fun Placeable.crossAxisSize() =
+ if (orientation == LayoutOrientation.Horizontal) height else width
+
+ Layout(content, modifier) { measurables, outerConstraints ->
+ val sequences = mutableListOf>()
+ val crossAxisSizes = mutableListOf()
+ val crossAxisPositions = mutableListOf()
+
+ var mainAxisSpace = 0
+ var crossAxisSpace = 0
+
+ val currentSequence = mutableListOf()
+ var currentMainAxisSize = 0
+ var currentCrossAxisSize = 0
+
+ val constraints = OrientationIndependentConstraints(outerConstraints, orientation)
+
+ val childConstraints = if (orientation == LayoutOrientation.Horizontal) {
+ Constraints(maxWidth = constraints.mainAxisMax)
+ } else {
+ Constraints(maxHeight = constraints.mainAxisMax)
+ }
+
+ // Return whether the placeable can be added to the current sequence.
+ fun canAddToCurrentSequence(placeable: Placeable) =
+ currentSequence.isEmpty() || currentMainAxisSize + mainAxisSpacing.roundToPx() +
+ placeable.mainAxisSize() <= constraints.mainAxisMax
+
+ // Store current sequence information and start a new sequence.
+ fun startNewSequence() {
+ if (sequences.isNotEmpty()) {
+ crossAxisSpace += crossAxisSpacing.roundToPx()
+ }
+ sequences += currentSequence.toList()
+ crossAxisSizes += currentCrossAxisSize
+ crossAxisPositions += crossAxisSpace
+
+ crossAxisSpace += currentCrossAxisSize
+ mainAxisSpace = max(mainAxisSpace, currentMainAxisSize)
+
+ currentSequence.clear()
+ currentMainAxisSize = 0
+ currentCrossAxisSize = 0
+ }
+
+ for (measurable in measurables) {
+ // Ask the child for its preferred size.
+ val placeable = measurable.measure(childConstraints)
+
+ // Start a new sequence if there is not enough space.
+ if (!canAddToCurrentSequence(placeable)) startNewSequence()
+
+ // Add the child to the current sequence.
+ if (currentSequence.isNotEmpty()) {
+ currentMainAxisSize += mainAxisSpacing.roundToPx()
+ }
+ currentSequence.add(placeable)
+ currentMainAxisSize += placeable.mainAxisSize()
+ currentCrossAxisSize = max(currentCrossAxisSize, placeable.crossAxisSize())
+ }
+
+ if (currentSequence.isNotEmpty()) startNewSequence()
+
+ val mainAxisLayoutSize = if (constraints.mainAxisMax != Constraints.Infinity &&
+ mainAxisSize == SizeMode.Expand
+ ) {
+ constraints.mainAxisMax
+ } else {
+ max(mainAxisSpace, constraints.mainAxisMin)
+ }
+ val crossAxisLayoutSize = max(crossAxisSpace, constraints.crossAxisMin)
+
+ val layoutWidth = if (orientation == LayoutOrientation.Horizontal) {
+ mainAxisLayoutSize
+ } else {
+ crossAxisLayoutSize
+ }
+ val layoutHeight = if (orientation == LayoutOrientation.Horizontal) {
+ crossAxisLayoutSize
+ } else {
+ mainAxisLayoutSize
+ }
+
+ layout(layoutWidth, layoutHeight) {
+ sequences.forEachIndexed { i, placeables ->
+ val childrenMainAxisSizes = IntArray(placeables.size) { j ->
+ placeables[j].mainAxisSize() +
+ if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0
+ }
+ val arrangement = if (i < sequences.lastIndex) {
+ mainAxisAlignment.arrangement
+ } else {
+ lastLineMainAxisAlignment.arrangement
+ }
+ // TODO(soboleva): rtl support
+ // Handle vertical direction
+ val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 }
+ with(arrangement) {
+ arrange(mainAxisLayoutSize, childrenMainAxisSizes, mainAxisPositions)
+ }
+ placeables.forEachIndexed { j, placeable ->
+ val crossAxis = when (crossAxisAlignment) {
+ FlowCrossAxisAlignment.Start -> 0
+ FlowCrossAxisAlignment.End ->
+ crossAxisSizes[i] - placeable.crossAxisSize()
+ FlowCrossAxisAlignment.Center ->
+ Alignment.Center.align(
+ IntSize.Zero,
+ IntSize(
+ width = 0,
+ height = crossAxisSizes[i] - placeable.crossAxisSize()
+ ),
+ LayoutDirection.Ltr
+ ).y
+ }
+ if (orientation == LayoutOrientation.Horizontal) {
+ placeable.place(
+ x = mainAxisPositions[j],
+ y = crossAxisPositions[i] + crossAxis
+ )
+ } else {
+ placeable.place(
+ x = crossAxisPositions[i] + crossAxis,
+ y = mainAxisPositions[j]
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Used to specify how a layout chooses its own size when multiple behaviors are possible.
+ */
+// TODO(popam): remove this when Flow is reworked
+public enum class SizeMode {
+ /**
+ * Minimize the amount of free space by wrapping the children,
+ * subject to the incoming layout constraints.
+ */
+ Wrap,
+ /**
+ * Maximize the amount of free space by expanding to fill the available space,
+ * subject to the incoming layout constraints.
+ */
+ Expand
+}
+
+/**
+ * Used to specify the alignment of a layout's children, in main axis direction.
+ */
+public enum class MainAxisAlignment(internal val arrangement: Arrangement.Vertical) {
+ // TODO(soboleva) support RTl in Flow
+ // workaround for now - use Arrangement that equals to previous Arrangement
+ /**
+ * Place children such that they are as close as possible to the middle of the main axis.
+ */
+ Center(Arrangement.Center),
+
+ /**
+ * Place children such that they are as close as possible to the start of the main axis.
+ */
+ Start(Arrangement.Top),
+
+ /**
+ * Place children such that they are as close as possible to the end of the main axis.
+ */
+ End(Arrangement.Bottom),
+
+ /**
+ * Place children such that they are spaced evenly across the main axis, including free
+ * space before the first child and after the last child.
+ */
+ SpaceEvenly(Arrangement.SpaceEvenly),
+
+ /**
+ * Place children such that they are spaced evenly across the main axis, without free
+ * space before the first child or after the last child.
+ */
+ SpaceBetween(Arrangement.SpaceBetween),
+
+ /**
+ * Place children such that they are spaced evenly across the main axis, including free
+ * space before the first child and after the last child, but half the amount of space
+ * existing otherwise between two consecutive children.
+ */
+ SpaceAround(Arrangement.SpaceAround);
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/views/flow/Layout.kt b/src/main/kotlin/ui/views/flow/Layout.kt
new file mode 100644
index 0000000..84eff3c
--- /dev/null
+++ b/src/main/kotlin/ui/views/flow/Layout.kt
@@ -0,0 +1,22 @@
+package ui.views.flow
+
+import androidx.compose.ui.unit.Constraints
+
+internal enum class LayoutOrientation {
+ Horizontal,
+ Vertical
+}
+
+internal data class OrientationIndependentConstraints(
+ val mainAxisMin: Int,
+ val mainAxisMax: Int,
+ val crossAxisMin: Int,
+ val crossAxisMax: Int
+) {
+ constructor(c: Constraints, orientation: LayoutOrientation) : this(
+ if (orientation === LayoutOrientation.Horizontal) c.minWidth else c.minHeight,
+ if (orientation === LayoutOrientation.Horizontal) c.maxWidth else c.maxHeight,
+ if (orientation === LayoutOrientation.Horizontal) c.minHeight else c.minWidth,
+ if (orientation === LayoutOrientation.Horizontal) c.maxHeight else c.maxWidth
+ )
+}
\ No newline at end of file
diff --git a/src/main/kotlin/ui/views/init.kt b/src/main/kotlin/ui/views/init.kt
new file mode 100644
index 0000000..d40c864
--- /dev/null
+++ b/src/main/kotlin/ui/views/init.kt
@@ -0,0 +1,2 @@
+package ui.views
+
diff --git a/src/main/kotlin/utils/AppResources.kt b/src/main/kotlin/utils/AppResources.kt
new file mode 100644
index 0000000..fe0de2d
--- /dev/null
+++ b/src/main/kotlin/utils/AppResources.kt
@@ -0,0 +1,3 @@
+package utils
+
+const val APP_NAME = "LogVue"
\ No newline at end of file
diff --git a/src/main/kotlin/utils/Either.kt b/src/main/kotlin/utils/Either.kt
new file mode 100644
index 0000000..a78b381
--- /dev/null
+++ b/src/main/kotlin/utils/Either.kt
@@ -0,0 +1,156 @@
+package utils
+
+import utils.Either.Left
+import utils.Either.Right
+import java.io.Serializable
+
+/**
+ * Copyright (C) 2019 Fernando Cejas Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Represents a value of one of two possible types (a disjoint union).
+ * Instances of [Either] are either an instance of [Left] or [Right].
+ * FP Convention dictates that [Left] is used for "failure"
+ * and [Right] is used for "success".
+ *
+ * @see Left
+ * @see Right
+ */
+sealed class Either : Serializable {
+ /** * Represents the left side of [Either] class which by convention is a "Failure". */
+ data class Left(val a: L) : Either(), Serializable
+
+ /** * Represents the right side of [Either] class which by convention is a "Success". */
+ data class Right(val b: R) : Either(), Serializable
+
+ /**
+ * Returns true if this is a Right, false otherwise.
+ * @see Right
+ */
+ val isRight get() = this is Right
+
+ /**
+ * Returns true if this is a Left, false otherwise.
+ * @see Left
+ */
+ val isLeft get() = this is Left
+
+ /**
+ * Returns true if this is a Left, false otherwise.
+ * @see Left
+ */
+ val isSuccess get() = isRight
+
+ /**
+ * Returns true if this is a Left, false otherwise.
+ * @see Left
+ */
+ val isFailure get() = isLeft
+
+ /**
+ * Creates a Left type.
+ * @see Left
+ */
+ fun left(a: L) = Either.Left(a)
+
+
+ /**
+ * Creates a Left type.
+ * @see Right
+ */
+ fun right(b: R) = Either.Right(b)
+
+ /**
+ * Applies fnL if this is a Left or fnR if this is a Right.
+ * @see Left
+ * @see Right
+ */
+ fun fold(fnL: (L) -> Any, fnR: (R) -> Any): Any =
+ when (this) {
+ is Left -> fnL(a)
+ is Right -> fnR(b)
+ }
+}
+
+/**
+ * Composes 2 functions
+ * See Credits to Alex Hart.
+ */
+fun ((A) -> B).c(f: (B) -> C): (A) -> C = {
+ f(this(it))
+}
+
+/**
+ * Right-biased flatMap() FP convention which means that Right is assumed to be the default case
+ * to operate on. If it is Left, operations like map, flatMap, ... return the Left value unchanged.
+ */
+fun Either.flatMap(fn: (R) -> Either): Either =
+ when (this) {
+ is Either.Left -> Either.Left(a)
+ is Either.Right -> fn(b)
+ }
+
+/**
+ * Right-biased map() FP convention which means that Right is assumed to be the default case
+ * to operate on. If it is Left, operations like map, flatMap, ... return the Left value unchanged.
+ */
+fun Either.map(fn: (R) -> (T)): Either =
+ this.flatMap(fn.c(::right))
+
+/** Returns the value from this `Right` or the given argument if this is a `Left`.
+ * Right(12).getOrElse(17) RETURNS 12 and Left(12).getOrElse(17) RETURNS 17
+ */
+fun Either.getOrElse(value: R): R =
+ when (this) {
+ is Either.Left -> value
+ is Either.Right -> b
+ }
+
+/**
+ * Left-biased onFailure() FP convention dictates that when this class is Left, it'll perform
+ * the onFailure functionality passed as a parameter, but, overall will still return an either
+ * object so you chain calls.
+ */
+fun Either.onFailure(fn: (failure: L) -> Unit): Either =
+ this.apply { if (this is Either.Left) fn(a) }
+
+/**
+ * Right-biased onSuccess() FP convention dictates that when this class is Right, it'll perform
+ * the onSuccess functionality passed as a parameter, but, overall will still return an either
+ * object so you chain calls.
+ */
+fun Either.onSuccess(fn: (success: R) -> Unit): Either =
+ this.apply { if (this is Either.Right) fn(b) }
+
+/**
+ * Right-biased onSuccess() FP convention dictates that when this class is Right, it'll perform
+ * the onSuccess functionality passed as a parameter, but, overall will still return an either
+ * object so you chain calls.
+ */
+fun Either.getOrNull(): R? = when (this) {
+ is Either.Left -> null
+ is Either.Right -> b
+}
+
+/**
+ * Right-biased onSuccess() FP convention dictates that when this class is Right, it'll perform
+ * the onSuccess functionality passed as a parameter, but, overall will still return an either
+ * object so you chain calls.
+ */
+fun Either.failureOrNull(): L? = when (this) {
+ is Either.Left -> a
+ is Either.Right -> null
+}
\ No newline at end of file
diff --git a/src/main/kotlin/utils/HashMapEntity.kt b/src/main/kotlin/utils/HashMapEntity.kt
new file mode 100644
index 0000000..8c68ff1
--- /dev/null
+++ b/src/main/kotlin/utils/HashMapEntity.kt
@@ -0,0 +1,25 @@
+package utils
+
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.properties.Delegates
+
+class HashMapEntity : HashMap() {
+
+ private val isHashCodeCached = AtomicBoolean(false)
+ private var cachedHashCode by Delegates.notNull()
+
+ override fun hashCode(): Int {
+ if (isHashCodeCached.get()) {
+ return cachedHashCode
+ }
+ val hashCode = super.hashCode()
+ cachedHashCode = hashCode
+ isHashCodeCached.set(true)
+ return hashCode
+ }
+
+ override fun equals(other: Any?): Boolean {
+ return super.equals(other)
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/utils/Helpers.kt b/src/main/kotlin/utils/Helpers.kt
new file mode 100644
index 0000000..00c0251
--- /dev/null
+++ b/src/main/kotlin/utils/Helpers.kt
@@ -0,0 +1,312 @@
+package utils
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import com.github.drapostolos.typeparser.GenericType
+import com.github.drapostolos.typeparser.TypeParser
+import kotlinx.coroutines.flow.MutableStateFlow
+import models.LogCatMessage2
+import models.LogItem
+import models.SourceFA
+import org.snakeyaml.engine.v2.api.Dump
+import org.snakeyaml.engine.v2.api.DumpSettings
+import org.snakeyaml.engine.v2.api.StreamDataWriter
+import org.snakeyaml.engine.v2.common.ScalarStyle
+import processor.YamlWriter
+import storage.Db
+import java.awt.Desktop
+import java.io.PrintWriter
+import java.net.URI
+import java.nio.file.Path
+import java.util.*
+import kotlin.io.path.absolutePathString
+
+
+object Helpers {
+
+ private val objectMapper by lazy {
+ ItemObjectMapper()
+ }
+
+ private const val faPrefix = "Passing event to registered event handler (FE): "
+
+ private val parser = TypeParser.newBuilder().build()
+
+ private val settings by lazy {
+ DumpSettings.builder().setDefaultScalarStyle(ScalarStyle.PLAIN)
+ .build()
+ }
+
+ val isThemeLightMode = MutableStateFlow(Db.configs["isThemeLightMode"]?.toBooleanStrictOrNull() ?: true)
+
+ fun switchThemes(isLightMode: Boolean) {
+ isThemeLightMode.value = isLightMode
+ Db.configs["isThemeLightMode"] = isLightMode.toString()
+ }
+
+ fun validateFALogString(rawText: String): Boolean {
+ if (rawText.isBlank()) return false
+// val firstIndexOfClose = rawText.indexOfFirst { it == ']' }
+// if (firstIndexOfClose == -1) return false
+// val logText = rawText.substring(firstIndexOfClose + 1).trim()
+ if (!rawText.startsWith(faPrefix)) return false
+ return true
+ }
+
+ fun cutLogString(rawText: String): String {
+ val firstIndexOfClose = rawText.indexOfFirst { it == ']' }
+ val trim = rawText.substring(firstIndexOfClose + 1).trim()
+// println("trimmed : $trim")
+ return trim
+ }
+
+ /*
+ * Sample: Passing event to registered event handler (FE): lumos_home, Bundle[{analytics={request_id=a85e6056-448b-4bb3-beaf-14c2550d7499}, templateName=vaccination, screenName=home_notloggedin, utm_campaign=GI_VACCINATION_V2_LOW_B2C_IN_DEF, cardName=GI_VACCINATION_V2_LOW_B2C_IN_DEF, ga_screen_class(_sc)=HomeActivity, ga_screen_id(_si)=5665805600968775538, home=skywalker_v1, type=cardViewed, request_id=a85e6056-448b-4bb3-beaf-14c2550d7499}]
+ */
+ fun parseFALogs(msg: LogCatMessage2): LogItem {
+ val rawText = msg.message
+ val cut1 = rawText.removePrefix(faPrefix)
+ val eventParamsCutter = cut1.split(Regex(","), 2)
+ val eventName = eventParamsCutter[0].trim()
+ val properties = hashMapEntityOf()
+ eventParamsCutter.getOrNull(1)?.trim()?.let {
+ val objectItem = Item.ObjectItem(it.trim())
+ val something = objectMapper.parse(objectItem) as HashMap
+ properties.putAll(something)
+ }
+ val time = msg.header.timestamp.toEpochMilli()
+ return LogItem(source = SourceFA, eventName = eventName, properties = properties, localTime = time)
+ }
+
+ fun tryParseToType(str: String?): Any? {
+ return try {
+ tryParseInternal(str)
+ } catch (ve: ValueException) {
+ ve.value
+ }
+ }
+
+ @Throws(ValueException::class)
+ private fun tryParseInternal(str: String?): Any? {
+ if (str == null) return null
+ if (str.isBlank()) return str
+ var parsed: Boolean
+ parsed = tryParseType(str, Boolean::class.java)
+ if (!parsed) {
+ parsed = tryParseType(str, Long::class.java)
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, Int::class.java)
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, Double::class.java)
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, Float::class.java)
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, Double::class.java)
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ if (!parsed) {
+ parsed = tryParseType(str, object : GenericType>() {})
+ }
+ return str
+ }
+
+ @Throws(ValueException::class)
+ private fun tryParseType(str: String, clazz: Class): Boolean {
+ return tryOp {
+ val value = parser.parse(str, clazz)
+ throw ValueException(value) // anti-pattern ki ****
+ }
+ }
+
+ @Throws(ValueException::class)
+ private fun tryParseType(str: String, genericType: GenericType): Boolean {
+ return tryOp {
+ val value = parser.parse(str, genericType)
+ throw ValueException(value)
+ }
+ }
+
+ private fun tryOp(op: () -> Unit): Boolean {
+ return try {
+ op()
+ true
+ } catch (e: ValueException) {
+ throw e
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ fun convertToYaml(properties: HashMap, printWriter: PrintWriter) {
+ try {
+ val dump = Dump(settings)
+ dump.dump(properties, YamlWriter(printWriter))
+ } catch (e: Exception) {
+ Log.d("YamlConverter", e.localizedMessage)
+ }
+ }
+
+ fun convertToYaml(properties: HashMap, streamDataWriter: StreamDataWriter) {
+ try {
+ val dump = Dump(settings)
+ dump.dump(properties, streamDataWriter)
+ } catch (e: Exception) {
+ Log.d("YamlConverter", e.localizedMessage)
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ fun createAnnotatedString(properties: HashMap, indent: Int = 0): AnnotatedString {
+ return buildAnnotatedString {
+ var counter = 0
+ val mapSize = properties.size
+ properties.forEach { (key, value) ->
+ append(buildString {
+ if (indent == 0) return@buildString
+ for (i in 0 until indent) {
+ append(" ")
+ }
+ })
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(key)
+ }
+ append(" : ")
+ if (value is Map<*, *>) {
+ val childIndent = indent + 4
+ val childProperties = value as? HashMap ?: hashMapOf()
+ val childString = createAnnotatedString(childProperties, childIndent)
+ append("\n")
+ append(childString)
+ } else {
+ append(value.toString())
+ }
+ counter++
+ if (counter != mapSize) {
+ append("\n\n")
+ }
+ }
+ }
+ }
+
+ /**
+ * Get string for any object with a [maxLength].
+ * It will add ellipsize dots (...) at the end to clip length to maxLength if [addEllipsize] is true else
+ * it will just clip the text.
+ * Example :
+ * value = false
+ * maxLength = 4
+ * f...
+ * value = true
+ * maxLength = 4
+ * true
+ *
+ * @param value Any object
+ * @param maxLength max length of returned string. (...) added will make string like (maxLength - 3 + ...).
+ * Must be greater than 3 if [addEllipsize] is true
+ * @param addEllipsize whether to add ellipsize (...) or just clip to maxLength
+ * @return clipped or full string or empty string if [value] is null
+ */
+ fun valueShortText(value: Any?, maxLength: Int = 20, addEllipsize: Boolean = true): String {
+ if (value == null) return ""
+ if (addEllipsize) {
+ require(maxLength > 3)
+ }
+ val valueStr = value.toString().trim()
+ val takeValue = if (valueStr.length <= maxLength) {
+ valueStr
+ } else {
+ if (addEllipsize) {
+ valueStr.take(maxLength - 3) + "..."
+ } else {
+ valueStr.take(maxLength)
+ }
+ }
+ return takeValue
+ }
+
+ fun isWindows(): Boolean {
+ val os = System.getProperty("os.name").lowercase(Locale.getDefault())
+ // windows
+ return os.indexOf("win") >= 0
+ }
+
+ fun openFileExplorer(path: Path) {
+ try {
+ val pathString = path.absolutePathString()
+ val command = if (isWindows()) {
+ "Explorer.exe $pathString"
+ } else {
+ "open $pathString"
+ }
+ Runtime.getRuntime().exec(command)
+ } catch (e: Exception) {
+ Log.d("failed to open file manager")
+ }
+ }
+
+ fun openInBrowser(url: String) {
+ openInBrowser(URI.create(url))
+ }
+
+ fun openInBrowser(uri: URI) {
+ val osName by lazy(LazyThreadSafetyMode.NONE) { System.getProperty("os.name").lowercase(Locale.getDefault()) }
+ val desktop = Desktop.getDesktop()
+ when {
+ Desktop.isDesktopSupported() && desktop.isSupported(Desktop.Action.BROWSE) -> desktop.browse(uri)
+ "mac" in osName -> Runtime.getRuntime().exec("open $uri")
+ "nix" in osName || "nux" in osName -> Runtime.getRuntime().exec("xdg-open $uri")
+ else -> throw RuntimeException("cannot open $uri")
+ }
+ }
+
+}
+
+public inline fun hashMapEntityOf(): HashMap = HashMapEntity()
\ No newline at end of file
diff --git a/src/main/kotlin/utils/Item.kt b/src/main/kotlin/utils/Item.kt
new file mode 100644
index 0000000..a66d7d7
--- /dev/null
+++ b/src/main/kotlin/utils/Item.kt
@@ -0,0 +1,194 @@
+package utils
+
+import com.google.common.base.Joiner
+import com.google.common.base.Splitter
+import org.slf4j.LoggerFactory
+import java.util.*
+import java.util.regex.Pattern
+
+
+/**
+ * Model for parsing toString() output.
+ *
+ * Caveat: if values/strings contain '[' or ']' one might get unexpected
+ * results.
+ *
+ * @author dschreiber
+ */
+abstract class Item(val stringRepresentation: String?) {
+ class ValueItem(stringRepresentation: String?) : Item(stringRepresentation) {
+ val isNullOrEmpty: Boolean
+ get() = (stringRepresentation.isNullOrBlank())
+ || ("null" == stringRepresentation)
+ }
+
+ class ObjectItem(stringRepresentation: String) : Item(stringRepresentation) {
+ var type: String? = null
+ private val attributes: MutableMap = HashMap()
+
+ init {
+ val typePattern = Pattern.compile("(^[A-Z]\\S*)\\[(.*)]$", Pattern.DOTALL)
+ val typeMatcher = typePattern.matcher(stringRepresentation)
+ if (typeMatcher.matches()) {
+ type = typeMatcher.group(1)
+ val onFirstLevelCommaRespectEqualSign =
+ splitOnFirstLevelCommaRespectEqualSign(typeMatcher.group(2))
+ for (attributeValue: String in onFirstLevelCommaRespectEqualSign) {
+ val split: Iterator = Splitter.on("=").trimResults()
+ .limit(2).split(attributeValue).iterator()
+ val attributeName = split.next()
+ val attributeValueString = split.next()
+ attributes[attributeName] = parseString(attributeValueString)
+ }
+ } else {
+ throw IllegalArgumentException(
+ "cannot create object from string: "
+ + stringRepresentation
+ )
+ }
+ }
+
+ fun getAttributes(): Map {
+ return attributes
+ }
+
+ override fun toString(): String {
+ return (super.toString()
+ + "\n Type="
+ + type
+ + "\n "
+ + Joiner.on("\n ").withKeyValueSeparator(" = ")
+ .join(attributes))
+ }
+ }
+
+ class ListItem(stringRepresentation: String) : Item(stringRepresentation) {
+ private val values: MutableList- = ArrayList()
+
+ init {
+ // remove "[" and "]":
+ val valueString = stringRepresentation.substring(
+ 1,
+ stringRepresentation.length - 1
+ )
+ LOGGER.debug("no brackets - list: $valueString")
+ for (value: String in splitOnFirstLevelComma(valueString)) {
+ values.add(parseString(value))
+ }
+ }
+
+ fun getValues(): List
- {
+ return values
+ }
+
+ override fun toString(): String {
+ return super.toString() + "\n " + Joiner.on("\n ").join(values)
+ }
+ }
+
+ init {
+// LOGGER.info("creating: $stringRepresentation")
+ }
+
+ override fun toString(): String {
+ return "Item [stringRepresentation=$stringRepresentation]"
+ }
+
+ companion object {
+ private val LOGGER = LoggerFactory.getLogger(Item::class.java)
+
+ /**
+ * counts occurence of `count` in `string`
+ *
+ * @param string
+ * @param count
+ * @return
+ */
+ private fun contains(string: String, count: Char): Int {
+ var counter = 0
+ for (element in string) {
+ if (element == count) {
+ counter++
+ }
+ }
+ return counter
+ }
+
+ /**
+ * only the first comma before an equal sign ('=') is used for split. (So
+ * that strings that contain a comma are not split.)
+ *
+ * @param string
+ * @return
+ */
+ fun splitOnFirstLevelCommaRespectEqualSign(
+ string: String
+ ): List {
+ val allSplits = splitOnFirstLevelComma(string)
+ val result: MutableList = ArrayList(allSplits.size)
+ for (current: String in allSplits) {
+ if (current.contains("=")) {
+ result.add(current)
+ } else {
+ if (result.isEmpty()) {
+ throw IllegalStateException(
+ ("first comma must not occur before first equal sign! ("
+ + string + ")")
+ )
+ }
+ result[result.size - 1] = (result[result.size - 1]
+ + ", " + current)
+ }
+ }
+ return result
+ }
+
+ /**
+ * ignores commas nested in square brackets ("[", "]")
+ *
+ * @param string
+ */
+ fun splitOnFirstLevelComma(string: String?): List {
+ val scanner = Scanner(string)
+ scanner.useDelimiter(", ")
+ val result: MutableList = ArrayList()
+ var openBrackets = 0
+ while (scanner.hasNext()) {
+ val next = scanner.next()
+ val open = contains(next, '[')
+ val close = contains(next, ']')
+ LOGGER.debug(
+ ("openBrackets: " + openBrackets + ", open: " + open
+ + ", close: " + close + ", next: " + next)
+ )
+ if (openBrackets > 0) {
+ result[result.size - 1] = (result[result.size - 1]
+ + ", " + next)
+ } else {
+ result.add(next)
+ }
+ openBrackets = openBrackets + open - close
+ }
+ scanner.close()
+ return result
+ }
+
+ fun parseString(stringRaw: String?): Item {
+ if (stringRaw.isNullOrBlank()) {
+ return ValueItem(stringRaw)
+ }
+ val objectPattern = Pattern.compile(
+ "^[A-Z][^ ]* \\[.*",
+ Pattern.DOTALL
+ )
+ val string = stringRaw.trim { it <= ' ' }
+ return if (string.startsWith("[")) {
+ ListItem(string)
+ } else if (objectPattern.matcher(string).matches()) {
+ ObjectItem(string)
+ } else {
+ ValueItem(string)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/utils/ItemObjectMapper.kt b/src/main/kotlin/utils/ItemObjectMapper.kt
new file mode 100644
index 0000000..e3ffb92
--- /dev/null
+++ b/src/main/kotlin/utils/ItemObjectMapper.kt
@@ -0,0 +1,53 @@
+package utils
+
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import utils.Item.ObjectItem
+
+
+/**
+ * Creates object/instance from toString()-Model.
+ *
+ * @author dschreiber
+ */
+class ItemObjectMapper {
+
+ fun parse(item: Item): Any? {
+ return try {
+ when (item) {
+ is ObjectItem -> {
+ parseObject(item)
+ }
+ else -> {
+ Helpers.tryParseToType(item.stringRepresentation)
+ }
+ }
+ } catch (e: Exception) {
+ LOGGER.error("Unexpected exception!", e)
+ println("Unexpected Exception! (item=$item e = ${e.message}")
+ item.stringRepresentation
+ }
+ }
+
+ private fun parseObject(item: ObjectItem): HashMap {
+ val map = hashMapOf()
+ item.getAttributes().forEach { entry ->
+// println("parsing for field: $entry")
+ val stringRepresentation = entry.value.stringRepresentation ?: ""
+ val key = entry.key.removePrefix("{").removeSuffix("}")
+
+ if (stringRepresentation.startsWith("{") && stringRepresentation.endsWith("}")) {
+ // this is an object
+ map[key] = parse(ObjectItem("Bundle[$stringRepresentation]"))
+ } else {
+ map[key] = parse(Item.ValueItem(stringRepresentation.removePrefix("{").removeSuffix("}")))
+ }
+ }
+ return map
+ }
+
+ companion object {
+ private val LOGGER: Logger = LoggerFactory
+ .getLogger(ItemObjectMapper::class.java)
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/utils/Log.kt b/src/main/kotlin/utils/Log.kt
new file mode 100644
index 0000000..62be482
--- /dev/null
+++ b/src/main/kotlin/utils/Log.kt
@@ -0,0 +1,20 @@
+package utils
+
+import java.util.logging.Level
+import java.util.logging.LogRecord
+import java.util.logging.SimpleFormatter
+
+object Log {
+
+ private val formatter = SimpleFormatter()
+
+ fun d(tag: String, msg: String) {
+ val s = formatter.format(LogRecord(Level.FINER, "$tag : $msg"))
+ println(s)
+ }
+
+ fun d(msg: String) {
+ d("Debug", msg)
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/utils/ValueException.kt b/src/main/kotlin/utils/ValueException.kt
new file mode 100644
index 0000000..52293e5
--- /dev/null
+++ b/src/main/kotlin/utils/ValueException.kt
@@ -0,0 +1,3 @@
+package utils
+
+class ValueException(val value: Any?) : Exception("Value is $value")
\ No newline at end of file
diff --git a/src/main/resources/WorkSans-Bold.ttf b/src/main/resources/WorkSans-Bold.ttf
new file mode 100644
index 0000000..2076ede
Binary files /dev/null and b/src/main/resources/WorkSans-Bold.ttf differ
diff --git a/src/main/resources/WorkSans-Medium.ttf b/src/main/resources/WorkSans-Medium.ttf
new file mode 100644
index 0000000..11e3dda
Binary files /dev/null and b/src/main/resources/WorkSans-Medium.ttf differ
diff --git a/src/main/resources/WorkSans-Regular.ttf b/src/main/resources/WorkSans-Regular.ttf
new file mode 100644
index 0000000..92cd6d4
Binary files /dev/null and b/src/main/resources/WorkSans-Regular.ttf differ
diff --git a/src/main/resources/WorkSans-SemiBold.ttf b/src/main/resources/WorkSans-SemiBold.ttf
new file mode 100644
index 0000000..fa0af5b
Binary files /dev/null and b/src/main/resources/WorkSans-SemiBold.ttf differ
diff --git a/src/main/resources/icons/DarkMode.svg b/src/main/resources/icons/DarkMode.svg
new file mode 100644
index 0000000..c728d93
--- /dev/null
+++ b/src/main/resources/icons/DarkMode.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/Info.svg b/src/main/resources/icons/Info.svg
new file mode 100644
index 0000000..0dff3a7
--- /dev/null
+++ b/src/main/resources/icons/Info.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/Share1.svg b/src/main/resources/icons/Share1.svg
new file mode 100644
index 0000000..dfa7e7a
--- /dev/null
+++ b/src/main/resources/icons/Share1.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/Share2.svg b/src/main/resources/icons/Share2.svg
new file mode 100644
index 0000000..f0cda7b
--- /dev/null
+++ b/src/main/resources/icons/Share2.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/Tornado.svg b/src/main/resources/icons/Tornado.svg
new file mode 100644
index 0000000..1e42fc2
--- /dev/null
+++ b/src/main/resources/icons/Tornado.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/empty_state.svg b/src/main/resources/icons/empty_state.svg
new file mode 100644
index 0000000..586647f
--- /dev/null
+++ b/src/main/resources/icons/empty_state.svg
@@ -0,0 +1,90 @@
+
diff --git a/src/main/resources/icons/eventType_activity.svg b/src/main/resources/icons/eventType_activity.svg
new file mode 100644
index 0000000..b132917
--- /dev/null
+++ b/src/main/resources/icons/eventType_activity.svg
@@ -0,0 +1,11 @@
+
diff --git a/src/main/resources/icons/eventType_click.svg b/src/main/resources/icons/eventType_click.svg
new file mode 100644
index 0000000..fffe108
--- /dev/null
+++ b/src/main/resources/icons/eventType_click.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/eventType_view.svg b/src/main/resources/icons/eventType_view.svg
new file mode 100644
index 0000000..e6f2d58
--- /dev/null
+++ b/src/main/resources/icons/eventType_view.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/main/resources/icons/firebaseLogo.webp b/src/main/resources/icons/firebaseLogo.webp
new file mode 100644
index 0000000..d8ea022
Binary files /dev/null and b/src/main/resources/icons/firebaseLogo.webp differ
diff --git a/src/main/resources/icons/ic_illustration_new_session.xml b/src/main/resources/icons/ic_illustration_new_session.xml
new file mode 100644
index 0000000..c9e0d90
--- /dev/null
+++ b/src/main/resources/icons/ic_illustration_new_session.xml
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/icons/ico-alert.svg b/src/main/resources/icons/ico-alert.svg
new file mode 100644
index 0000000..4ab6cd9
--- /dev/null
+++ b/src/main/resources/icons/ico-alert.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/main/resources/icons/ico-carrot-right.svg b/src/main/resources/icons/ico-carrot-right.svg
new file mode 100644
index 0000000..1fcc71e
--- /dev/null
+++ b/src/main/resources/icons/ico-carrot-right.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/ico-email.svg b/src/main/resources/icons/ico-email.svg
new file mode 100644
index 0000000..02f46de
--- /dev/null
+++ b/src/main/resources/icons/ico-email.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/ico-plus.svg b/src/main/resources/icons/ico-plus.svg
new file mode 100644
index 0000000..14691a1
--- /dev/null
+++ b/src/main/resources/icons/ico-plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/ico-search.svg b/src/main/resources/icons/ico-search.svg
new file mode 100644
index 0000000..b7ce11b
--- /dev/null
+++ b/src/main/resources/icons/ico-search.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/ico-settings.svg b/src/main/resources/icons/ico-settings.svg
new file mode 100644
index 0000000..e61ff17
--- /dev/null
+++ b/src/main/resources/icons/ico-settings.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/ico-share.svg b/src/main/resources/icons/ico-share.svg
new file mode 100644
index 0000000..3454ef3
--- /dev/null
+++ b/src/main/resources/icons/ico-share.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/ico-trashcan.svg b/src/main/resources/icons/ico-trashcan.svg
new file mode 100644
index 0000000..dd12ea0
--- /dev/null
+++ b/src/main/resources/icons/ico-trashcan.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/ico_close.xml b/src/main/resources/icons/ico_close.xml
new file mode 100644
index 0000000..625ccc3
--- /dev/null
+++ b/src/main/resources/icons/ico_close.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/icons/ico_copy.svg b/src/main/resources/icons/ico_copy.svg
new file mode 100644
index 0000000..baafae6
--- /dev/null
+++ b/src/main/resources/icons/ico_copy.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/ico_filter.svg b/src/main/resources/icons/ico_filter.svg
new file mode 100644
index 0000000..ea1e0b2
--- /dev/null
+++ b/src/main/resources/icons/ico_filter.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/ico_info.svg b/src/main/resources/icons/ico_info.svg
new file mode 100644
index 0000000..4bd7691
--- /dev/null
+++ b/src/main/resources/icons/ico_info.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/ico_pause.svg b/src/main/resources/icons/ico_pause.svg
new file mode 100644
index 0000000..c27ba8c
--- /dev/null
+++ b/src/main/resources/icons/ico_pause.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/ico_play.svg b/src/main/resources/icons/ico_play.svg
new file mode 100644
index 0000000..57adf5d
--- /dev/null
+++ b/src/main/resources/icons/ico_play.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/ico_view.svg b/src/main/resources/icons/ico_view.svg
new file mode 100644
index 0000000..7ebc74a
--- /dev/null
+++ b/src/main/resources/icons/ico_view.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/main/resources/icons/layered_waves.svg b/src/main/resources/icons/layered_waves.svg
new file mode 100644
index 0000000..7afaf77
--- /dev/null
+++ b/src/main/resources/icons/layered_waves.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/src/main/resources/icons/logo.svg b/src/main/resources/icons/logo.svg
new file mode 100644
index 0000000..1c8ab7d
--- /dev/null
+++ b/src/main/resources/icons/logo.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/main/resources/icons/social/social_facebook.svg b/src/main/resources/icons/social/social_facebook.svg
new file mode 100644
index 0000000..e7a3c0d
--- /dev/null
+++ b/src/main/resources/icons/social/social_facebook.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/main/resources/icons/social/social_github.svg b/src/main/resources/icons/social/social_github.svg
new file mode 100644
index 0000000..d60cbcb
--- /dev/null
+++ b/src/main/resources/icons/social/social_github.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/main/resources/icons/social/social_instagram.svg b/src/main/resources/icons/social/social_instagram.svg
new file mode 100644
index 0000000..efe092c
--- /dev/null
+++ b/src/main/resources/icons/social/social_instagram.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/main/resources/icons/social/social_linkedIn.svg b/src/main/resources/icons/social/social_linkedIn.svg
new file mode 100644
index 0000000..704d659
--- /dev/null
+++ b/src/main/resources/icons/social/social_linkedIn.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/main/resources/icons/social/social_twitter.svg b/src/main/resources/icons/social/social_twitter.svg
new file mode 100644
index 0000000..fdecf7a
--- /dev/null
+++ b/src/main/resources/icons/social/social_twitter.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/main/resources/icons/waiting.svg b/src/main/resources/icons/waiting.svg
new file mode 100644
index 0000000..58dacb7
--- /dev/null
+++ b/src/main/resources/icons/waiting.svg
@@ -0,0 +1,133 @@
+
\ No newline at end of file
diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml
new file mode 100644
index 0000000..f6fa73b
--- /dev/null
+++ b/src/main/resources/log4j2.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file