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