diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8105a26
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,74 @@
+.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/
\ 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/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..70e9f01
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,53 @@
+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.flink/flink-streaming-java
+ implementation("org.apache.flink:flink-streaming-java_2.12:1.14.0")
+ // https://mvnrepository.com/artifact/org.apache.flink/flink-clients
+ implementation("org.apache.flink:flink-clients_2.12:1.14.0")
+ // https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j-impl
+ implementation("org.apache.logging.log4j:log4j-slf4j-impl:2.16.0")
+ 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")
+ implementation ("com.malinskiy.adam:adam:0.4.3")
+ implementation("com.github.pgreze:kotlin-process:1.3.1")
+}
+
+tasks.test {
+ useJUnit()
+}
+
+tasks.withType() {
+ kotlinOptions.jvmTarget = "11"
+}
+
+compose.desktop {
+ application {
+ mainClass = "MainKt"
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "Goflog"
+ packageVersion = "1.0.0"
+ }
+ }
+}
\ No newline at end of file
diff --git a/filters b/filters
new file mode 100644
index 0000000..e6bb091
Binary files /dev/null and b/filters differ
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..7fc6f1f
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1 @@
+kotlin.code.style=official
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/sessions.db b/sessions.db
new file mode 100644
index 0000000..28c5990
Binary files /dev/null and b/sessions.db differ
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..6285983
--- /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 = "Goflog"
+
diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt
new file mode 100644
index 0000000..b49da47
--- /dev/null
+++ b/src/main/kotlin/Application.kt
@@ -0,0 +1,2 @@
+class Application {
+}
\ No newline at end of file
diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt
new file mode 100644
index 0000000..3e443fe
--- /dev/null
+++ b/src/main/kotlin/Main.kt
@@ -0,0 +1,90 @@
+// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
+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.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 processor.FlinkProcessor
+import storage.Db
+import ui.AppTheme
+import ui.CustomTheme
+import ui.LocalCustomColors
+import ui.components.BodyPanel
+import ui.components.SideNavigation
+import utils.Helpers
+import utils.Log
+import java.awt.Desktop
+
+@Composable
+@Preview
+fun App() {
+ val packageName = "com.goibibo.debug"
+ val processor = remember { FlinkProcessor(packageName) }
+ val isLightTheme by Helpers.isThemeLightMode.collectAsState()
+ AppTheme(isLightTheme) {
+ Row(Modifier.fillMaxSize().background(LocalCustomColors.current.background)) {
+ var sessionId by remember { mutableStateOf(Db.sessionId()) }
+ SideNavigation(
+ processor, sessionId, Modifier.fillMaxHeight().weight(0.2f)
+ .background(CustomTheme.colors.componentBackground)
+ ) {
+ sessionId = it
+ }
+ Divider(Modifier.fillMaxHeight().width(1.dp).background(Color.LightGray.copy(alpha = 0.3f)))
+ BodyPanel(processor, sessionId, Modifier.fillMaxHeight().weight(0.8f))
+ }
+ }
+}
+
+
+@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)
+ }
+ }
+ }
+}
+
+fun main() = application {
+ Desktop.getDesktop().setQuitHandler { e, response ->
+ Log.d("QuitHandler", "Quiting : ${e.source}")
+ Db.close()
+ response.performQuit()
+ }
+ val onCloseRequest = {
+ Log.d("QuitHandler", "Quiting : OnClose Request")
+ Db.close()
+ exitApplication()
+ }
+ val windowState = rememberWindowState(WindowPlacement.Floating, size = DpSize(1440.dp, 1024.dp))
+ Window(onCloseRequest = onCloseRequest, title = "GoFlog", state = windowState) {
+ 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/AdbStreamer.kt b/src/main/kotlin/inputs/adb/AdbStreamer.kt
new file mode 100644
index 0000000..3d34bda
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/AdbStreamer.kt
@@ -0,0 +1,35 @@
+package inputs.adb
+
+import kotlinx.coroutines.runBlocking
+import org.apache.flink.streaming.api.functions.source.RichParallelSourceFunction
+import org.apache.flink.streaming.api.functions.source.SourceFunction
+
+class AdbStreamer(packageName: String) : RichParallelSourceFunction>() {
+
+ private val command = LoggerCommand(packageName)
+
+ override fun run(ctx: SourceFunction.SourceContext>) {
+ runBlocking {
+ val process = command.stream(this)
+ for (value in process) {
+ value.fold({
+ if (it.isNotBlank()) {
+ ctx.collect(Result.success(it))
+ }
+ }, {
+ ctx.collect(Result.failure(it))
+ })
+ }
+ }
+ }
+
+ override fun cancel() {
+ command.close()
+ }
+
+ override fun close() {
+ super.close()
+ command.close()
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/AdbStreamerAdam.kt b/src/main/kotlin/inputs/adb/AdbStreamerAdam.kt
new file mode 100644
index 0000000..feecebf
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/AdbStreamerAdam.kt
@@ -0,0 +1,30 @@
+package inputs.adb
+
+import kotlinx.coroutines.runBlocking
+import org.apache.flink.streaming.api.functions.source.RichParallelSourceFunction
+import org.apache.flink.streaming.api.functions.source.SourceFunction
+
+class AdbStreamerAdam(packageName: String) : RichParallelSourceFunction>() {
+
+ private val command = LoggerCommandAdam(packageName)
+
+ override fun run(ctx: SourceFunction.SourceContext>) {
+ runBlocking {
+ val result = command.init(this)
+ result.fold({
+ command.stream {
+ if (!it.isNullOrBlank()) {
+ ctx.collect(Result.success(it))
+ }
+ }
+ }, {
+ ctx.collect(Result.failure(it))
+ })
+ }
+ }
+
+ override fun cancel() {
+ command.close()
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/AdbUtils.kt b/src/main/kotlin/inputs/adb/AdbUtils.kt
new file mode 100644
index 0000000..287f981
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/AdbUtils.kt
@@ -0,0 +1,130 @@
+package inputs.adb
+
+import com.malinskiy.adam.AndroidDebugBridgeClient
+import com.malinskiy.adam.AndroidDebugBridgeClientFactory
+import com.malinskiy.adam.exception.RequestRejectedException
+import com.malinskiy.adam.interactor.StartAdbInteractor
+import com.malinskiy.adam.request.device.AsyncDeviceMonitorRequest
+import com.malinskiy.adam.request.device.Device
+import com.malinskiy.adam.request.logcat.LogcatFilterSpec
+import com.malinskiy.adam.request.logcat.LogcatReadMode
+import com.malinskiy.adam.request.logcat.LogcatVerbosityLevel
+import com.malinskiy.adam.request.pkg.Package
+import com.malinskiy.adam.request.pkg.PmListRequest
+import com.malinskiy.adam.request.prop.GetSinglePropRequest
+import com.malinskiy.adam.request.shell.v2.ShellCommandRequest
+import com.malinskiy.adam.request.shell.v2.ShellCommandResult
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.flow.*
+import models.DeviceDetails
+
+object AdbUtils {
+
+ private val adb by lazy { getClient() }
+
+ private val _currentDeviceFlow: MutableStateFlow = MutableStateFlow(null)
+ private val currentDevice: String?
+ get() = _currentDeviceFlow.value
+
+ val currentDeviceFlow = _currentDeviceFlow
+
+ private const val CMD_ENABLE_FA = "setprop log.tag.FA VERBOSE"
+ private const val CMD_ENABLE_FA_SVC = "setprop log.tag.FA-SVC VERBOSE"
+ private const val CMD_GET_PID = "pidof -s %s"
+
+ suspend fun startServer() {
+ //Start the adb server
+ StartAdbInteractor().execute()
+ }
+
+ private fun getClient(): AndroidDebugBridgeClient {
+ //Create adb client
+ return AndroidDebugBridgeClientFactory().build()
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun monitorDevices(scope: CoroutineScope): Flow> {
+ val deviceEventsChannel: ReceiveChannel> = adb.execute(
+ request = AsyncDeviceMonitorRequest(),
+ scope = scope
+ )
+ return deviceEventsChannel.receiveAsFlow().distinctUntilChanged()
+ .mapLatest {
+ val list = arrayListOf()
+ it.forEach { device ->
+ val name = getDeviceProperty(device.serial, "ro.product.device")
+ val dd = DeviceDetails(serial = device.serial, name = name, state = device.state)
+ list.add(dd)
+ }
+ list
+ }
+ }
+
+ fun setCurrentDevice(serial: String?) {
+ _currentDeviceFlow.value = serial
+ }
+
+ suspend fun getDeviceProperty(deviceSerial: String, property: String): String {
+ return adb.execute(
+ request = GetSinglePropRequest(name = property),
+ serial = deviceSerial
+ ).trim()
+ }
+
+ suspend fun getAllPackages(includePath: Boolean = false): List {
+ return adb.execute(
+ request = PmListRequest(includePath = includePath),
+ serial = currentDevice
+ )
+ }
+
+ /**
+ * Monitor logs of specific package id or null if no package is selected
+ */
+ suspend fun monitorLogs(
+ scope: CoroutineScope, packageId: String?,
+ filterList: List = listOf("FA", "FA-SVC")
+ ): Result> {
+ try {
+ val logsEnabled = shellCommand(CMD_ENABLE_FA) successWith shellCommand(CMD_ENABLE_FA_SVC)
+ if (!logsEnabled) return Result.failure(LogErrorNotEnabledForFA)
+ val request = if (packageId.isNullOrBlank()) {
+ StreamingLogcatRequest()
+ } else {
+ // "adb shell setprop log.tag.FA VERBOSE && adb shell setprop log.tag.FA-SVC VERBOSE &&" +
+ // " pid=\$(adb shell pidof -s $packageName) && adb logcat -s FA FA-SVC --pid=\$pid -v long time"
+ val filters = filterList.map { LogcatFilterSpec(it, LogcatVerbosityLevel.D) }
+ val pid = shellCommand(String.format(CMD_GET_PID, packageId)).stdout.trim().toLongOrNull()
+ ?: return Result.failure(LogErrorPackageIssue)
+ StreamingLogcatRequest(
+ pid = pid, modes = listOf(LogcatReadMode.long),
+ filters = filters
+ )
+ }
+ val receiveChannel = adb.execute(
+ request = request,
+ scope = scope,
+ serial = currentDevice
+ )
+ return Result.success(receiveChannel)
+ } catch (e: RequestRejectedException) {
+ return Result.failure(LogErrorADBIssue)
+ } catch (e: Exception) {
+ return Result.failure(e)
+ }
+ }
+
+ suspend fun shellCommand(cmd: String): ShellCommandResult {
+ return adb.execute(
+ request = ShellCommandRequest(cmd),
+ serial = currentDevice
+ )
+ }
+
+ private infix fun ShellCommandResult.successWith(second: ShellCommandResult): Boolean {
+ return (this.exitCode == 0 && second.exitCode == 0)
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/AutoClosableProcess.kt b/src/main/kotlin/inputs/adb/AutoClosableProcess.kt
new file mode 100644
index 0000000..6e4dcb3
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/AutoClosableProcess.kt
@@ -0,0 +1,222 @@
+package inputs.adb
+
+import org.apache.flink.util.Preconditions
+import org.slf4j.LoggerFactory
+import java.io.*
+import java.nio.charset.StandardCharsets
+import java.util.*
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import java.util.concurrent.atomic.AtomicLong
+import java.util.function.Consumer
+import kotlin.concurrent.thread
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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,
+ * WITHWARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Utility class to terminate a given [Process] when exiting a try-with-resources statement.
+ */
+class AutoClosableProcess private constructor(
+ private val commands: Array,
+ private var stdoutProcessor: Consumer,
+ private val stderrProcessor: Consumer,
+ private val stdInputs: Array?,
+) : AutoCloseable, Serializable {
+
+ /**
+ * Builder for most sophisticated processes.
+ */
+ class AutoClosableProcessBuilder internal constructor(commands: Array) : Serializable {
+
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+
+ private val commands: Array
+ private var stdoutProcessor: Consumer = CommonStdConsumer()
+ private var stderrProcessor: Consumer = CommonStdConsumer()
+ private var stdInputs: Array? = null
+
+ init {
+ this.commands = arrayOf(*commands)
+ }
+
+ fun setStdoutProcessor(stdoutProcessor: Consumer): AutoClosableProcessBuilder {
+ this.stdoutProcessor = stdoutProcessor
+ return this
+ }
+
+ fun setStderrProcessor(stderrProcessor: Consumer): AutoClosableProcessBuilder {
+ this.stderrProcessor = stderrProcessor
+ return this
+ }
+
+ fun setStdInputs(vararg inputLines: String): AutoClosableProcessBuilder {
+ Preconditions.checkArgument(inputLines.isNotEmpty())
+ stdInputs = arrayOf(*inputLines)
+ return this
+ }
+
+ @Throws(IOException::class)
+ fun build(): AutoClosableProcess {
+ return AutoClosableProcess(commands, stdoutProcessor, stderrProcessor, stdInputs)
+ }
+ }
+
+ @Throws(IOException::class, CancelException::class)
+ fun runBlocking() {
+ val sw = StringWriter()
+ PrintWriter(sw).use { printer ->
+ process = createProcess(commands, stdoutProcessor, { line: String? ->
+ stderrProcessor.accept(line)
+ printer.println(line)
+ }, stdInputs)
+ val pid = process.pid()
+ thread {
+ aPid.set(pid)
+ }
+ try {
+ val exitValue = process.waitFor()
+ if (exitValue == 143) {
+ throw CancelException(IOException("Process execution failed due error. Error output:$sw"))
+ }
+ if (exitValue != 0) {
+ // use some proper mechanism to pass back errors
+ throw IOException("Process execution failed due exit code $exitValue. Error output:$sw")
+ }
+ } catch (e: TimeoutException) {
+ throw IOException("Process failed due to timeout.")
+ } catch (e: InterruptedException) {
+ throw IOException("Process failed due to timeout.")
+ }
+ }
+ }
+
+ @Throws(IOException::class)
+ fun runNonBlocking() {
+ process = createProcess(commands, stdoutProcessor, stderrProcessor, stdInputs)
+ }
+
+ @Throws(CancelException::class)
+ override fun close() {
+// throw CancelException()
+// createProcess(arrayOf("kill -15 ${aPid.get()}"), null, null, null)
+ if (process.isAlive) {
+ process.destroy()
+ try {
+ process.waitFor(10, TimeUnit.SECONDS)
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ }
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun createProcess(
+ commands: Array,
+ stdoutProcessor: Consumer?,
+ stderrProcessor: Consumer?,
+ stdInputs: Array?
+ ): Process {
+ val processBuilder = ProcessBuilder()
+ val updatedCommands = concat(OS_LINUX_RUNTIME, commands)
+ processBuilder.command(updatedCommands.toList())
+ val process = processBuilder.start()
+ if (stdoutProcessor != null) {
+ consumeOutput(process.inputStream, stdoutProcessor)
+ }
+ if (stderrProcessor != null) {
+ consumeOutput(process.errorStream, stderrProcessor)
+ }
+ if (stdInputs != null) {
+ produceInput(process.outputStream, stdInputs)
+ }
+ return process
+ }
+
+ private fun consumeOutput(stream: InputStream, streamConsumer: Consumer) {
+ Thread {
+ try {
+ BufferedReader(
+ InputStreamReader(
+ stream,
+ StandardCharsets.UTF_8
+ )
+ ).use { bufferedReader ->
+ var line: String?
+ while (bufferedReader.readLine().also { line = it } != null) {
+ streamConsumer.accept(line)
+ }
+ }
+ } catch (e: IOException) {
+ LOG.error("Failure while processing process stdout/stderr.", e)
+ }
+ }.start()
+ }
+
+ private fun produceInput(stream: OutputStream, inputLines: Array) {
+ Thread {
+ // try with resource will close the OutputStream automatically,
+ // usually the process terminal will also be finished then.
+ try {
+ PrintStream(stream, true, StandardCharsets.UTF_8.name()).use { printStream ->
+ for (line in inputLines) {
+ printStream.println(line)
+ }
+ }
+ } catch (e: IOException) {
+ LOG.error("Failure while processing process stdin.", e)
+ }
+ }.start()
+ }
+
+ private fun concat(first: Array, second: Array): Array {
+ val result = Arrays.copyOf(first, first.size + second.size)
+ System.arraycopy(second, 0, result, first.size, second.size)
+ return result
+ }
+
+ fun setStdoutProcessor(stdoutProcessor: Consumer) {
+ this.stdoutProcessor = stdoutProcessor
+ }
+
+ companion object {
+ private const val serialVersionUID = 1L
+ private val LOG = LoggerFactory.getLogger(AutoClosableProcess::class.java)
+ private val WIN_RUNTIME = arrayOf("cmd.exe", "/C")
+ private val OS_LINUX_RUNTIME = arrayOf("/bin/bash", "-l", "-c")
+
+ private val aPid = AtomicLong(0)
+ private lateinit var process: Process
+
+ @Throws(IOException::class)
+ fun runNonBlocking(vararg commands: String) {
+ return create(*commands).build().runNonBlocking()
+ }
+
+ @Throws(IOException::class)
+ fun runBlocking(vararg commands: String) {
+ create(*commands).build().runBlocking()
+ }
+
+ fun create(vararg commands: String): AutoClosableProcessBuilder {
+ return AutoClosableProcessBuilder(arrayOf(*commands))
+ }
+ }
+}
\ 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..57fbf4b
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/LogCatErrors.kt
@@ -0,0 +1,10 @@
+package inputs.adb
+
+sealed class LogCatErrors : Exception()
+object LogErrorNotEnabledForFA : LogCatErrors()
+object LogErrorDeviceNotConnected : 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/LoggerCommand.kt b/src/main/kotlin/inputs/adb/LoggerCommand.kt
new file mode 100644
index 0000000..5316c26
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/LoggerCommand.kt
@@ -0,0 +1,42 @@
+package inputs.adb
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.ReceiveChannel
+import java.io.Serializable
+
+
+data class LoggerCommand(val packageName: String) : AdbCommand, Serializable {
+
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+
+ private var process: ReceiveChannel>? = null
+ private val logCommand =
+ "adb shell setprop log.tag.FA VERBOSE && adb shell setprop log.tag.FA-SVC VERBOSE &&" +
+ " pid=\$(adb shell pidof -s $packageName) && adb logcat -s FA FA-SVC --pid=\$pid -v long time"
+
+ suspend fun stream(scope: CoroutineScope): ReceiveChannel> {
+ val stream = SimpleAdbProcess().stream(scope, createCommands())
+ process = stream
+ return stream
+ }
+
+ private fun createCommands(): Array {
+ val currentDevice = AdbUtils.currentDeviceFlow.value
+ val deviceParam = if (currentDevice.isNullOrBlank()) {
+ ""
+ } else {
+ "-s $currentDevice"
+ }
+ return arrayOf(
+ "adb $deviceParam shell setprop log.tag.FA VERBOSE && adb $deviceParam shell setprop log.tag.FA-SVC VERBOSE &&" +
+ " pid=\$(adb $deviceParam shell pidof -s $packageName) && adb $deviceParam logcat -s FA FA-SVC --pid=\$pid -v long time"
+ )
+ }
+
+ fun close() {
+ process?.cancel()
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/LoggerCommandAdam.kt b/src/main/kotlin/inputs/adb/LoggerCommandAdam.kt
new file mode 100644
index 0000000..470bdf4
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/LoggerCommandAdam.kt
@@ -0,0 +1,43 @@
+package inputs.adb
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.consumeAsFlow
+import java.io.Serializable
+
+
+data class LoggerCommandAdam(val packageName: String) : AdbCommand, Serializable {
+
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+
+ private var process: ReceiveChannel? = null
+ private val logCommand =
+ "adb shell setprop log.tag.FA VERBOSE && adb shell setprop log.tag.FA-SVC VERBOSE &&" +
+ " pid=\$(adb shell pidof -s $packageName) && adb logcat -s FA FA-SVC --pid=\$pid -v long time"
+
+ suspend fun init(scope: CoroutineScope): Result> {
+ val monitorLogs = AdbUtils.monitorLogs(scope, packageName)
+ process = monitorLogs.getOrNull()
+ return monitorLogs
+ }
+
+ suspend fun stream(onNewLog : (msg : String?) -> Unit) {
+// process?.let {
+// for (e in it) {
+// onNewLog(e)
+// }
+// }
+ process?.consumeAsFlow()?.collect {
+// println("logs: $it")
+ onNewLog(it)
+ }
+ }
+
+ fun close() {
+ process?.cancel()
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/SimpleAdbProcess.kt b/src/main/kotlin/inputs/adb/SimpleAdbProcess.kt
new file mode 100644
index 0000000..bbef02a
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/SimpleAdbProcess.kt
@@ -0,0 +1,79 @@
+package inputs.adb
+
+import com.github.pgreze.process.Redirect
+import com.github.pgreze.process.process
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.channels.produce
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+import utils.Log
+import java.io.Serializable
+
+class SimpleAdbProcess : Serializable {
+
+ companion object {
+ private const val serialVersionUID = 1L
+ private val WIN_RUNTIME = arrayOf("cmd.exe", "/C")
+ private val OS_LINUX_RUNTIME = arrayOf("/bin/bash", "-l", "-c")
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ suspend fun stream(scope: CoroutineScope, commands: Array): ReceiveChannel> {
+ val isLinux = !(System.getProperty("os.name").contains("Windows"))
+ val baseCommand = if (isLinux) OS_LINUX_RUNTIME else WIN_RUNTIME
+ val allCommands = (baseCommand + commands)
+ return scope.produce {
+ if (AdbUtils.currentDeviceFlow.value.isNullOrBlank()) {
+ send(Result.failure(LogErrorDeviceNotConnected))
+ return@produce
+ }
+ var errorConsumed = false
+ val result = process(*allCommands, stdout = Redirect.Consume { flow: Flow ->
+ flow.collect {
+ yield()
+ send(Result.success(it))
+ }
+ },
+ stderr = Redirect.Consume { flow: Flow ->
+ errorConsumed = true
+ val error = flow.map {
+ yield()
+ it
+ }.firstOrNull()
+ Log.d("ConsoleError", "Some error = $error")
+ if (error.isNullOrBlank()) {
+ send(Result.failure(LogErrorPackageIssue))
+ } else {
+ send(Result.failure(LogErrorUnknown(error)))
+ }
+ }) {
+ send(Result.success(it))
+ }.resultCode
+ println("Result Code of simple process : $result")
+ if (!errorConsumed && result != 0) {
+ send(Result.failure(LogErrorUnknown("There are some errors in getting logs")))
+ }
+ flushLogs(baseCommand)
+ awaitClose {
+ scope.launch {
+ flushLogs(baseCommand)
+ }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private suspend fun flushLogs(baseCommand: Array) {
+ try {
+ Log.d("Logcat Clear", "Clearing logs")
+ process(*baseCommand, "adb logcat -c")
+ } catch (e: Exception) {
+ Log.d("Logcat Clear", "Error: ${e.message}")
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/StreamingLogcatRequest.kt b/src/main/kotlin/inputs/adb/StreamingLogcatRequest.kt
new file mode 100644
index 0000000..309ce02
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/StreamingLogcatRequest.kt
@@ -0,0 +1,48 @@
+package inputs.adb
+
+import com.malinskiy.adam.request.logcat.LogcatBuffer
+import com.malinskiy.adam.request.logcat.LogcatFilterSpec
+import com.malinskiy.adam.request.logcat.LogcatReadMode
+import com.malinskiy.adam.request.shell.v1.ChanneledShellCommandRequest
+import java.time.Instant
+
+class StreamingLogcatRequest(
+ since: Instant? = null,
+ modes: List = listOf(LogcatReadMode.long),
+ buffers: List = emptyList(),
+ pid: Long? = null,
+ lastReboot: Boolean? = null,
+ filters: List = emptyList(),
+ defaultFilterToSilent: Boolean = true
+) : StreamingShellCommandRequest(
+ cmd = command(since, modes, buffers, pid, lastReboot, defaultFilterToSilent, filters),
+ socketIdleTimeout = Long.MAX_VALUE,
+)
+
+private fun command(
+ since: Instant?,
+ modes: List,
+ buffers: List,
+ pid: Long?,
+ lastReboot: Boolean?,
+ defaultFilterToSilent: Boolean,
+ filters: List
+): String {
+ val s = "logcat" +
+ (since?.let {
+ " -T ${since.toEpochMilli()}.0"
+ } ?: "") +
+ " ${modes.joinToString(separator = " ") { "-v $it" }}" +
+ if (buffers.isNotEmpty()) {
+ " ${buffers.joinToString(separator = " ") { "-b $it" }}"
+ } else {
+ ""
+ } +
+ (pid?.let { " --pid=$it" } ?: "") +
+ (lastReboot?.let { " -L" } ?: "") +
+ (if (defaultFilterToSilent) " -s" else "") +
+ " ${filters.joinToString(separator = " ") { "${it.tag}:${it.level.name}" }}"
+ .trimEnd()
+ println(s)
+ return s
+}
\ No newline at end of file
diff --git a/src/main/kotlin/inputs/adb/StreamingShellCommandRequest.kt b/src/main/kotlin/inputs/adb/StreamingShellCommandRequest.kt
new file mode 100644
index 0000000..40227b8
--- /dev/null
+++ b/src/main/kotlin/inputs/adb/StreamingShellCommandRequest.kt
@@ -0,0 +1,60 @@
+package inputs.adb
+
+import com.malinskiy.adam.Const
+import com.malinskiy.adam.request.AsyncChannelRequest
+import com.malinskiy.adam.request.NonSpecifiedTarget
+import com.malinskiy.adam.request.Target
+import com.malinskiy.adam.transport.ByteBufferPool
+import com.malinskiy.adam.transport.Socket
+import kotlinx.coroutines.channels.SendChannel
+import java.io.BufferedReader
+import java.io.ByteArrayInputStream
+import java.io.InputStreamReader
+import java.nio.ByteBuffer
+
+
+open class StreamingShellCommandRequest(
+ private val cmd: String,
+ target: Target = NonSpecifiedTarget,
+ socketIdleTimeout: Long? = null
+) : AsyncChannelRequest(target = target, socketIdleTimeout = socketIdleTimeout) {
+
+ companion object {
+ val MaxFilePacketPool: ByteBufferPool =
+ ByteBufferPool(poolSize = Const.DEFAULT_BUFFER_SIZE, bufferSize = Const.MAX_FILE_PACKET_LENGTH * 100)
+ }
+
+ override suspend fun readElement(socket: Socket, sendChannel: SendChannel): Boolean {
+ withMaxFilePacketBuffer {
+ val data = array()
+ val count = socket.readAvailable(data, 0, data.size)
+ when {
+ count > 0 -> {
+ val stream = ByteArrayInputStream(data)
+ val streamReader = InputStreamReader(stream, Const.DEFAULT_TRANSPORT_ENCODING)
+ val bufferedReader = BufferedReader(streamReader)
+ var line: String?
+ while (bufferedReader.readLine().also { line = it } != null) {
+ sendChannel.send(line?:"")
+ }
+ }
+ count == -1 -> return true
+ else -> Unit
+ }
+ return false
+ }
+ }
+
+ private inline fun withMaxFilePacketBuffer(block: ByteBuffer.() -> R): R {
+ val instance = MaxFilePacketPool.borrow()
+ return try {
+ block(instance)
+ } finally {
+ println("Buffer clear")
+ MaxFilePacketPool.recycle(instance)
+ }
+ }
+
+ override fun serialize() = createBaseRequest("shell:$cmd")
+ override suspend fun writeElement(element: Unit, socket: Socket) = Unit
+}
\ 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/models/DeviceDetails.kt b/src/main/kotlin/models/DeviceDetails.kt
new file mode 100644
index 0000000..0408309
--- /dev/null
+++ b/src/main/kotlin/models/DeviceDetails.kt
@@ -0,0 +1,17 @@
+package models
+
+import com.malinskiy.adam.request.device.DeviceState
+
+data class DeviceDetails(
+ val serial: String,
+ val name: String,
+ val state: DeviceState,
+) {
+ fun isOnline() : Boolean {
+ return (state == DeviceState.DEVICE)
+ }
+
+ fun stateText() : String {
+ return if (isOnline()) "Connected" else "Offline"
+ }
+}
diff --git a/src/main/kotlin/models/Filter.kt b/src/main/kotlin/models/Filter.kt
new file mode 100644
index 0000000..d5201f1
--- /dev/null
+++ b/src/main/kotlin/models/Filter.kt
@@ -0,0 +1,9 @@
+package models
+
+import java.io.Serializable
+
+data class Filter(val key : String, val value : Any, val operation : FilterOperation = FilterOperation.OpEqual) : Serializable {
+ companion object {
+ private const val serialVersionUID = 1L
+ }
+}
diff --git a/src/main/kotlin/models/FilterOperation.kt b/src/main/kotlin/models/FilterOperation.kt
new file mode 100644
index 0000000..e822349
--- /dev/null
+++ b/src/main/kotlin/models/FilterOperation.kt
@@ -0,0 +1,31 @@
+package models
+
+enum class FilterOperation(val opString : String, val opDescriptor : String) {
+ OpEqual("=", "Equals");
+ // TODO: Add capability for more like >,<, >= , <=, !=
+
+ companion object {
+
+ fun getOpList() = values().map { it.opString }
+
+ fun getOp(sign : String): FilterOperation {
+ val s = sign.trim()
+ values().forEach {
+ if (it.opString == s) return it
+ }
+ return OpEqual
+ }
+
+ fun filter(propertyValue : Any, filter: Filter) : Boolean {
+ return when(filter.operation) {
+ OpEqual -> {
+ filter.value == propertyValue
+ }
+ else -> false
+ }
+ }
+ }
+}
+
+
+
diff --git a/src/main/kotlin/models/InternalContent.kt b/src/main/kotlin/models/InternalContent.kt
new file mode 100644
index 0000000..b3beb56
--- /dev/null
+++ b/src/main/kotlin/models/InternalContent.kt
@@ -0,0 +1,5 @@
+package models
+
+sealed interface InternalContent
+
+object NoLogsContent : 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/LogItem.kt b/src/main/kotlin/models/LogItem.kt
new file mode 100644
index 0000000..dacd9ff
--- /dev/null
+++ b/src/main/kotlin/models/LogItem.kt
@@ -0,0 +1,24 @@
+package models
+
+import androidx.compose.ui.text.AnnotatedString
+import java.io.Serializable
+
+data class LogItem(
+ val source: ItemSource,
+ val eventName: String,
+ val properties: HashMap = hashMapOf(),
+ val localTime : Long = System.currentTimeMillis(),
+ val internalContent : InternalContent? = null
+) : Serializable {
+ companion object {
+ private const val serialVersionUID = 1L
+
+ val NoContent = LogItem(SourceInternalContent, "No Logs", internalContent = NoLogsContent)
+
+ }
+
+ var propertiesAString : AnnotatedString? = null
+ var isSelected : Boolean = false
+
+ fun key() = "${source.type}_${eventName}_$localTime"
+}
diff --git a/src/main/kotlin/processor/DbSinkFunction.kt b/src/main/kotlin/processor/DbSinkFunction.kt
new file mode 100644
index 0000000..534a513
--- /dev/null
+++ b/src/main/kotlin/processor/DbSinkFunction.kt
@@ -0,0 +1,26 @@
+package processor
+
+import models.LogItem
+import models.SourceInternalContent
+import org.apache.flink.configuration.Configuration
+import org.apache.flink.streaming.api.functions.sink.RichSinkFunction
+import org.apache.flink.streaming.api.functions.sink.SinkFunction
+import storage.Db
+
+
+class DbSinkFunction : RichSinkFunction() {
+
+ override fun open(parameters: Configuration?) {
+ super.open(parameters)
+ }
+
+ override fun close() {
+ super.close()
+ }
+
+ override fun invoke(value: LogItem?, context: SinkFunction.Context?) {
+ super.invoke(value, context)
+ if (value == null || value.source is SourceInternalContent) return
+ Db.currentSession()[value.key()] = value
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/processor/FlinkProcessor.kt b/src/main/kotlin/processor/FlinkProcessor.kt
new file mode 100644
index 0000000..02bf225
--- /dev/null
+++ b/src/main/kotlin/processor/FlinkProcessor.kt
@@ -0,0 +1,113 @@
+package processor
+
+import inputs.adb.AdbStreamer
+import inputs.adb.LogCatErrors
+import inputs.adb.LogErrorUnknown
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import models.LogItem
+import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment
+import storage.Db
+import utils.Helpers
+import utils.Log
+
+class FlinkProcessor(packageName: String) {
+
+ private val streamer = AdbStreamer(packageName)
+
+ private val env by lazy {
+ val e = StreamExecutionEnvironment.getExecutionEnvironment()
+ e.parallelism = 1
+ e
+ }
+
+ suspend fun getSessions() = withContext(Dispatchers.IO) {
+ Db.getAllSessions().asReversed()
+ }
+
+ fun startOldSession(session: String) {
+ pause()
+ Db.changeSession(session)
+ }
+
+ fun createNewSession() {
+ pause()
+ Db.createNewSession()
+ }
+
+ fun deleteSession(sessionId: String) {
+ if (sessionId == getCurrentSessionId()) {
+ pause()
+ }
+ Db.deleteSession(sessionId)
+ }
+
+ fun getCurrentSessionId() = Db.sessionId()
+
+ suspend fun fetchOldStream(onMessage: (msg: LogItem) -> Unit) = withContext(Dispatchers.IO) {
+ val filterFunction = ParamFilterFunction()
+ val lastItems = Db.currentSession().filter {
+ val value = it.value
+ Db.parameterSet.addAll(value.properties.keys)
+ filterFunction.filter(value)
+ }.map { it.value }.sortedBy { it.localTime }
+ val oldStream = if (lastItems.isNotEmpty()) {
+ env.fromCollection(lastItems)
+ } else {
+ null
+ }
+ if (oldStream != null) {
+ uiSink(oldStream, onMessage)
+ } else {
+ onMessage(LogItem.NoContent)
+ }
+ }
+
+ suspend fun observeNewStream(
+ onError: (logError: LogCatErrors) -> Unit,
+ onMessage: (msg: LogItem) -> Unit
+ ) = withContext(Dispatchers.IO) {
+ val source = env.addSource(streamer)
+ val newStream = source
+ .filter { it.isSuccess }
+ .map { it.getOrNull() ?: "" }
+ .returns(String::class.java)
+ .filter { Helpers.validateFALogString(it) }
+// .map { Helpers.cutLogString(it) }
+ .returns(String::class.java)
+ .map { Helpers.parseFALogs(it) }
+ .returns(LogItem::class.java)
+ newStream.addSink(DbSinkFunction())
+ uiSink(newStream, onMessage)
+
+ // Error Handling
+ source
+ .filter { it.isFailure }
+ .map { (it.exceptionOrNull() as? LogCatErrors) ?: LogErrorUnknown() }
+ .returns(LogCatErrors::class.java)
+ .executeAndCollect()
+ .forEachRemaining { onError(it) }
+ }
+
+ private fun uiSink(logItemStream: SingleOutputStreamOperator, onMessage: (msg: LogItem) -> Unit) {
+ logItemStream
+ .map {
+ val pString = Helpers.createAnnotatedString(it.properties)
+ it.propertiesAString = pString
+ it
+ }
+ .returns(LogItem::class.java)
+ .filter(ParamFilterFunction())
+ .executeAndCollect().forEachRemaining(onMessage)
+ }
+
+ fun pause() {
+ try {
+ streamer.close()
+ } 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/ParamFilterFunction.kt b/src/main/kotlin/processor/ParamFilterFunction.kt
new file mode 100644
index 0000000..d1efecf
--- /dev/null
+++ b/src/main/kotlin/processor/ParamFilterFunction.kt
@@ -0,0 +1,27 @@
+package processor
+
+import models.FilterOperation
+import models.LogItem
+import org.apache.flink.api.common.functions.FilterFunction
+import storage.Db
+
+class ParamFilterFunction : FilterFunction {
+
+ private val paramsForFilter
+ get() = Db.getSessionFilters()
+
+ override fun filter(value: LogItem): Boolean {
+ val properties = value.properties
+ val paramsForFilter1 = paramsForFilter
+ if (paramsForFilter1.isEmpty()) {
+ return true
+ }
+ paramsForFilter1.forEach { filter ->
+ val propertyValue = properties[filter.key] ?: return@forEach
+ if (FilterOperation.filter(propertyValue, filter)) {
+ return true
+ }
+ }
+ return false
+ }
+}
\ 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..e6978d3
--- /dev/null
+++ b/src/main/kotlin/storage/Db.kt
@@ -0,0 +1,157 @@
+package storage
+
+import models.Filter
+import models.ItemSource
+import models.LogItem
+import models.SourceInternalContent
+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 lateinit var session: HTreeMap
+ private lateinit var sessionId: String
+ private val LOCK = Any()
+
+ private val filters by lazy {
+ val filterDb = DBMaker.fileDB("filters").fileMmapEnableIfSupported().checksumHeaderBypass().make()
+ filterDb.hashMap("filters", Serializer.STRING, ObjectSerializer>())
+ .counterEnable().createOrOpen()
+ }
+
+ val parameterSet by lazy {
+ val memoryDb = DBMaker.memoryDB().closeOnJvmShutdown().cleanerHackEnable().make()
+ memoryDb.hashSet("allParamSet", Serializer.STRING).createOrOpen()
+ }
+
+ val configs by lazy {
+ db.hashMap("configs", Serializer.STRING, Serializer.STRING).createOrOpen()
+ }
+
+ init {
+ if (areNoSessionsCreated()) {
+ createNewSession()
+ } else {
+ getOrCreateSession(getPreviousSessionNumber())
+ }
+ }
+
+ fun getSessionFilters(): HashSet {
+ return filters.getOrPut(sessionId()) { hashSetOf() }
+ }
+
+ fun addFilterInCurrentSession(filter: Filter) {
+ val oldFilters = getSessionFilters()
+ oldFilters.add(filter)
+ filters[sessionId()] = oldFilters
+ }
+
+ fun deleteFilterInCurrentSession(filter: Filter) {
+ val oldFilters = getSessionFilters()
+ oldFilters.remove(filter)
+ filters[sessionId()] = oldFilters
+ }
+
+ 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() = getLastSessionNumber() == 0
+
+ fun createNewSession() {
+ val sessionNumber = getLastSessionNumber()
+ changeSession(sessionIdFromNumber(sessionNumber + 1))
+ }
+
+ fun deleteSession(sessionId : String) {
+ if (sessionId == sessionId()) {
+ val sessionNumber = getLastSessionNumber()
+ changeSession(sessionIdFromNumber(sessionNumber + 1))
+ }
+ val oldSession = db.hashMap(sessionId, Serializer.STRING, ObjectSerializer())
+ .open()
+ oldSession.clear()
+ filters.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): HTreeMap {
+ val number = getSessionNumber(sessionId)
+ parameterSet.clear()
+ getOrCreateSession(number)
+ return session
+ }
+
+ fun getOrCreateSession(sessionNumber: Int) {
+ if (sessionNumber < 1) throw Exception("Session number must be greater than 1")
+ synchronized(LOCK) {
+ 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..031208b
--- /dev/null
+++ b/src/main/kotlin/ui/CustomHeading.kt
@@ -0,0 +1,11 @@
+package ui
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.sp
+
+@Immutable
+data class CustomHeading(
+ val h3: TextStyle = TextStyle(fontSize = 28.sp),
+ val caption: TextStyle = TextStyle(fontSize = 12.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..ce82290
--- /dev/null
+++ b/src/main/kotlin/ui/Theme.kt
@@ -0,0 +1,171 @@
+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 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
+ )
+ } 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
+ )
+ }
+ 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(50), 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..6c4fca4
--- /dev/null
+++ b/src/main/kotlin/ui/components/ActionBar.kt
@@ -0,0 +1,60 @@
+package ui.components
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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())
+ })
+ } 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)
+ val PauseList = arrayListOf(ActionPause, ActionExport)
+ }
+}
+// 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/Play.svg")
+object ActionPause : ActionMenu("Pause", isPrimary = true, icon = "icons/Pause.svg")
+object ActionExport : ActionMenu("Export Session Data", isPrimary = false, icon = "icons/ico-share.svg")
\ 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..582c942
--- /dev/null
+++ b/src/main/kotlin/ui/components/BodyHeader.kt
@@ -0,0 +1,219 @@
+package ui.components
+
+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.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+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 models.Filter
+import models.FilterOperation
+import storage.Db
+import ui.CustomTheme
+import utils.Helpers
+
+private val signList = FilterOperation.getOpList()
+
+@Composable
+fun BodyHeader(
+ sessionId: String, availableParams: List, modifier: Modifier = Modifier,
+ filtersEnabled: Boolean = true,
+ onFilterUpdated: () -> Unit
+) {
+ Column(modifier) {
+ var filterItems by remember(sessionId) { mutableStateOf(Db.getSessionFilters().toList()) }
+ FilterSearchHeader(Modifier.fillMaxWidth(), sessionId, filtersEnabled, availableParams) {
+ onFilterUpdated()
+ filterItems = Db.getSessionFilters().toList()
+ }
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(8.dp)) {
+ items(filterItems, { item -> item.key + item.value }) {
+ // TODO: Change this to correct operation type
+ val text = "${it.key} ${it.operation.opString} ${it.value}"
+ Chip(
+ text, bgColor = CustomTheme.colors.highContrast,
+ textColor = CustomTheme.colors.componentBackground
+ ) {
+ IconButton({
+ Db.deleteFilterInCurrentSession(it)
+ onFilterUpdated()
+ filterItems = Db.getSessionFilters().toList()
+ }) {
+ Icon(
+ painterResource("icons/ico_close.xml"), "delete filter",
+ tint = CustomTheme.colors.componentBackground
+ )
+ }
+ }
+ }
+ }
+ }
+
+}
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+private fun FilterSearchHeader(
+ modifier: Modifier,
+ sessionId: String,
+ filtersEnabled: Boolean,
+ availableParams: List,
+ onFilterUpdated: () -> Unit
+) {
+ Box(modifier) {
+ var filterText by remember(sessionId) { mutableStateOf(TextFieldValue("")) }
+ var menuExpanded by remember(sessionId) { mutableStateOf(false) }
+ var showErrorDialog by remember(sessionId) { mutableStateOf(false) }
+ FilterSearchBar(
+ filterText,
+ Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp),
+ filtersEnabled,
+ {
+ HeaderEndIconsPanel()
+ }) {
+ filterText = it
+ menuExpanded = filterText.text.isNotBlank()
+ }
+ val spacesSplitted = filterText.text.split(" ")
+ val length = spacesSplitted.size
+ if (length == 0) return
+ DropdownMenu(
+ menuExpanded, { menuExpanded = false }, false,
+ Modifier.fillMaxWidth(0.3f).heightIn(80.dp, 200.dp)
+ ) {
+ CreateAutoSearchItems(availableParams, filterText, spacesSplitted, {
+ val filterCreated = createFilter(spacesSplitted)
+ if (filterCreated) {
+ menuExpanded = false
+ filterText = TextFieldValue()
+ onFilterUpdated()
+ } else {
+ showErrorDialog = true
+ }
+ }) {
+ filterText = if (length == 1) {
+ val combinedText = it
+ TextFieldValue(combinedText, TextRange(combinedText.length + 1))
+ } else {
+ val combinedText = filterText.text + it
+ TextFieldValue(combinedText, TextRange(combinedText.length + 1))
+ }
+ }
+ }
+
+ if (showErrorDialog) {
+ AlertDialog({
+ showErrorDialog = false
+ }, {
+ TextButton({ showErrorDialog = false }, Modifier.padding(16.dp)) {
+ Text("Ok")
+ }
+ }, Modifier.fillMaxSize(0.25f), title = { Text("Error in creating filter") },
+ text = { Text("Please select supported operations") })
+ }
+ }
+}
+
+fun createFilter(spacesSplitted: List): Boolean {
+ val length = spacesSplitted.size
+ if (length < 3) return false
+ val key = spacesSplitted[0].trim()
+ val sign = spacesSplitted[1].trim()
+ if (!signList.contains(sign)) return false
+ val value = spacesSplitted.takeLast(length - 2).joinToString(" ").trim()
+ val filter = Filter(key, value, FilterOperation.getOp(sign))
+ Db.addFilterInCurrentSession(filter)
+ return true
+}
+
+@Composable
+private fun CreateAutoSearchItems(
+ availableParams: List,
+ filterText: TextFieldValue,
+ spacesSplitted: List,
+ onAddFilterClick: () -> Unit,
+ onItemClick: (String) -> Unit
+) {
+ when (spacesSplitted.size) {
+ 1 -> {
+ availableParams.forEach { param ->
+ if (param.contains(filterText.text)) {
+ DropdownMenuItem({ onItemClick("$param ") }) {
+ Text(param)
+ }
+ }
+ }
+ }
+ 2 -> {
+ signList.forEach { param ->
+ DropdownMenuItem({ onItemClick("$param ") }) {
+ Text(param)
+ }
+ }
+ }
+ else -> {
+ DropdownMenuItem(onAddFilterClick) {
+ Icon(painterResource("icons/Filter.svg"), "Filter")
+ Spacer(Modifier.width(8.dp))
+ Text("Create filter")
+ }
+ }
+ }
+}
+
+@Composable
+private fun FilterSearchBar(
+ filterText: TextFieldValue,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ endIcons: @Composable (() -> Unit)? = null,
+ onValueChange: (TextFieldValue) -> Unit
+) {
+ val colors = TextFieldDefaults.textFieldColors(
+ backgroundColor = CustomTheme.colors.componentBackground,
+ focusedIndicatorColor = Color.Unspecified,
+ unfocusedIndicatorColor = Color.Unspecified,
+ )
+ val placeholderText = 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")
+ Icon(painter, "Search filters", tint = CustomTheme.colors.highContrast)
+ }, trailingIcon = endIcons, shape = RectangleShape, colors = colors, singleLine = true
+ )
+}
+
+@Composable
+private fun HeaderEndIconsPanel() {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ IconButton({}) { // TODO: Settings
+ val painter = painterResource("icons/ico-settings.svg")
+ Icon(painter, "Settings", tint = CustomTheme.colors.highContrast)
+ }
+ DarkThemeSwitch()
+ }
+}
+
+@Composable
+private fun DarkThemeSwitch() {
+ var switched by remember { mutableStateOf(!Helpers.isThemeLightMode.value) }
+ Switch(switched, {
+ switched = it
+ Helpers.switchThemes(!it)
+ })
+}
\ 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..05c7423
--- /dev/null
+++ b/src/main/kotlin/ui/components/BodyPanel.kt
@@ -0,0 +1,284 @@
+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 kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import models.LogItem
+import models.SourceInternalContent
+import processor.FlinkProcessor
+import storage.Db
+import ui.CustomTheme
+
+@Composable
+fun BodyPanel(
+ processor: FlinkProcessor,
+ sessionId: String,
+ modifier: Modifier = Modifier
+) {
+ val logItems = remember(sessionId) { mutableStateListOf() }
+ var streamRunning by remember(sessionId) { mutableStateOf(false) }
+ var paramItems by remember(sessionId) { mutableStateOf(Db.parameterSet.toList()) }
+ 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 AdbUtils.currentDeviceFlow.collectAsState()
+ var errorString by remember(currentDevice) {
+ mutableStateOf(if (currentDevice.isNullOrBlank()) "No device is connected" else "")
+ }
+ val onNewMessage: (msg: LogItem) -> Unit = { msg ->
+ paramItems = Db.parameterSet.toList().sorted()
+ logItems.add(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 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() {
+ fetchOldData(processor, scope) {
+ onNewMessage(it)
+ if (logItems.isNotEmpty()) {
+ scope.launch {
+ state.scrollToItem((logItems.size - 1).coerceAtLeast(0))
+ }
+ }
+ }
+ }
+ BodyHeader(
+ sessionId, paramItems,
+ Modifier.fillMaxWidth().background(CustomTheme.colors.componentBackground),
+ !streamRunning
+ ) {
+ logItems.clear()
+ oldStreamFun()
+ }
+ if (errorString.isNotBlank()) {
+ ErrorBar(errorString)
+ }
+ ActionBarMenu(actionMenuItems, {
+ streamData(processor, scope, onError, onNewMessage)
+ actionMenuItems = ActionMenu.PauseList
+ streamRunning = true
+ errorString = ""
+ }, {
+ pauseProcessor(processor)
+ actionMenuItems = ActionMenu.DefaultList
+ streamRunning = false
+ })
+ Text(
+ "Analytics Logs", Modifier.padding(24.dp),
+ style = CustomTheme.typography.headings.h3
+ )
+ 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 = 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 ActionBarMenu(actionMenuItems: List, onStreamStart: () -> Unit, onStreamPause: () -> Unit) {
+ ActionBar(
+ actionMenuItems, Modifier.fillMaxWidth()
+ .background(CustomTheme.colors.componentBackground)
+ .padding(horizontal = 24.dp, vertical = 8.dp)
+ ) {
+ when (it) {
+ ActionExport -> {
+ // TODO: Export action
+ }
+ ActionPause -> {
+ onStreamPause()
+ }
+ ActionStart -> {
+ onStreamStart()
+ }
+ }
+ }
+}
+
+@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: FlinkProcessor, scope: CoroutineScope,
+ onError: (logError: LogCatErrors) -> Unit, onMessage: (msg: LogItem) -> Unit
+) {
+ scope.launch {
+ processor.observeNewStream(onError) { msg ->
+// Log.d("Got Message" , msg)
+ onMessage(msg)
+ }
+ }
+}
+
+private fun fetchOldData(processor: FlinkProcessor, scope: CoroutineScope, onMessage: (msg: LogItem) -> Unit) {
+ scope.launch {
+ processor.fetchOldStream { msg ->
+// Log.d("Got Message" , msg)
+ onMessage(msg)
+ }
+ }
+}
+
+private fun pauseProcessor(processor: FlinkProcessor) {
+ 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..c730a0f
--- /dev/null
+++ b/src/main/kotlin/ui/components/Chip.kt
@@ -0,0 +1,53 @@
+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.text.AnnotatedString
+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
+) {
+ Chip(AnnotatedString(text), modifier, bgColor, textColor, addBorder, icon)
+}
+
+@Composable
+fun Chip(
+ text: AnnotatedString, modifier: Modifier = Modifier,
+ bgColor: Color = LocalCustomColors.current.componentBackground,
+ textColor: Color = LocalCustomColors.current.highContrast,
+ addBorder: Boolean = false,
+ icon: @Composable (() -> Unit)? = null,
+) {
+ var modifier1 = modifier.wrapContentSize().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/DetailCard.kt b/src/main/kotlin/ui/components/DetailCard.kt
new file mode 100644
index 0000000..9ec7306
--- /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_illustration.webp"),
+ "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/Copy.svg"), "Copy",
+ tint = CustomTheme.colors.highContrast
+ )
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/ui/components/ListItemInternalContent.kt b/src/main/kotlin/ui/components/ListItemInternalContent.kt
new file mode 100644
index 0000000..41d908f
--- /dev/null
+++ b/src/main/kotlin/ui/components/ListItemInternalContent.kt
@@ -0,0 +1,34 @@
+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.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import models.InternalContent
+import models.NoLogsContent
+
+@Composable
+fun ListItemInternalContent(internalContent: InternalContent?, modifier: Modifier = Modifier) {
+ if (internalContent == null) return
+ when (internalContent) {
+ NoLogsContent -> ListItemEmptyContent(modifier)
+ }
+}
+
+@Composable
+fun ListItemEmptyContent(modifier: Modifier = Modifier) {
+ Card(modifier) {
+ Column(Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Image(painterResource("icons/empty_state.svg"), "Use start to log events",
+ Modifier.fillMaxWidth(0.7f))
+ Spacer(Modifier.height(16.dp))
+ Text("Record logs using the Start button above", textAlign = TextAlign.Center)
+ }
+ }
+}
diff --git a/src/main/kotlin/ui/components/LogCard.kt b/src/main/kotlin/ui/components/LogCard.kt
new file mode 100644
index 0000000..28c48e6
--- /dev/null
+++ b/src/main/kotlin/ui/components/LogCard.kt
@@ -0,0 +1,120 @@
+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.AnnotatedString
+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 models.SourceInternalContent
+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 = AnnotatedString("$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/SideNavigation.kt b/src/main/kotlin/ui/components/SideNavigation.kt
new file mode 100644
index 0000000..22d7f14
--- /dev/null
+++ b/src/main/kotlin/ui/components/SideNavigation.kt
@@ -0,0 +1,153 @@
+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.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import inputs.adb.AdbUtils
+import kotlinx.coroutines.launch
+import models.DeviceDetails
+import processor.FlinkProcessor
+import ui.CustomTheme
+
+@Composable
+fun SideNavigation(
+ processor: FlinkProcessor, sessionId: String, modifier: Modifier = Modifier,
+ onSessionChange: (sessionId: String) -> Unit
+) {
+ var sessions by remember { mutableStateOf>(arrayListOf()) }
+ val scope = rememberCoroutineScope()
+ LaunchedEffect(sessionId) {
+ sessions = processor.getSessions()
+ }
+ Column(modifier) {
+ Row(
+ Modifier.padding(32.dp), verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Image(painterResource("icons/logo.png"), "goFlog", Modifier.size(40.dp))
+ Text("GoFlog", fontSize = 16.sp)
+ }
+ Text(
+ "Sessions", Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ style = CustomTheme.typography.headings.h3
+ )
+
+ Button(
+ {
+ processor.createNewSession()
+ onSessionChange(processor.getCurrentSessionId())
+ }, 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))
+ }
+ }
+
+ SessionsList(sessions, processor, Modifier.fillMaxHeight(0.5f).padding(vertical = 16.dp), onSessionChange) {
+ scope.launch {
+ sessions = processor.getSessions()
+ }
+ }
+
+ Divider(Modifier.height(1.dp).fillMaxWidth().background(Color.LightGray.copy(alpha = 0.3f)))
+
+ Text(
+ "Devices", Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
+ style = CustomTheme.typography.headings.h3
+ )
+
+ DeviceList(Modifier.fillMaxHeight().padding(vertical = 16.dp))
+ }
+}
+
+@Composable
+private fun DeviceList(modifier: Modifier = Modifier) {
+ val scope = rememberCoroutineScope()
+ val devices by AdbUtils.monitorDevices(scope).collectAsState(emptyList())
+ val currentDeviceSelected by AdbUtils.currentDeviceFlow.collectAsState()
+ if (devices.isEmpty()) {
+ scope.launch {
+ AdbUtils.setCurrentDevice(null)
+ }
+ } else if (devices.size == 1) {
+ scope.launch {
+ AdbUtils.setCurrentDevice(devices.first().serial)
+ }
+ }
+ LazyColumn(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(devices, { item: DeviceDetails -> item.serial }) {
+ val shape = RoundedCornerShape(0, 50, 50, 0)
+ var modifier1 = Modifier.clip(shape).clickable {
+ AdbUtils.setCurrentDevice(it.serial)
+ }.fillMaxWidth(0.8f)
+ if (it.serial == currentDeviceSelected) {
+ modifier1 = modifier1.background(
+ CustomTheme.colors.accent.copy(0.4f),
+ shape
+ )
+ }
+ modifier1 = modifier1.padding(start = 24.dp, top = 8.dp, bottom = 8.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)
+ }
+ }
+ }
+}
+
+@Composable
+private fun SessionsList(
+ sessions: List,
+ processor: FlinkProcessor,
+ modifier: Modifier = Modifier,
+ onSessionChange: (sessionId: String) -> Unit,
+ onSessionDelete: () -> Unit
+) {
+ LazyColumn(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(sessions, key = { item: String -> item }) {
+ val currentSession = processor.getCurrentSessionId()
+ val shape = RoundedCornerShape(0, 50, 50, 0)
+ val isThisCurrentSession = currentSession == it
+ var modifier1 = Modifier.clip(shape).clickable {
+ processor.startOldSession(it)
+ onSessionChange(processor.getCurrentSessionId())
+ }.fillMaxWidth(0.8f)
+ 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(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(it, modifier1)
+ IconButton({
+ processor.deleteSession(it)
+ onSessionDelete()
+ }) {
+ Icon(painterResource("icons/ico_close.xml"), "delete session")
+ }
+ }
+ }
+ }
+}
\ 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/Helpers.kt b/src/main/kotlin/utils/Helpers.kt
new file mode 100644
index 0000000..a936a05
--- /dev/null
+++ b/src/main/kotlin/utils/Helpers.kt
@@ -0,0 +1,255 @@
+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.LogItem
+import models.SourceFA
+import org.snakeyaml.engine.v2.api.Dump
+import org.snakeyaml.engine.v2.api.DumpSettings
+import org.snakeyaml.engine.v2.common.ScalarStyle
+import storage.Db
+
+
+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 = 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(rawText: String): LogItem {
+ val cut1 = rawText.removePrefix(faPrefix)
+ val eventParamsCutter = cut1.split(Regex(","), 2)
+ val eventName = eventParamsCutter[0].trim()
+ val properties = hashMapOf()
+ eventParamsCutter.getOrNull(1)?.trim()?.let {
+ val objectItem = Item.ObjectItem(it.trim())
+ val something = objectMapper.parse(objectItem) as HashMap
+ properties.putAll(something)
+ Db.parameterSet.addAll(properties.keys)
+ }
+ return LogItem(SourceFA, eventName, properties)
+ }
+
+ 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): String? {
+ return try {
+ val dump = Dump(settings)
+ dump.dumpToString(properties)
+ } catch (e: Exception) {
+ Log.d("YamlConverter", e.localizedMessage)
+ null
+ }
+ }
+
+ @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
+ }
+
+}
\ 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..8f39bd5
--- /dev/null
+++ b/src/main/kotlin/utils/Item.kt
@@ -0,0 +1,194 @@
+package utils
+
+import org.apache.flink.shaded.guava30.com.google.common.base.Joiner
+import org.apache.flink.shaded.guava30.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/Copy.svg b/src/main/resources/icons/Copy.svg
new file mode 100644
index 0000000..baafae6
--- /dev/null
+++ b/src/main/resources/icons/Copy.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/Filter.svg b/src/main/resources/icons/Filter.svg
new file mode 100644
index 0000000..dae7546
--- /dev/null
+++ b/src/main/resources/icons/Filter.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/Firebase_Logo.svg b/src/main/resources/icons/Firebase_Logo.svg
new file mode 100644
index 0000000..33bf238
--- /dev/null
+++ b/src/main/resources/icons/Firebase_Logo.svg
@@ -0,0 +1,70 @@
+
+
\ No newline at end of file
diff --git a/src/main/resources/icons/Pause.svg b/src/main/resources/icons/Pause.svg
new file mode 100644
index 0000000..c27ba8c
--- /dev/null
+++ b/src/main/resources/icons/Pause.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/main/resources/icons/Play.svg b/src/main/resources/icons/Play.svg
new file mode 100644
index 0000000..57adf5d
--- /dev/null
+++ b/src/main/resources/icons/Play.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/main/resources/icons/View.svg b/src/main/resources/icons/View.svg
new file mode 100644
index 0000000..7ebc74a
--- /dev/null
+++ b/src/main/resources/icons/View.svg
@@ -0,0 +1,5 @@
+
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/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-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..8ee27c7
--- /dev/null
+++ b/src/main/resources/icons/ico-share.svg
@@ -0,0 +1,3 @@
+
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/logo.png b/src/main/resources/icons/logo.png
new file mode 100644
index 0000000..1a7109e
Binary files /dev/null and b/src/main/resources/icons/logo.png differ
diff --git a/src/main/resources/icons/waiting_illustration.webp b/src/main/resources/icons/waiting_illustration.webp
new file mode 100644
index 0000000..883c03c
Binary files /dev/null and b/src/main/resources/icons/waiting_illustration.webp differ