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 @@ + + + + logo_lockup_firebase_vertical + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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